"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Volume2, VolumeX, Sparkles, Clock, Trophy, KeyRound, ShieldCheck, LogOut, Smartphone, Monitor, Trash2 } from "lucide-react"; import type { UserSettings } from "@spinza/shared"; import { SESSION_REMINDER_OPTIONS } from "@spinza/shared"; import { api } from "@/lib/api"; import { toast, useSession } from "@/lib/store"; import { useApi } from "@/lib/use-api"; import { cn, timeAgo } from "@/lib/utils"; import { Button, Card, Input, Sheet, Skeleton, Switch } from "@/components/ui"; import { AppShell } from "@/components/shell/app-shell"; import { RequireAuth } from "@/components/shell/require-auth"; import { RecoveryCodePanel } from "@/components/shell/recovery-code"; import { ApiErrorState } from "@/components/shell/api-error"; /* ---------------------------------------------------------------- pieces */ function Section({ id, icon: Icon, title, description, children }: { id: string; icon: React.ElementType; title: string; description?: string; children: React.ReactNode }) { return (

{title}

{description ?

{description}

: null}
{children}
); } /** Range input with local state; commits (PATCH) at most every 300 ms while dragging. */ function Slider({ label, value, onChange, disabled }: { label: string; value: number; onChange: (v: number) => void; disabled?: boolean }) { // Settings are loaded before this renders, and `value` only changes through this control, // so the initial local state is always in sync. const [local, setLocal] = useState(Math.round(value * 100)); const timer = useRef | null>(null); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); const change = (n: number) => { setLocal(n); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => onChange(n / 100), 300); }; return ( ); } function Segmented({ value, onChange, options, label }: { value: T; onChange: (v: T) => void; options: { value: T; label: string }[]; label: string }) { return (
{options.map((o) => ( ))}
); } /* ------------------------------------------------------------- security */ function ChangePassword() { const [cur, setCur] = useState(""); const [next, setNext] = useState(""); const [confirm, setConfirm] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const can = cur && next.length >= 8 && next === confirm && !busy; const submit = async (e: React.FormEvent) => { e.preventDefault(); if (!can) return; setBusy(true); setError(null); try { await api("/api/auth/password", { json: { currentPassword: cur, newPassword: next } }); toast({ title: "Password updated", tone: "success" }); setCur(""); setNext(""); setConfirm(""); } catch (err) { setError(err instanceof Error ? err.message : "Could not change password."); } finally { setBusy(false); } }; return (
setCur(e.target.value)} autoComplete="current-password" />
setNext(e.target.value)} autoComplete="new-password" error={next && next.length < 8 ? "At least 8 characters." : null} /> setConfirm(e.target.value)} autoComplete="new-password" error={confirm && confirm !== next ? "Passwords do not match." : null} />
{error ? (

{error}

) : null}
); } function RotateRecovery() { const [open, setOpen] = useState(false); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [code, setCode] = useState(null); const close = () => { setOpen(false); setPassword(""); setError(null); setCode(null); }; const rotate = async (e: React.FormEvent) => { e.preventDefault(); if (!password || busy) return; setBusy(true); setError(null); try { const res = await api<{ recoveryCode: string }>("/api/auth/recovery-code/rotate", { json: { password } }); setCode(res.recoveryCode); } catch (err) { setError(err instanceof Error ? err.message : "Could not rotate the code."); } finally { setBusy(false); } }; return ( <>

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.

undefined : close} side="center" title={code ? "New recovery code" : "Rotate recovery code"}> {code ? ( ) : (

Confirm your password to generate a new recovery code.

setPassword(e.target.value)} autoComplete="current-password" autoFocus /> {error ? (

{error}

) : null}
)}
); } interface SessionRow { id: string; current: boolean; createdAt: string; lastSeenAt: string; userAgent: string | null; ip: string | null; } function describeUa(ua: string | null): { label: string; mobile: boolean } { if (!ua) return { label: "Unknown device", mobile: false }; const mobile = /iPhone|Android|Mobile/i.test(ua); 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"; const browser = /Edg\//i.test(ua) ? "Edge" : /Chrome\//i.test(ua) ? "Chrome" : /Firefox\//i.test(ua) ? "Firefox" : /Safari\//i.test(ua) ? "Safari" : "Browser"; return { label: `${browser} · ${os}`, mobile }; } function Sessions() { const { data, error, loading, reload, setData } = useApi<{ sessions: SessionRow[] }>("/api/user/sessions"); const [busy, setBusy] = useState(null); const revoke = async (id: string) => { setBusy(id); try { await api(`/api/user/sessions/${id}`, { method: "DELETE" }); setData((d) => (d ? { sessions: d.sessions.filter((s) => s.id !== id) } : d)); toast({ title: "Session signed out", tone: "success" }); } catch (e) { toast({ title: "Could not revoke", description: e instanceof Error ? e.message : undefined, tone: "danger" }); } finally { setBusy(null); } }; if (error && !data) return ; if (loading && !data) return ; const list = data?.sessions ?? []; return (
    {list.map((s) => { const d = describeUa(s.userAgent); const Icon = d.mobile ? Smartphone : Monitor; return (
  • {d.label} {s.current ? This device : null}
    Active {timeAgo(s.lastSeenAt)} {s.ip ? ` · ${s.ip}` : ""} · signed in {timeAgo(s.createdAt)}
    {!s.current ? ( ) : null}
  • ); })}
); } /* ------------------------------------------------------------------ page */ function SettingsBody() { const router = useRouter(); const settings = useSession((s) => s.settings); const setSettings = useSession((s) => s.setSettings); const signOut = useSession((s) => s.signOut); const [signingOut, setSigningOut] = useState(false); if (!settings) return ; const set = (patch: Partial) => setSettings(patch); const muted = !settings.soundEnabled; return (
set({ soundEnabled: v })} label="Sound" description={muted ? "All game audio is muted." : "Game audio is on."} />
set({ masterVolume: v })} disabled={muted} /> set({ musicVolume: v })} disabled={muted} /> set({ effectsVolume: v })} disabled={muted} />
Animation intensity
label="Animation intensity" value={settings.animationIntensity} onChange={(v) => set({ animationIntensity: v })} options={[{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }]} />

Low keeps particles and screen effects to a minimum. High turns everything up.

set({ reduceMotion: v })} label="Reduce motion" description="Shorten transitions and disable celebratory motion across Spinza." />
Session reminder
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` }))} />

Shows how long you have been playing at the chosen interval.

set({ breakReminder: v })} label="Break reminder" description="Suggest a short break after long stretches of continuous play." />
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."} />

Change password

Recovery code

Active sessions

Signing out only affects this device. Your credits and progress stay on your account.

); } export default function SettingsPage() { return (
Settings

Make Spinza yours.

Preferences save instantly and follow your account across devices.

); }