SPB Git
18.1 KB · 109 lines tsx
1"use client";2import { useState } from "react";3import { useRouter } from "next/navigation";4import Link from "next/link";5import { api } from "./api";6import { PRODUCT_TYPES, PRODUCT_STATUSES, SOURCE_MODELS, PRICING_MODELS, OPERATING_SYSTEMS, EXECUTION_MODES, INTERFACES, DEPLOYMENT_MODELS, SOURCE_TYPES, CONFIDENCE, DEPTHS, EDITORIAL_STATUSES, VISIBILITIES } from "@/lib/content/schema";7import { OwnerPanel } from "./owner-panel";89type Rec = Record<string, any>;10const EMPTY: Rec = { slug: "", name: "", aliases: [], vendor: { name: "" }, homepage_url: "", product_type: "application", product_status: "active", summary: "", description: "", capabilities: [], workflows: [], advantages: [], limitations: [], prerequisites: [], audiences: [], alternatives: [], categories: [], tags: [], use_cases: [], operating_systems: [], execution_modes: [], interfaces: [], deployment_models: [], integrations: [], data_formats: [], languages: [], source_model: "open-source", pricing_model: ["free"], self_hostable: null, sources: [{ url: "", type: "official_site", supports: [], checked_at: new Date().toISOString().slice(0, 10) }], confidence: "medium", depth: "standard", editorial_status: "draft", visibility: "public", featured: false, author: "editorial", discovery_method: "editorial-research" };1112const lines = (a?: string[]) => (a ?? []).join("\n");13const unlines = (s: string) => s.split("\n").map((x) => x.trim()).filter(Boolean);14function Field({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) { return <label className="block"><span className="label">{label}</span>{children}{hint && <span className="mt-1 block text-xs text-fg-3">{hint}</span>}</label>; }15function Text({ r, k, set, type = "text", required }: { r: Rec; k: string; set: (k: string, v: any) => void; type?: string; required?: boolean }) { return <input type={type} value={r[k] ?? ""} onChange={(e) => set(k, e.target.value || undefined)} className="input" required={required} />; }16function Lines({ r, k, set, rows = 4 }: { r: Rec; k: string; set: (k: string, v: any) => void; rows?: number }) { return <textarea value={lines(r[k])} onChange={(e) => set(k, unlines(e.target.value))} rows={rows} className="input font-mono text-sm" />; }17function Multi({ r, k, set, opts }: { r: Rec; k: string; set: (k: string, v: any) => void; opts: readonly string[] }) { const cur: string[] = r[k] ?? []; return <div className="flex flex-wrap gap-1.5">{opts.map((o) => <button type="button" key={o} onClick={() => set(k, cur.includes(o) ? cur.filter((x) => x !== o) : [...cur, o])} aria-pressed={cur.includes(o)} className={`chip ${cur.includes(o) ? "chip-accent" : ""}`}>{o}</button>)}</div>; }18function Select({ r, k, set, opts }: { r: Rec; k: string; set: (k: string, v: any) => void; opts: readonly string[] }) { return <select value={r[k] ?? ""} onChange={(e) => set(k, e.target.value)} className="input">{opts.map((o) => <option key={o} value={o}>{o}</option>)}</select>; }1920export function ToolEditor({ initial }: { initial: Rec | null }) {21  const router = useRouter();22  const [r, setR] = useState<Rec>(initial ? { ...EMPTY, ...initial } : EMPTY);23  const [tab, setTab] = useState<"form" | "json">("form"); const [json, setJson] = useState(""); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [busy, setBusy] = useState(false); const [ai, setAi] = useState<any>(null);24  const set = (k: string, v: any) => setR((x) => ({ ...x, [k]: v }));25  const meta = initial ? { id: initial.id, quality_score: initial.quality_score, link_state: initial.link_state, checked_at: initial.checked_at, updated_at: initial.updated_at } : null;26  const payload = () => { const { id: _i, quality_score: _q, link_state: _l, checked_at: _c, published_at: _p, updated_at: _u, duplicate_of: _d, ...rest } = r; return rest; };27  async function save(e?: React.FormEvent) { e?.preventDefault(); setBusy(true); setMsg(null); try { const body = tab === "json" ? JSON.parse(json) : payload(); const res = initial ? await api(`/api/admin/tools/${initial.id}`, { method: "PUT", body: JSON.stringify(body) }) : await api("/api/admin/tools", { method: "POST", body: JSON.stringify(body) }); setMsg({ ok: true, text: "Saved. Link check and embedding queued." }); if (!initial) router.push(`/admin/tools/${res.id}`); else router.refresh(); } catch (e) { setMsg({ ok: false, text: (e as Error).message }); } finally { setBusy(false); } }28  async function act(action: string, extra: Rec = {}) { setBusy(true); setMsg(null); try { await api(`/api/admin/tools/${initial!.id}/action`, { method: "POST", body: JSON.stringify({ action, ...extra }) }); setMsg({ ok: true, text: `${action} done` }); router.refresh(); } catch (e) { setMsg({ ok: false, text: (e as Error).message }); } finally { setBusy(false); } }29  async function suggest() { setBusy(true); setMsg(null); try { setAi(await api("/api/admin/ai/suggest", { method: "POST", body: JSON.stringify({ url: r.homepage_url }) })); } catch (e) { setMsg({ ok: false, text: (e as Error).message }); } finally { setBusy(false); } }30  const srcSet = (i: number, k: string, v: any) => set("sources", r.sources.map((s: Rec, j: number) => (j === i ? { ...s, [k]: v } : s)));31  return (32    <form onSubmit={save} className="mx-auto max-w-5xl">33      <div className="flex flex-wrap items-start justify-between gap-3">34        <div><Link href="/admin/tools" className="text-sm text-fg-3 hover:text-fg">← Tools</Link><h1 className="display h1 mt-1">{initial ? r.name : "New record"}</h1>35          {meta && <p className="mt-1 text-xs text-fg-3">quality {meta.quality_score} · links {meta.link_state} · checked {meta.checked_at ? new Date(meta.checked_at).toLocaleDateString("en-CA") : "never"} · {r.editorial_status} · {r.visibility}{r.duplicate_of ? " · merged duplicate" : ""} · <Link href={`/tools/${r.slug}`} target="_blank" className="text-accent">public page</Link></p>}</div>36        <div className="flex flex-wrap gap-2">37          {initial && <>38            {r.editorial_status !== "published" ? <button type="button" onClick={() => act("publish")} className="btn-ghost" disabled={busy}>Publish</button> : <button type="button" onClick={() => act("unpublish")} className="btn-ghost" disabled={busy}>Unpublish</button>}39            <button type="button" onClick={() => act("recheck")} className="btn-ghost" disabled={busy}>Re-check links</button>40            <button type="button" onClick={() => act("rescan")} className="btn-ghost" disabled={busy}>Rescan source</button>41            <button type="button" onClick={() => act(r.featured ? "unfeature" : "feature")} className="btn-ghost" disabled={busy}>{r.featured ? "Unfeature" : "Feature"}</button>42            <button type="button" onClick={() => act("set_visibility", { visibility: r.visibility === "public" ? "private" : "public" })} className="btn-ghost" disabled={busy}>Make {r.visibility === "public" ? "private" : "public"}</button>43          </>}44          <button className="btn-primary" disabled={busy}>Save</button>45        </div>46      </div>47      {msg && <p role="status" className={`mt-3 rounded-xl border p-3 text-sm ${msg.ok ? "border-ok/30 bg-ok/10 text-ok" : "border-bad/30 bg-bad/10 text-bad"}`}>{msg.text}</p>}48      <div className="mt-4 flex gap-1 border-b border-line text-sm">{(["form", "json"] as const).map((t) => <button type="button" key={t} onClick={() => { if (t === "json") setJson(JSON.stringify(payload(), null, 2)); else { try { setR({ ...r, ...JSON.parse(json) }); } catch {} } setTab(t); }} className={`px-3 py-2 ${tab === t ? "border-b-2 border-accent font-medium" : "text-fg-2"}`}>{t === "form" ? "Form" : "Raw JSON"}</button>)}</div>49      {tab === "json" ? <textarea value={json} onChange={(e) => setJson(e.target.value)} rows={40} className="input mt-4 font-mono text-xs" spellCheck={false} /> : (50        <div className="mt-6 grid gap-8 lg:grid-cols-[1fr_18rem]">51          <div className="space-y-8">52            <section className="card space-y-4 p-5"><h2 className="font-semibold">Identity</h2>53              <div className="grid gap-4 sm:grid-cols-2"><Field label="Name"><Text r={r} k="name" set={set} required /></Field><Field label="Slug" hint="kebab-case, stable"><Text r={r} k="slug" set={set} required /></Field>54                <Field label="Vendor"><input value={r.vendor?.name ?? ""} onChange={(e) => set("vendor", { ...r.vendor, name: e.target.value })} className="input" required /></Field><Field label="Vendor website"><input value={r.vendor?.website ?? ""} onChange={(e) => set("vendor", { ...r.vendor, website: e.target.value || undefined })} className="input" type="url" /></Field>55                <Field label="Product type"><Select r={r} k="product_type" set={set} opts={PRODUCT_TYPES} /></Field><Field label="Product status"><Select r={r} k="product_status" set={set} opts={PRODUCT_STATUSES} /></Field></div>56              <Field label="Aliases (one per line)"><Lines r={r} k="aliases" set={set} rows={2} /></Field>57              <div className="grid gap-4 sm:grid-cols-2">{["homepage_url", "docs_url", "repo_url", "pricing_url", "license_url", "privacy_url", "security_url"].map((k) => <Field key={k} label={k.replace(/_/g, " ")}><Text r={r} k={k} set={set} type="url" required={k === "homepage_url"} /></Field>)}</div>58              <button type="button" onClick={suggest} className="btn-ghost" disabled={busy || !r.homepage_url}>AI suggestions from official page (review before use)</button>59              {ai && <div className="rounded-xl border border-info/30 bg-info/10 p-3 text-sm"><p className="font-medium">{ai.provider} / {ai.model} · {ai.note}</p>{ai.suggestion.summary && <p className="mt-2"><strong>Summary:</strong> {ai.suggestion.summary} <button type="button" className="text-accent" onClick={() => set("summary", ai.suggestion.summary)}>use</button></p>}{ai.suggestion.capabilities && <p className="mt-1"><strong>Capabilities:</strong> {ai.suggestion.capabilities.join(" · ")} <button type="button" className="text-accent" onClick={() => set("capabilities", ai.suggestion.capabilities)}>use</button></p>}{ai.suggestion.tags && <p className="mt-1"><strong>Tags:</strong> {ai.suggestion.tags.join(", ")} <button type="button" className="text-accent" onClick={() => set("tags", Array.from(new Set([...(r.tags ?? []), ...ai.suggestion.tags])))}>merge</button></p>}{ai.suggestion.evidence?.length ? <details className="mt-2"><summary className="cursor-pointer">Evidence quotes ({ai.suggestion.evidence.length})</summary><ul className="mt-1 list-disc pl-5">{ai.suggestion.evidence.map((e: any, i: number) => <li key={i}><em>{e.claim}</em> — “{e.quote}”</li>)}</ul></details> : <p className="mt-1 text-fg-3">No evidence quotes survived verification.</p>}</div>}60            </section>61            <section className="card space-y-4 p-5"><h2 className="font-semibold">Content</h2>62              <Field label="Summary (60–220 chars)"><textarea value={r.summary} onChange={(e) => set("summary", e.target.value)} rows={2} className="input" required /></Field>63              <Field label="Description (Markdown; deep = 100–300 words)"><textarea value={r.description} onChange={(e) => set("description", e.target.value)} rows={12} className="input" required /><span className="mt-1 block text-xs text-fg-3">{r.description.trim().split(/\s+/).filter(Boolean).length} words</span></Field>64              <div className="grid gap-4 sm:grid-cols-2">{["capabilities", "workflows", "advantages", "limitations", "prerequisites", "audiences"].map((k) => <Field key={k} label={`${k} (one per line)`}><Lines r={r} k={k} set={set} /></Field>)}</div>65              <Field label="Comparison notes"><textarea value={r.comparison_notes ?? ""} onChange={(e) => set("comparison_notes", e.target.value || undefined)} rows={3} className="input" /></Field>66              <Field label="Alternatives (slugs, one per line)"><Lines r={r} k="alternatives" set={set} rows={3} /></Field>67            </section>68            <section className="card space-y-4 p-5"><h2 className="font-semibold">Classification</h2>69              <Field label="Categories (slug per line; first = primary; optional ' | label')" hint="Leaf slugs from docs/taxonomy.md"><textarea value={(r.categories ?? []).map((c: Rec) => c.slug + (c.label ? ` | ${c.label}` : "")).join("\n")} onChange={(e) => set("categories", unlines(e.target.value).map((l, i) => { const [slug, label] = l.split("|").map((x) => x.trim()); return { slug, primary: i === 0, label: label || undefined }; }))} rows={3} className="input font-mono text-sm" required /></Field>70              <div className="grid gap-4 sm:grid-cols-2"><Field label="Tags (kebab-case, per line)"><Lines r={r} k="tags" set={set} rows={3} /></Field><Field label="Use cases (kebab-case, per line)"><Lines r={r} k="use_cases" set={set} rows={3} /></Field></div>71              <Field label="Operating systems"><Multi r={r} k="operating_systems" set={set} opts={OPERATING_SYSTEMS} /></Field><Field label="Execution modes"><Multi r={r} k="execution_modes" set={set} opts={EXECUTION_MODES} /></Field><Field label="Interfaces"><Multi r={r} k="interfaces" set={set} opts={INTERFACES} /></Field><Field label="Deployment models"><Multi r={r} k="deployment_models" set={set} opts={DEPLOYMENT_MODELS} /></Field>72              <div className="grid gap-4 sm:grid-cols-3">{["integrations", "data_formats", "languages"].map((k) => <Field key={k} label={`${k} (per line)`}><Lines r={r} k={k} set={set} rows={3} /></Field>)}</div>73            </section>74            <section className="card space-y-4 p-5"><h2 className="font-semibold">Commercial & governance</h2>75              <div className="grid gap-4 sm:grid-cols-3"><Field label="Source model"><Select r={r} k="source_model" set={set} opts={SOURCE_MODELS} /></Field><Field label="License (SPDX)"><Text r={r} k="license_spdx" set={set} /></Field><Field label="Self-hostable"><select value={r.self_hostable === null || r.self_hostable === undefined ? "" : String(r.self_hostable)} onChange={(e) => set("self_hostable", e.target.value === "" ? null : e.target.value === "true")} className="input"><option value="">unknown</option><option value="true">yes</option><option value="false">no</option></select></Field></div>76              <Field label="Pricing model (no prices!)"><Multi r={r} k="pricing_model" set={set} opts={PRICING_MODELS} /></Field>77              <div className="grid gap-4 sm:grid-cols-2"><Field label="Pricing checked at"><Text r={r} k="pricing_checked_at" set={set} type="date" /></Field><Field label="Data residency (verifiable only)"><Text r={r} k="data_residency" set={set} /></Field></div>78              <Field label="Privacy posture (requires a policy_page source)"><textarea value={r.privacy_posture ?? ""} onChange={(e) => set("privacy_posture", e.target.value || undefined)} rows={2} className="input" /></Field>79            </section>80            <section className="card space-y-3 p-5"><h2 className="font-semibold">Sources & provenance</h2>81              {(r.sources ?? []).map((s: Rec, i: number) => (82                <div key={i} className="grid gap-2 rounded-xl border border-line p-3 sm:grid-cols-[1fr_10rem_8rem_auto]"><input value={s.url} onChange={(e) => srcSet(i, "url", e.target.value)} placeholder="https://…" className="input" type="url" required /><select value={s.type} onChange={(e) => srcSet(i, "type", e.target.value)} className="input">{SOURCE_TYPES.map((o) => <option key={o}>{o}</option>)}</select><input type="date" value={s.checked_at} onChange={(e) => srcSet(i, "checked_at", e.target.value)} className="input" /><button type="button" onClick={() => set("sources", r.sources.filter((_: Rec, j: number) => j !== i))} className="btn-ghost">Remove</button>83                  <input value={(s.supports ?? []).join(", ")} onChange={(e) => srcSet(i, "supports", e.target.value.split(",").map((x) => x.trim()).filter(Boolean))} placeholder="supports: license_spdx, pricing_model…" className="input sm:col-span-2" /><input value={s.title ?? ""} onChange={(e) => srcSet(i, "title", e.target.value || undefined)} placeholder="title" className="input sm:col-span-2" /></div>84              ))}85              <button type="button" onClick={() => set("sources", [...(r.sources ?? []), { url: "", type: "official_docs", supports: [], checked_at: new Date().toISOString().slice(0, 10) }])} className="btn-ghost">Add source</button>86              <div className="grid gap-4 sm:grid-cols-3"><Field label="Confidence"><Select r={r} k="confidence" set={set} opts={CONFIDENCE} /></Field><Field label="Depth"><Select r={r} k="depth" set={set} opts={DEPTHS} /></Field><Field label="Discovery method"><Text r={r} k="discovery_method" set={set} /></Field></div>87              <Field label="Uncertainty note (what could not be verified)"><textarea value={r.uncertainty_note ?? ""} onChange={(e) => set("uncertainty_note", e.target.value || undefined)} rows={2} className="input" /></Field>88            </section>89          </div>90          <aside className="space-y-4 lg:sticky lg:top-20 lg:self-start">91            <section className="card space-y-3 p-4"><h2 className="font-semibold">Editorial</h2>92              <Field label="Status"><Select r={r} k="editorial_status" set={set} opts={EDITORIAL_STATUSES} /></Field><Field label="Visibility"><Select r={r} k="visibility" set={set} opts={VISIBILITIES} /></Field>93              <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={!!r.featured} onChange={(e) => set("featured", e.target.checked)} /> Featured</label>94              <Field label="Author"><Text r={r} k="author" set={set} /></Field>95              <p className="text-xs text-fg-3">Publishing requires quality ≥ 40 and no broken links. Every save writes a revision and an audit entry.</p>96            </section>97            {initial && <OwnerPanel toolId={initial.id} />}98            {initial && <RevisionList id={initial.id} />}99          </aside>100        </div>101      )}102    </form>103  );104}105function RevisionList({ id }: { id: string }) {106  const [rows, setRows] = useState<any[] | null>(null);107  return <section className="card p-4"><h2 className="font-semibold">History</h2>{rows === null ? <button type="button" onClick={async () => setRows(await api(`/api/admin/tools/${id}/revisions`))} className="btn-ghost mt-2 w-full">Load revisions</button> : <ul className="mt-2 space-y-1 text-xs text-fg-2">{rows.map((r) => <li key={r.id}>{new Date(r.createdAt).toLocaleString("en-CA")} · {r.actor} · {r.reason}</li>)}{!rows.length && <li>No revisions.</li>}</ul>}</section>;108}109
Files · ba7bd272caa00f8811bdea5814fbf1150547c055
Menu

Theme