TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import { AlertTriangle, Save, Wrench } from "lucide-react";5import { Button } from "@/components/ui";6import { api } from "@/lib/api";7import { toast } from "@/lib/store";8import { cn } from "@/lib/utils";9import { useAdmin } from "@/components/admin/store";10import { useAdminQuery } from "@/components/admin/use-query";11import type { MaintenanceSetting, ProfanitySetting, SettingsResponse } from "@/components/admin/types";12import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, Toggle, DenseTextarea, FieldLabel, InlineError, ConfirmDialog, Pill } from "@/components/admin/primitives";13import { describeError, int } from "@/components/admin/format";1415const FLAG_LABELS: Record<string, string> = {16 "registration.enabled": "New account creation",17 "dailyRewards.enabled": "Daily reward claims",18 "leaderboards.enabled": "Public leaderboards",19 "missions.enabled": "Daily & weekly missions",20 "achievements.enabled": "Achievements",21 "rescue.enabled": "Rescue credits at zero balance",22};2324export default function AdminSettingsPage() {25 const q = useAdminQuery<SettingsResponse>("/api/admin/settings");26 const d = q.data;27 const [busyFlag, setBusyFlag] = React.useState<string | null>(null);2829 async function setFlag(key: string, enabled: boolean) {30 setBusyFlag(key);31 q.mutate((prev) => ({ ...prev, flags: { ...prev.flags, [key]: enabled } }));32 try {33 await api(`/api/admin/flags/${encodeURIComponent(key)}`, { json: { enabled } });34 toast({ title: `${FLAG_LABELS[key] ?? key} ${enabled ? "enabled" : "disabled"}`, tone: enabled ? "success" : "default" });35 } catch (e) {36 q.mutate((prev) => ({ ...prev, flags: { ...prev.flags, [key]: !enabled } }));37 toast({ title: "Flag update failed", description: describeError(e), tone: "danger" });38 } finally {39 setBusyFlag(null);40 }41 }4243 const platformFlags = Object.entries(d?.flags ?? {}).filter(([k]) => !k.startsWith("game.")).sort(([a], [b]) => a.localeCompare(b));44 const gameFlags = Object.entries(d?.flags ?? {}).filter(([k]) => k.startsWith("game.")).sort(([a], [b]) => a.localeCompare(b));45 const gamesOff = gameFlags.filter(([, v]) => !v).length;4647 return (48 <>49 <PageHeader title="Settings" description="Feature flags, maintenance mode and the username profanity list." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />50 {q.error && !d ? (51 <ErrorState error={q.error} onRetry={() => void q.refresh()} />52 ) : !d ? (53 <div className="grid gap-4 xl:grid-cols-2">54 <Panel>55 <TableSkeleton rows={6} cols={2} />56 </Panel>57 <Panel>58 <TableSkeleton rows={4} cols={2} />59 </Panel>60 </div>61 ) : (62 <div className={cn("grid gap-4 xl:grid-cols-2", q.stale && "opacity-70")}>63 <div className="space-y-4">64 <Panel title="Platform feature flags" description="Runtime switches read by the API within 10 s" padded={false}>65 <ul className="divide-y divide-line/60">66 {platformFlags.map(([key, enabled]) => (67 <li key={key} className="flex items-center justify-between gap-4 px-4 py-2.5">68 <div className="min-w-0">69 <div className="text-[13px] font-medium text-fg">{FLAG_LABELS[key] ?? key}</div>70 <div className="font-mono text-[11px] text-fg-4">{key}</div>71 </div>72 <Toggle checked={enabled} disabled={busyFlag === key} onChange={(v) => void setFlag(key, v)} label={FLAG_LABELS[key] ?? key} />73 </li>74 ))}75 {platformFlags.length === 0 ? <li className="px-4 py-6 text-center text-[13px] text-fg-4">No flags defined. Run the database seed.</li> : null}76 </ul>77 </Panel>78 <Panel title="Game kill-switches" description={`${gameFlags.length} games · ${gamesOff ? `${gamesOff} disabled` : "all enabled"}`} padded={false}>79 <ul className="grid divide-y divide-line/60 sm:grid-cols-2 sm:divide-y-0">80 {gameFlags.map(([key, enabled]) => (81 <li key={key} className="flex items-center justify-between gap-3 border-b border-line/60 px-4 py-2 sm:[&:nth-last-child(-n+2)]:border-b-0">82 <span className="truncate font-mono text-[12px] text-fg-2">{key.replace(/^game\./, "").replace(/\.enabled$/, "")}</span>83 <Toggle checked={enabled} disabled={busyFlag === key} onChange={(v) => void setFlag(key, v)} label={key} />84 </li>85 ))}86 </ul>87 </Panel>88 </div>89 <div className="space-y-4">90 <MaintenanceCard key={`m-${q.updatedAt}`} initial={(d.settings["maintenance"] as MaintenanceSetting | undefined) ?? { enabled: false, message: "Spinza is getting an upgrade. Your credits and progress are safe." }} onSaved={() => void q.refresh()} />91 <ProfanityCard key={`p-${q.updatedAt}`} initial={((d.settings["profanity"] as ProfanitySetting | undefined)?.words ?? []).filter((w) => typeof w === "string")} onSaved={() => void q.refresh()} />92 </div>93 </div>94 )}95 </>96 );97}9899function MaintenanceCard({ initial, onSaved }: { initial: MaintenanceSetting; onSaved: () => void }) {100 const setMaintenance = useAdmin((s) => s.setMaintenance);101 const [enabled, setEnabled] = React.useState(!!initial.enabled);102 const [message, setMessage] = React.useState(initial.message ?? "");103 const [confirm, setConfirm] = React.useState(false);104 const [busy, setBusy] = React.useState(false);105 const [error, setError] = React.useState<string | null>(null);106 const dirty = enabled !== !!initial.enabled || message !== (initial.message ?? "");107 const valid = message.trim().length >= 3;108109 async function save() {110 setBusy(true);111 setError(null);112 try {113 const value = { enabled, message: message.trim() };114 await api("/api/admin/settings/maintenance", { json: { value } });115 setMaintenance(value);116 toast({ title: enabled ? "Maintenance mode ON" : "Maintenance mode OFF", description: enabled ? "Players now see the maintenance message." : "The platform is live again.", tone: enabled ? "danger" : "success" });117 setConfirm(false);118 onSaved();119 } catch (e) {120 setError(describeError(e));121 setConfirm(false);122 } finally {123 setBusy(false);124 }125 }126127 return (128 <Panel129 title={130 <span className="inline-flex items-center gap-2">131 <Wrench className="h-3.5 w-3.5 text-fg-3" /> Maintenance mode132 <Pill tone={initial.enabled ? "danger" : "success"} dot>133 {initial.enabled ? "active" : "off"}134 </Pill>135 </span>136 }137 description="When enabled every player request returns 503 with this message. Admin routes stay reachable."138 tone={initial.enabled ? "danger" : undefined}139 >140 <div className="flex items-center justify-between gap-4 rounded-sm border border-line bg-bg-1/60 px-3 py-2.5">141 <div>142 <div className="text-[13px] font-medium">Maintenance enabled</div>143 <div className="text-[12px] text-fg-3">Blocks gameplay, sign-in and registration.</div>144 </div>145 <Toggle checked={enabled} onChange={setEnabled} label="Maintenance enabled" size="md" />146 </div>147 <div className="mt-3">148 <FieldLabel hint={`${message.trim().length} chars · min 3`}>Player-facing message</FieldLabel>149 <DenseTextarea rows={3} value={message} onChange={(e) => setMessage(e.target.value)} />150 </div>151 {error ? <div className="mt-3"><InlineError>{error}</InlineError></div> : null}152 <div className="mt-4 flex justify-end gap-2">153 <Button size="sm" variant="ghost" onClick={() => { setEnabled(!!initial.enabled); setMessage(initial.message ?? ""); }} disabled={!dirty || busy}>154 Reset155 </Button>156 <Button size="sm" variant={enabled ? "danger" : "primary"} onClick={() => setConfirm(true)} disabled={!dirty || !valid}>157 <Save className="h-3.5 w-3.5" /> {enabled && !initial.enabled ? "Enable maintenance" : !enabled && initial.enabled ? "Disable maintenance" : "Save"}158 </Button>159 </div>160 <ConfirmDialog open={confirm} onClose={() => setConfirm(false)} onConfirm={() => void save()} title={enabled ? "Enable maintenance mode?" : "Update maintenance settings?"} confirmLabel={enabled ? "Yes, take Spinza offline" : "Save"} danger={enabled} loading={busy}161 description={162 enabled ? (163 <span className="inline-flex items-start gap-2">164 <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-danger" />165 <span>All player traffic will receive a 503 immediately. Active spins finish, no credits are lost. This is logged as a high-severity event.</span>166 </span>167 ) : (168 "Players will see the platform again as soon as the API cache refreshes (≤ 10 s)."169 )170 }171 >172 <blockquote className="rounded-sm border border-line bg-bg-1 px-3 py-2 text-[13px] italic text-fg-2">“{message.trim()}”</blockquote>173 </ConfirmDialog>174 </Panel>175 );176}177178function ProfanityCard({ initial, onSaved }: { initial: string[]; onSaved: () => void }) {179 const [text, setText] = React.useState(initial.join("\n"));180 const [busy, setBusy] = React.useState(false);181 const [error, setError] = React.useState<string | null>(null);182 const words = React.useMemo(() => Array.from(new Set(text.split(/[\n,]+/).map((w) => w.trim().toLowerCase()).filter(Boolean))), [text]);183 const dirty = JSON.stringify(words) !== JSON.stringify(initial.map((w) => w.trim().toLowerCase()).filter(Boolean));184185 async function save() {186 setBusy(true);187 setError(null);188 try {189 await api("/api/admin/settings/profanity", { json: { value: { words } } });190 toast({ title: "Profanity list saved", description: `${int(words.length)} custom words`, tone: "success" });191 onSaved();192 } catch (e) {193 setError(describeError(e));194 } finally {195 setBusy(false);196 }197 }198199 return (200 <Panel title="Username profanity list" description="Extends the built-in list; one word per line (or comma-separated). Matching is case-insensitive." actions={<span className="text-[12px] text-fg-3 tabular">{int(words.length)} words</span>}>201 <DenseTextarea rows={8} value={text} onChange={(e) => setText(e.target.value)} placeholder={"one word per line"} className="font-mono" spellCheck={false} />202 {error ? <div className="mt-3"><InlineError>{error}</InlineError></div> : null}203 <div className="mt-3 flex justify-end gap-2">204 <Button size="sm" variant="ghost" onClick={() => setText(initial.join("\n"))} disabled={!dirty || busy}>205 Reset206 </Button>207 <Button size="sm" variant="accent" onClick={() => void save()} disabled={!dirty} loading={busy}>208 <Save className="h-3.5 w-3.5" /> Save list209 </Button>210 </div>211 </Panel>212 );213}214