|
| 1 | +import { useEffect, useState } from 'react' |
| 2 | +import { motion as Motion, AnimatePresence } from 'framer-motion' |
| 3 | + |
| 4 | +/** Clock that updates every second for the lock screen */ |
| 5 | +function LockClock() { |
| 6 | + const [now, setNow] = useState(() => new Date()) |
| 7 | + |
| 8 | + useEffect(() => { |
| 9 | + const id = setInterval(() => setNow(new Date()), 1000) |
| 10 | + return () => clearInterval(id) |
| 11 | + }, []) |
| 12 | + |
| 13 | + const time = now.toLocaleTimeString([], { |
| 14 | + hour: '2-digit', |
| 15 | + minute: '2-digit', |
| 16 | + hour12: false, |
| 17 | + }) |
| 18 | + |
| 19 | + const date = now.toLocaleDateString([], { |
| 20 | + weekday: 'long', |
| 21 | + month: 'long', |
| 22 | + day: 'numeric', |
| 23 | + year: 'numeric', |
| 24 | + }) |
| 25 | + |
| 26 | + return ( |
| 27 | + <div className="flex flex-col items-center gap-1"> |
| 28 | + <span |
| 29 | + className="heading-display select-none tabular-nums" |
| 30 | + style={{ |
| 31 | + fontSize: 'clamp(4rem, 10vw, 7rem)', |
| 32 | + fontWeight: 900, |
| 33 | + color: 'hsl(56 100% 48%)', |
| 34 | + textShadow: |
| 35 | + '0 0 20px hsl(56 100% 48% / 0.6), 0 0 60px hsl(56 100% 48% / 0.2)', |
| 36 | + lineHeight: 1, |
| 37 | + letterSpacing: '0.04em', |
| 38 | + }} |
| 39 | + > |
| 40 | + {time} |
| 41 | + </span> |
| 42 | + <span |
| 43 | + className="heading-ui select-none" |
| 44 | + style={{ |
| 45 | + fontSize: 'clamp(0.85rem, 2vw, 1.1rem)', |
| 46 | + color: 'hsl(170 76% 63% / 0.85)', |
| 47 | + letterSpacing: '0.08em', |
| 48 | + textTransform: 'uppercase', |
| 49 | + }} |
| 50 | + > |
| 51 | + {date} |
| 52 | + </span> |
| 53 | + </div> |
| 54 | + ) |
| 55 | +} |
| 56 | + |
| 57 | +/** Blinking "click to unlock" hint */ |
| 58 | +function UnlockHint() { |
| 59 | + return ( |
| 60 | + <Motion.p |
| 61 | + className="select-none font-mono text-xs" |
| 62 | + style={{ color: 'hsl(0 0% 60%)' }} |
| 63 | + animate={{ opacity: [1, 0.3, 1] }} |
| 64 | + transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }} |
| 65 | + > |
| 66 | + Click or press any key to unlock |
| 67 | + </Motion.p> |
| 68 | + ) |
| 69 | +} |
| 70 | + |
| 71 | +/** CSS-only animated circuit pattern rendered as a background div */ |
| 72 | +function CircuitBackground() { |
| 73 | + return ( |
| 74 | + <div |
| 75 | + className="pointer-events-none absolute inset-0" |
| 76 | + style={{ zIndex: 0 }} |
| 77 | + aria-hidden |
| 78 | + > |
| 79 | + {/* Base grid */} |
| 80 | + <div |
| 81 | + className="absolute inset-0" |
| 82 | + style={{ |
| 83 | + backgroundImage: ` |
| 84 | + linear-gradient(hsl(56 100% 48% / 0.04) 1px, transparent 1px), |
| 85 | + linear-gradient(90deg, hsl(56 100% 48% / 0.04) 1px, transparent 1px) |
| 86 | + `, |
| 87 | + backgroundSize: '60px 60px', |
| 88 | + }} |
| 89 | + /> |
| 90 | + {/* Denser sub-grid */} |
| 91 | + <div |
| 92 | + className="absolute inset-0" |
| 93 | + style={{ |
| 94 | + backgroundImage: ` |
| 95 | + linear-gradient(hsl(170 76% 63% / 0.02) 1px, transparent 1px), |
| 96 | + linear-gradient(90deg, hsl(170 76% 63% / 0.02) 1px, transparent 1px) |
| 97 | + `, |
| 98 | + backgroundSize: '20px 20px', |
| 99 | + }} |
| 100 | + /> |
| 101 | + {/* Animated horizontal scan highlight */} |
| 102 | + <Motion.div |
| 103 | + className="absolute inset-x-0 h-[1px]" |
| 104 | + style={{ |
| 105 | + background: |
| 106 | + 'linear-gradient(90deg, transparent 0%, hsl(56 100% 48% / 0.15) 30%, hsl(170 76% 63% / 0.12) 70%, transparent 100%)', |
| 107 | + }} |
| 108 | + animate={{ top: ['0%', '100%'] }} |
| 109 | + transition={{ |
| 110 | + duration: 8, |
| 111 | + repeat: Infinity, |
| 112 | + ease: 'linear', |
| 113 | + }} |
| 114 | + /> |
| 115 | + {/* Ambient orbs */} |
| 116 | + <div |
| 117 | + style={{ |
| 118 | + position: 'absolute', |
| 119 | + width: 600, |
| 120 | + height: 600, |
| 121 | + borderRadius: '50%', |
| 122 | + background: 'hsl(56 100% 48%)', |
| 123 | + opacity: 0.05, |
| 124 | + filter: 'blur(130px)', |
| 125 | + top: '-15%', |
| 126 | + right: '-10%', |
| 127 | + }} |
| 128 | + /> |
| 129 | + <div |
| 130 | + style={{ |
| 131 | + position: 'absolute', |
| 132 | + width: 700, |
| 133 | + height: 700, |
| 134 | + borderRadius: '50%', |
| 135 | + background: 'hsl(170 76% 63%)', |
| 136 | + opacity: 0.04, |
| 137 | + filter: 'blur(140px)', |
| 138 | + bottom: '-20%', |
| 139 | + left: '-15%', |
| 140 | + }} |
| 141 | + /> |
| 142 | + </div> |
| 143 | + ) |
| 144 | +} |
| 145 | + |
| 146 | +/** Pulsing neon "N" logo for the lock screen */ |
| 147 | +function LockLogo() { |
| 148 | + return ( |
| 149 | + <Motion.div |
| 150 | + style={{ |
| 151 | + width: 80, |
| 152 | + height: 80, |
| 153 | + borderRadius: 16, |
| 154 | + display: 'flex', |
| 155 | + alignItems: 'center', |
| 156 | + justifyContent: 'center', |
| 157 | + background: 'rgba(0,0,0,0.7)', |
| 158 | + border: '1px solid hsl(56 100% 48% / 0.35)', |
| 159 | + position: 'relative', |
| 160 | + }} |
| 161 | + animate={{ |
| 162 | + boxShadow: [ |
| 163 | + '0 0 15px hsl(56 100% 48% / 0.3), 0 0 40px hsl(170 76% 63% / 0.1)', |
| 164 | + '0 0 30px hsl(56 100% 48% / 0.5), 0 0 80px hsl(170 76% 63% / 0.2)', |
| 165 | + '0 0 15px hsl(56 100% 48% / 0.3), 0 0 40px hsl(170 76% 63% / 0.1)', |
| 166 | + ], |
| 167 | + }} |
| 168 | + transition={{ |
| 169 | + duration: 3, |
| 170 | + repeat: Infinity, |
| 171 | + ease: 'easeInOut', |
| 172 | + }} |
| 173 | + > |
| 174 | + <span |
| 175 | + className="heading-display select-none" |
| 176 | + style={{ |
| 177 | + fontSize: '2.4rem', |
| 178 | + fontWeight: 900, |
| 179 | + color: 'hsl(56 100% 48%)', |
| 180 | + textShadow: '0 0 16px hsl(56 100% 48% / 0.7)', |
| 181 | + lineHeight: 1, |
| 182 | + }} |
| 183 | + > |
| 184 | + N |
| 185 | + </span> |
| 186 | + {/* Corner marks */} |
| 187 | + {[ |
| 188 | + { top: 5, left: 5, borderTop: true, borderLeft: true }, |
| 189 | + { bottom: 5, right: 5, borderBottom: true, borderRight: true }, |
| 190 | + ].map((pos, i) => ( |
| 191 | + <span |
| 192 | + key={i} |
| 193 | + style={{ |
| 194 | + position: 'absolute', |
| 195 | + width: 8, |
| 196 | + height: 8, |
| 197 | + ...(pos.top !== undefined ? { top: pos.top } : { bottom: pos.bottom }), |
| 198 | + ...(pos.left !== undefined ? { left: pos.left } : { right: pos.right }), |
| 199 | + borderTop: pos.borderTop ? '1px solid hsl(170 76% 63% / 0.5)' : undefined, |
| 200 | + borderLeft: pos.borderLeft ? '1px solid hsl(170 76% 63% / 0.5)' : undefined, |
| 201 | + borderBottom: pos.borderBottom ? '1px solid hsl(170 76% 63% / 0.5)' : undefined, |
| 202 | + borderRight: pos.borderRight ? '1px solid hsl(170 76% 63% / 0.5)' : undefined, |
| 203 | + }} |
| 204 | + /> |
| 205 | + ))} |
| 206 | + </Motion.div> |
| 207 | + ) |
| 208 | +} |
| 209 | + |
| 210 | +export default function LockScreen({ onUnlock }) { |
| 211 | + const [exiting, setExiting] = useState(false) |
| 212 | + |
| 213 | + const dismiss = () => { |
| 214 | + if (exiting) return |
| 215 | + setExiting(true) |
| 216 | + } |
| 217 | + |
| 218 | + // Listen for any user interaction |
| 219 | + useEffect(() => { |
| 220 | + const handleInteraction = () => { |
| 221 | + // Don't dismiss if user is typing in a focused input inside the lock screen |
| 222 | + const active = document.activeElement |
| 223 | + if ( |
| 224 | + active && |
| 225 | + (active.tagName === 'INPUT' || |
| 226 | + active.tagName === 'TEXTAREA' || |
| 227 | + active.isContentEditable) |
| 228 | + ) { |
| 229 | + return |
| 230 | + } |
| 231 | + dismiss() |
| 232 | + } |
| 233 | + |
| 234 | + document.addEventListener('click', handleInteraction) |
| 235 | + document.addEventListener('keydown', handleInteraction) |
| 236 | + document.addEventListener('mousedown', handleInteraction) |
| 237 | + |
| 238 | + return () => { |
| 239 | + document.removeEventListener('click', handleInteraction) |
| 240 | + document.removeEventListener('keydown', handleInteraction) |
| 241 | + document.removeEventListener('mousedown', handleInteraction) |
| 242 | + } |
| 243 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 244 | + }, [exiting]) |
| 245 | + |
| 246 | + return ( |
| 247 | + <AnimatePresence onExitComplete={onUnlock}> |
| 248 | + {!exiting && ( |
| 249 | + <Motion.div |
| 250 | + key="lock-screen" |
| 251 | + className="fixed inset-0 flex flex-col items-center justify-center overflow-hidden" |
| 252 | + style={{ |
| 253 | + zIndex: 2000, |
| 254 | + background: 'rgba(0, 0, 0, 0.97)', |
| 255 | + cursor: 'pointer', |
| 256 | + }} |
| 257 | + initial={{ opacity: 0 }} |
| 258 | + animate={{ opacity: 1 }} |
| 259 | + exit={{ y: '-100%', opacity: 0 }} |
| 260 | + transition={{ |
| 261 | + enter: { duration: 0.4, ease: 'easeOut' }, |
| 262 | + exit: { duration: 0.5, ease: 'easeInOut' }, |
| 263 | + }} |
| 264 | + aria-label="Lock screen — click or press any key to unlock" |
| 265 | + role="dialog" |
| 266 | + aria-modal |
| 267 | + > |
| 268 | + <CircuitBackground /> |
| 269 | + |
| 270 | + {/* Scanlines */} |
| 271 | + <div |
| 272 | + className="pointer-events-none absolute inset-0" |
| 273 | + style={{ |
| 274 | + background: |
| 275 | + 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.02) 2px, rgba(255,255,255,0.02) 4px)', |
| 276 | + zIndex: 1, |
| 277 | + }} |
| 278 | + /> |
| 279 | + |
| 280 | + {/* Center content */} |
| 281 | + <div |
| 282 | + className="relative flex flex-col items-center gap-6" |
| 283 | + style={{ zIndex: 5 }} |
| 284 | + > |
| 285 | + <LockLogo /> |
| 286 | + <LockClock /> |
| 287 | + <UnlockHint /> |
| 288 | + </div> |
| 289 | + |
| 290 | + {/* Bottom NEXUS wordmark */} |
| 291 | + <div |
| 292 | + className="absolute bottom-8 left-0 right-0 flex justify-center" |
| 293 | + style={{ zIndex: 5 }} |
| 294 | + > |
| 295 | + <span |
| 296 | + className="heading-display select-none text-[10px]" |
| 297 | + style={{ |
| 298 | + color: 'hsl(56 100% 48% / 0.25)', |
| 299 | + letterSpacing: '0.3em', |
| 300 | + }} |
| 301 | + > |
| 302 | + NEXUS OS |
| 303 | + </span> |
| 304 | + </div> |
| 305 | + </Motion.div> |
| 306 | + )} |
| 307 | + </AnimatePresence> |
| 308 | + ) |
| 309 | +} |
0 commit comments