TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useEffect, useRef, useState } from "react";4import { useRouter } from "next/navigation";5import { Volume2, VolumeX, Sparkles, Clock, Trophy, KeyRound, ShieldCheck, LogOut, Smartphone, Monitor, Trash2 } from "lucide-react";6import type { UserSettings } from "@spinza/shared";7import { SESSION_REMINDER_OPTIONS } from "@spinza/shared";8import { api } from "@/lib/api";9import { toast, useSession } from "@/lib/store";10import { useApi } from "@/lib/use-api";11import { cn, timeAgo } from "@/lib/utils";12import { Button, Card, Input, Sheet, Skeleton, Switch } from "@/components/ui";13import { AppShell } from "@/components/shell/app-shell";14import { RequireAuth } from "@/components/shell/require-auth";15import { RecoveryCodePanel } from "@/components/shell/recovery-code";16import { ApiErrorState } from "@/components/shell/api-error";1718/* ---------------------------------------------------------------- pieces */1920function Section({ id, icon: Icon, title, description, children }: { id: string; icon: React.ElementType; title: string; description?: string; children: React.ReactNode }) {21 return (22 <Card as="section" id={id} className="p-5 sm:p-6" aria-labelledby={`${id}-title`}>23 <div className="mb-4 flex items-center gap-3">24 <span className="grid h-10 w-10 place-items-center rounded-md metal text-accent-2">25 <Icon className="h-[18px] w-[18px]" />26 </span>27 <div>28 <h2 id={`${id}-title`} className="text-lg font-semibold tracking-tight">29 {title}30 </h2>31 {description ? <p className="text-[13px] text-fg-3">{description}</p> : null}32 </div>33 </div>34 {children}35 </Card>36 );37}3839/** Range input with local state; commits (PATCH) at most every 300 ms while dragging. */40function Slider({ label, value, onChange, disabled }: { label: string; value: number; onChange: (v: number) => void; disabled?: boolean }) {41 // Settings are loaded before this renders, and `value` only changes through this control,42 // so the initial local state is always in sync.43 const [local, setLocal] = useState(Math.round(value * 100));44 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);45 useEffect(46 () => () => {47 if (timer.current) clearTimeout(timer.current);48 },49 [],50 );51 const change = (n: number) => {52 setLocal(n);53 if (timer.current) clearTimeout(timer.current);54 timer.current = setTimeout(() => onChange(n / 100), 300);55 };56 return (57 <label className={cn("block py-2", disabled && "opacity-50")}>58 <span className="mb-1.5 flex items-center justify-between text-[15px] font-medium">59 {label}60 <span className="text-[13px] tabular text-fg-3">{local}%</span>61 </span>62 <input type="range" min={0} max={100} step={1} value={local} disabled={disabled} onChange={(e) => change(Number(e.target.value))} className="h-11 w-full cursor-pointer accent-[#c9a961]" aria-label={label} />63 </label>64 );65}6667function Segmented<T extends string | number>({ value, onChange, options, label }: { value: T; onChange: (v: T) => void; options: { value: T; label: string }[]; label: string }) {68 return (69 <div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={label}>70 {options.map((o) => (71 <button key={String(o.value)} type="button" role="radio" aria-checked={value === o.value} onClick={() => onChange(o.value)} className={cn("tap h-10 min-w-[44px] rounded-md border px-3.5 text-[13px] font-semibold transition-colors focus-ring", value === o.value ? "border-accent/50 bg-accent-soft text-accent-2" : "border-line text-fg-3 hover:border-line-2 hover:text-fg")}>72 {o.label}73 </button>74 ))}75 </div>76 );77}7879/* ------------------------------------------------------------- security */8081function ChangePassword() {82 const [cur, setCur] = useState("");83 const [next, setNext] = useState("");84 const [confirm, setConfirm] = useState("");85 const [busy, setBusy] = useState(false);86 const [error, setError] = useState<string | null>(null);87 const can = cur && next.length >= 8 && next === confirm && !busy;88 const submit = async (e: React.FormEvent) => {89 e.preventDefault();90 if (!can) return;91 setBusy(true);92 setError(null);93 try {94 await api("/api/auth/password", { json: { currentPassword: cur, newPassword: next } });95 toast({ title: "Password updated", tone: "success" });96 setCur("");97 setNext("");98 setConfirm("");99 } catch (err) {100 setError(err instanceof Error ? err.message : "Could not change password.");101 } finally {102 setBusy(false);103 }104 };105 return (106 <form onSubmit={submit} className="space-y-3" noValidate>107 <Input label="Current password" type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" />108 <div className="grid gap-3 sm:grid-cols-2">109 <Input label="New password" type="password" value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" error={next && next.length < 8 ? "At least 8 characters." : null} />110 <Input label="Confirm new password" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" error={confirm && confirm !== next ? "Passwords do not match." : null} />111 </div>112 {error ? (113 <p className="text-sm text-danger" role="alert">114 {error}115 </p>116 ) : null}117 <Button type="submit" variant="secondary" disabled={!can} loading={busy}>118 Update password119 </Button>120 </form>121 );122}123124function RotateRecovery() {125 const [open, setOpen] = useState(false);126 const [password, setPassword] = useState("");127 const [busy, setBusy] = useState(false);128 const [error, setError] = useState<string | null>(null);129 const [code, setCode] = useState<string | null>(null);130131 const close = () => {132 setOpen(false);133 setPassword("");134 setError(null);135 setCode(null);136 };137 const rotate = async (e: React.FormEvent) => {138 e.preventDefault();139 if (!password || busy) return;140 setBusy(true);141 setError(null);142 try {143 const res = await api<{ recoveryCode: string }>("/api/auth/recovery-code/rotate", { json: { password } });144 setCode(res.recoveryCode);145 } catch (err) {146 setError(err instanceof Error ? err.message : "Could not rotate the code.");147 } finally {148 setBusy(false);149 }150 };151 return (152 <>153 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">154 <p className="text-sm text-fg-2">Your recovery code is the only way to reset a forgotten password. Rotate it if you think it was exposed — the old code stops working immediately.</p>155 <Button variant="secondary" onClick={() => setOpen(true)} className="shrink-0">156 <KeyRound className="h-4 w-4" /> Rotate recovery code157 </Button>158 </div>159 <Sheet open={open} onClose={code ? () => undefined : close} side="center" title={code ? "New recovery code" : "Rotate recovery code"}>160 {code ? (161 <RecoveryCodePanel hideHeader code={code} notice="Your previous recovery code no longer works. Save this one now. Spinza does not collect your email address — if you lose your password and this code, your account cannot be recovered." continueLabel="Done" onContinue={close} />162 ) : (163 <form onSubmit={rotate} className="space-y-4" noValidate>164 <p className="text-sm text-fg-2">Confirm your password to generate a new recovery code.</p>165 <Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" autoFocus />166 {error ? (167 <p className="text-sm text-danger" role="alert">168 {error}169 </p>170 ) : null}171 <div className="flex gap-3">172 <Button type="button" variant="secondary" className="flex-1" onClick={close}>173 Cancel174 </Button>175 <Button type="submit" className="flex-1" disabled={!password} loading={busy}>176 Generate new code177 </Button>178 </div>179 </form>180 )}181 </Sheet>182 </>183 );184}185186interface SessionRow {187 id: string;188 current: boolean;189 createdAt: string;190 lastSeenAt: string;191 userAgent: string | null;192 ip: string | null;193}194195function describeUa(ua: string | null): { label: string; mobile: boolean } {196 if (!ua) return { label: "Unknown device", mobile: false };197 const mobile = /iPhone|Android|Mobile/i.test(ua);198 const os = /iPhone|iPad/i.test(ua) ? "iOS" : /Android/i.test(ua) ? "Android" : /Mac OS X/i.test(ua) ? "macOS" : /Windows/i.test(ua) ? "Windows" : /Linux/i.test(ua) ? "Linux" : "Unknown OS";199 const browser = /Edg\//i.test(ua) ? "Edge" : /Chrome\//i.test(ua) ? "Chrome" : /Firefox\//i.test(ua) ? "Firefox" : /Safari\//i.test(ua) ? "Safari" : "Browser";200 return { label: `${browser} · ${os}`, mobile };201}202203function Sessions() {204 const { data, error, loading, reload, setData } = useApi<{ sessions: SessionRow[] }>("/api/user/sessions");205 const [busy, setBusy] = useState<string | null>(null);206 const revoke = async (id: string) => {207 setBusy(id);208 try {209 await api(`/api/user/sessions/${id}`, { method: "DELETE" });210 setData((d) => (d ? { sessions: d.sessions.filter((s) => s.id !== id) } : d));211 toast({ title: "Session signed out", tone: "success" });212 } catch (e) {213 toast({ title: "Could not revoke", description: e instanceof Error ? e.message : undefined, tone: "danger" });214 } finally {215 setBusy(null);216 }217 };218 if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;219 if (loading && !data) return <Skeleton className="h-24" />;220 const list = data?.sessions ?? [];221 return (222 <ul className="divide-y divide-line rounded-md border border-line">223 {list.map((s) => {224 const d = describeUa(s.userAgent);225 const Icon = d.mobile ? Smartphone : Monitor;226 return (227 <li key={s.id} className="flex items-center gap-3 px-3 py-3">228 <span className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-surface-2 text-fg-2">229 <Icon className="h-[18px] w-[18px]" />230 </span>231 <div className="min-w-0 flex-1">232 <div className="flex items-center gap-2 text-[15px] font-medium">233 <span className="truncate">{d.label}</span>234 {s.current ? <span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-bold text-success">This device</span> : null}235 </div>236 <div className="text-[12px] text-fg-3">237 Active {timeAgo(s.lastSeenAt)}238 {s.ip ? ` · ${s.ip}` : ""} · signed in {timeAgo(s.createdAt)}239 </div>240 </div>241 {!s.current ? (242 <Button variant="ghost" size="sm" onClick={() => revoke(s.id)} loading={busy === s.id} aria-label="Sign out this session">243 <Trash2 className="h-4 w-4" /> Revoke244 </Button>245 ) : null}246 </li>247 );248 })}249 </ul>250 );251}252253/* ------------------------------------------------------------------ page */254255function SettingsBody() {256 const router = useRouter();257 const settings = useSession((s) => s.settings);258 const setSettings = useSession((s) => s.setSettings);259 const signOut = useSession((s) => s.signOut);260 const [signingOut, setSigningOut] = useState(false);261 if (!settings) return <Skeleton className="h-64" />;262 const set = (patch: Partial<UserSettings>) => setSettings(patch);263 const muted = !settings.soundEnabled;264265 return (266 <div className="grid gap-4 lg:grid-cols-2">267 <Section id="sound" icon={muted ? VolumeX : Volume2} title="Sound" description="Music and effects inside games.">268 <Switch checked={settings.soundEnabled} onChange={(v) => set({ soundEnabled: v })} label="Sound" description={muted ? "All game audio is muted." : "Game audio is on."} />269 <div className="mt-1 border-t border-line pt-2">270 <Slider label="Master volume" value={settings.masterVolume} onChange={(v) => set({ masterVolume: v })} disabled={muted} />271 <Slider label="Music" value={settings.musicVolume} onChange={(v) => set({ musicVolume: v })} disabled={muted} />272 <Slider label="Effects" value={settings.effectsVolume} onChange={(v) => set({ effectsVolume: v })} disabled={muted} />273 </div>274 </Section>275276 <Section id="motion" icon={Sparkles} title="Animation" description="Tune how cinematic the games feel.">277 <div className="py-2">278 <div className="mb-2 text-[15px] font-medium">Animation intensity</div>279 <Segmented<UserSettings["animationIntensity"]> label="Animation intensity" value={settings.animationIntensity} onChange={(v) => set({ animationIntensity: v })} options={[{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }]} />280 <p className="mt-2 text-[13px] text-fg-3">Low keeps particles and screen effects to a minimum. High turns everything up.</p>281 </div>282 <div className="border-t border-line">283 <Switch checked={settings.reduceMotion} onChange={(v) => set({ reduceMotion: v })} label="Reduce motion" description="Shorten transitions and disable celebratory motion across Spinza." />284 </div>285 </Section>286287 <Section id="time" icon={Clock} title="Time & breaks" description="Responsible-play reminders. Credits are fictional; your time is not.">288 <div className="py-2">289 <div className="mb-2 text-[15px] font-medium">Session reminder</div>290 <Segmented<number> label="Session reminder" value={settings.sessionReminderMinutes} onChange={(v) => set({ sessionReminderMinutes: v })} options={SESSION_REMINDER_OPTIONS.map((m) => ({ value: m, label: m === 0 ? "Off" : `${m} min` }))} />291 <p className="mt-2 text-[13px] text-fg-3">Shows how long you have been playing at the chosen interval.</p>292 </div>293 <div className="border-t border-line">294 <Switch checked={settings.breakReminder} onChange={(v) => set({ breakReminder: v })} label="Break reminder" description="Suggest a short break after long stretches of continuous play." />295 </div>296 </Section>297298 <Section id="leaderboard" icon={Trophy} title="Leaderboards" description="Control what other players can see.">299 <Switch checked={settings.leaderboardOptIn} onChange={(v) => set({ leaderboardOptIn: v })} label="Leaderboard participation" description={settings.leaderboardOptIn ? "Your username, level and results appear on public boards and the wins feed." : "You are hidden from all leaderboards and the wins feed."} />300 </Section>301302 <div className="lg:col-span-2">303 <Section id="security" icon={ShieldCheck} title="Security" description="Spinza stores only your username and a password hash — keep both safe.">304 <div className="grid gap-8 lg:grid-cols-2">305 <div>306 <h3 className="mb-3 text-[15px] font-semibold">Change password</h3>307 <ChangePassword />308 </div>309 <div className="space-y-8">310 <div>311 <h3 className="mb-3 text-[15px] font-semibold">Recovery code</h3>312 <RotateRecovery />313 </div>314 <div>315 <h3 className="mb-3 text-[15px] font-semibold">Active sessions</h3>316 <Sessions />317 </div>318 </div>319 </div>320 <div className="mt-8 flex flex-col gap-3 border-t border-line pt-6 sm:flex-row sm:items-center sm:justify-between">321 <p className="text-sm text-fg-3">Signing out only affects this device. Your credits and progress stay on your account.</p>322 <Button323 variant="danger"324 loading={signingOut}325 onClick={async () => {326 setSigningOut(true);327 await signOut();328 router.push("/");329 router.refresh();330 }}331 >332 <LogOut className="h-4 w-4" /> Sign out333 </Button>334 </div>335 </Section>336 </div>337 </div>338 );339}340341export default function SettingsPage() {342 return (343 <AppShell>344 <RequireAuth>345 <div className="mb-6">346 <div className="eyebrow mb-1">Settings</div>347 <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Make Spinza yours.</h1>348 <p className="mt-2 max-w-xl text-sm text-fg-3">Preferences save instantly and follow your account across devices.</p>349 </div>350 <SettingsBody />351 </RequireAuth>352 </AppShell>353 );354}355