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%
8.1 KB · 177 lines tsx
Raw Blame History
1"use client";23import * as React from "react";4import { Check, RotateCcw } from "lucide-react";5import { Button } from "@/components/ui";6import { api } from "@/lib/api";7import { toast } from "@/lib/store";8import { cn } from "@/lib/utils";9import type { ProgressionPatch } from "./types";10import { DenseInput, Pill, Toggle } from "./primitives";11import { describeError, int } from "./format";1213export interface ProgressionItem {14  key: string;15  name: string;16  description: string;17  target: number;18  rewardCredits: number;19  rewardXp: number;20  enabled: boolean;21  metric: string;22  /** Extra chip shown next to the key (period / category). */23  tag?: string;24  /** Right-hand statistics columns. */25  stats: { label: string; value: React.ReactNode }[];26}2728type Draft = Partial<Pick<ProgressionItem, "name" | "description" | "target" | "rewardCredits" | "rewardXp">>;2930/** Inline-editable table shared by Missions and Achievements. Each row saves through PATCH `${endpoint}/${key}`. */31export function ProgressionTable({ items, endpoint, onSaved, statHeaders }: { items: ProgressionItem[]; endpoint: string; onSaved: () => void; statHeaders: string[] }) {32  const [drafts, setDrafts] = React.useState<Record<string, Draft>>({});33  const [busy, setBusy] = React.useState<string | null>(null);3435  function edit(key: string, patch: Draft) {36    setDrafts((d) => ({ ...d, [key]: { ...d[key], ...patch } }));37  }38  function reset(key: string) {39    setDrafts((d) => {40      const n = { ...d };41      delete n[key];42      return n;43    });44  }4546  function validate(item: ProgressionItem, draft: Draft): string | null {47    const name = draft.name ?? item.name;48    const desc = draft.description ?? item.description;49    if (name.trim().length < 2) return "Name must be at least 2 characters.";50    if (desc.trim().length < 2) return "Description must be at least 2 characters.";51    const t = draft.target ?? item.target;52    if (!Number.isInteger(t) || t < 1) return "Target must be a positive integer.";53    const rc = draft.rewardCredits ?? item.rewardCredits;54    const rx = draft.rewardXp ?? item.rewardXp;55    if (!Number.isInteger(rc) || rc < 0 || !Number.isInteger(rx) || rx < 0) return "Rewards must be non-negative integers.";56    return null;57  }5859  async function save(item: ProgressionItem) {60    const draft = drafts[item.key];61    if (!draft) return;62    const err = validate(item, draft);63    if (err) {64      toast({ title: "Invalid values", description: err, tone: "danger" });65      return;66    }67    const patch: ProgressionPatch = {};68    if (draft.name !== undefined && draft.name !== item.name) patch.name = draft.name.trim();69    if (draft.description !== undefined && draft.description !== item.description) patch.description = draft.description.trim();70    if (draft.target !== undefined && draft.target !== item.target) patch.target = draft.target;71    if (draft.rewardCredits !== undefined && draft.rewardCredits !== item.rewardCredits) patch.rewardCredits = draft.rewardCredits;72    if (draft.rewardXp !== undefined && draft.rewardXp !== item.rewardXp) patch.rewardXp = draft.rewardXp;73    if (Object.keys(patch).length === 0) {74      reset(item.key);75      return;76    }77    setBusy(item.key);78    try {79      await api(`${endpoint}/${encodeURIComponent(item.key)}`, { method: "PATCH", json: patch });80      toast({ title: `${item.name} saved`, tone: "success" });81      reset(item.key);82      onSaved();83    } catch (e) {84      toast({ title: "Save failed", description: describeError(e), tone: "danger" });85    } finally {86      setBusy(null);87    }88  }8990  async function toggleEnabled(item: ProgressionItem, enabled: boolean) {91    setBusy(item.key);92    try {93      await api(`${endpoint}/${encodeURIComponent(item.key)}`, { method: "PATCH", json: { enabled } });94      onSaved();95    } catch (e) {96      toast({ title: "Update failed", description: describeError(e), tone: "danger" });97    } finally {98      setBusy(null);99    }100  }101102  const numInput = (item: ProgressionItem, field: "target" | "rewardCredits" | "rewardXp", width: string) => {103    const draft = drafts[item.key];104    const v = draft?.[field] ?? item[field];105    return <DenseInput inputMode="numeric" className={cn(width, "text-right font-mono", draft?.[field] !== undefined && draft[field] !== item[field] && "border-accent/60")} value={String(v)} onChange={(e) => edit(item.key, { [field]: Number(e.target.value.replace(/\D/g, "") || 0) })} aria-label={`${field} of ${item.name}`} />;106  };107108  return (109    <div className="overflow-x-auto">110      <table className="w-full min-w-[980px] border-collapse text-[13px]">111        <thead>112          <tr className="border-b border-line text-left text-[11px] font-semibold uppercase tracking-wider text-fg-3">113            <th className="px-3 py-2">On</th>114            <th className="px-3 py-2">Key</th>115            <th className="px-3 py-2">Name & description</th>116            <th className="px-3 py-2 text-right">Target</th>117            <th className="px-3 py-2 text-right">Reward SC</th>118            <th className="px-3 py-2 text-right">Reward XP</th>119            {statHeaders.map((h) => (120              <th key={h} className="px-3 py-2 text-right">121                {h}122              </th>123            ))}124            <th className="px-3 py-2" />125          </tr>126        </thead>127        <tbody>128          {items.map((item) => {129            const draft = drafts[item.key];130            const dirty = !!draft;131            return (132              <tr key={item.key} className={cn("border-b border-line/60 align-top last:border-0", !item.enabled && "opacity-60", dirty && "bg-accent-soft/30")}>133                <td className="px-3 py-2.5">134                  <Toggle checked={item.enabled} disabled={busy === item.key} onChange={(v) => void toggleEnabled(item, v)} label={`Enable ${item.name}`} />135                </td>136                <td className="px-3 py-2.5">137                  <div className="font-mono text-[12px] text-fg-2">{item.key}</div>138                  <div className="mt-1 flex flex-wrap gap-1">139                    {item.tag ? <Pill tone={item.tag === "weekly" ? "info" : "muted"}>{item.tag}</Pill> : null}140                    <Pill tone="muted">{item.metric}</Pill>141                  </div>142                </td>143                <td className="px-3 py-2.5">144                  <DenseInput className={cn("mb-1.5 h-8 font-medium", draft?.name !== undefined && draft.name !== item.name && "border-accent/60")} value={draft?.name ?? item.name} onChange={(e) => edit(item.key, { name: e.target.value })} aria-label={`Name of ${item.key}`} />145                  <DenseInput className={cn("h-8 text-fg-2", draft?.description !== undefined && draft.description !== item.description && "border-accent/60")} value={draft?.description ?? item.description} onChange={(e) => edit(item.key, { description: e.target.value })} aria-label={`Description of ${item.key}`} />146                </td>147                <td className="px-3 py-2.5 text-right">{numInput(item, "target", "w-24")}</td>148                <td className="px-3 py-2.5 text-right">{numInput(item, "rewardCredits", "w-24")}</td>149                <td className="px-3 py-2.5 text-right">{numInput(item, "rewardXp", "w-20")}</td>150                {item.stats.map((s) => (151                  <td key={s.label} className="px-3 py-2.5 text-right tabular text-fg-2">152                    {s.value}153                  </td>154                ))}155                <td className="px-3 py-2.5 text-right">156                  {dirty ? (157                    <div className="inline-flex gap-1">158                      <Button size="sm" variant="accent" onClick={() => void save(item)} loading={busy === item.key} aria-label="Save">159                        <Check className="h-3.5 w-3.5" /> Save160                      </Button>161                      <Button size="sm" variant="ghost" onClick={() => reset(item.key)} disabled={busy === item.key} aria-label="Discard changes">162                        <RotateCcw className="h-3.5 w-3.5" />163                      </Button>164                    </div>165                  ) : (166                    <span className="text-[11px] text-fg-4">{int(item.target)} target</span>167                  )}168                </td>169              </tr>170            );171          })}172        </tbody>173      </table>174    </div>175  );176}177