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%
10.9 KB · 214 lines tsx
Raw Blame History
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