SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
16.5 KB · 355 lines tsx
Raw Blame History
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