SPB Git
14.0 KB · 151 lines tsx
1"use client";2/** Settings → Data: export (ZIP / JSON), import dropzone with progress polling, encrypted backups list + run/verify. */3import * as React from "react";4import Link from "next/link";5import { useQuery, useQueryClient } from "@tanstack/react-query";6import { Archive, Database, Download, FileJson, FileUp, HardDrive, Play, RefreshCw, ShieldCheck, Upload } from "lucide-react";7import { api } from "@/lib/client/api";8import { cn, fmtDate, timeAgo } from "@/lib/utils";9import { Button } from "@/components/ui/button";10import { Badge, Spinner, Tip } from "@/components/ui/primitives";11import { toast } from "@/components/ui/toast";1213const ACCEPT = ".md,.markdown,.txt,.zip,.docx,.pdf,.csv,.bib,.json";14const fmtBytes = (n: number | null | undefined) => (n == null ? "–" : n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(2)} GB`);15type Progress = { jobId?: string; status: "queued" | "running" | "done" | "error"; progress: number; message?: string; pageId?: string | null; error?: string; stats?: { pages: number; databases: number; rows: number; attachments: number; references: number; skipped: string[] }; filename?: string; kind?: string };16type BackupJob = { id: string; kind: string; status: string; path: string | null; sizeBytes: number | null; sha256: string | null; offsite: boolean | null; error: string | null; startedAt: string; finishedAt: string | null };17type BackupsResp = { jobs: BackupJob[]; lastOk: BackupJob | null; config: { dir: string | null; offsite: string | null; keepDays: number; schedule: string; passphraseSet: boolean } };1819function Section({ icon, title, hint, children, action }: { icon: React.ReactNode; title: string; hint?: string; children: React.ReactNode; action?: React.ReactNode }) {20  return (21    <section className="rounded-xl border border-line bg-bg-elev p-4 sm:p-5">22      <div className="mb-3 flex flex-wrap items-start justify-between gap-2">23        <div className="flex items-start gap-2.5"><span className="mt-0.5 text-fg-muted [&_svg]:size-4">{icon}</span><div><h3 className="text-sm font-semibold">{title}</h3>{hint && <p className="text-[12.5px] text-fg-muted">{hint}</p>}</div></div>24        {action}25      </div>26      {children}27    </section>28  );29}3031function ExportBlock() {32  const items = [33    { href: "/api/export?format=zip", icon: <Archive />, label: "Workspace ZIP", desc: "Markdown per page (folders mirror the tree), CSV per database, attachments/, references.bib, workspace.json" },34    { href: "/api/export?format=json", icon: <FileJson />, label: "JSON backup", desc: "Every content table, re-importable here (merge by id)" },35    { href: "/api/export?format=bib", icon: <Download />, label: "references.bib", desc: "All references as BibTeX" },36  ];37  return (38    <div className="grid gap-2 sm:grid-cols-3">39      {items.map((it) => (40        <a key={it.href} href={it.href} className="group flex min-h-[44px] items-start gap-2.5 rounded-lg border border-line bg-bg p-3 transition-colors hover:bg-bg-sunk">41          <span className="mt-0.5 text-fg-muted group-hover:text-fg [&_svg]:size-4">{it.icon}</span>42          <span className="min-w-0"><span className="block text-[13px] font-medium">{it.label}</span><span className="block text-[12px] leading-snug text-fg-muted">{it.desc}</span></span>43        </a>44      ))}45      <p className="text-[12px] text-fg-faint sm:col-span-3">Per page: use the page menu → Export (Markdown, PDF print view) or <code className="rounded bg-bg-sunk px-1">/api/export?format=md|pdf|zip|csv&amp;pageId=…</code>.</p>46    </div>47  );48}4950function ImportBlock() {51  const qc = useQueryClient();52  const inputRef = React.useRef<HTMLInputElement>(null);53  const [drag, setDrag] = React.useState(false);54  const [busy, setBusy] = React.useState<string | null>(null);55  const [prog, setProg] = React.useState<Progress | null>(null);56  const pollRef = React.useRef<number | null>(null);57  React.useEffect(() => () => { if (pollRef.current) window.clearInterval(pollRef.current); }, []);58  const finish = (p: Progress) => { setProg(p); setBusy(null); qc.invalidateQueries({ queryKey: ["tree"] }); if (p.status === "done") toast("Import finished", { tone: "success", description: p.stats ? `${p.stats.pages} pages, ${p.stats.databases} databases, ${p.stats.rows} rows, ${p.stats.attachments} files, ${p.stats.references} refs` : undefined }); else toast("Import failed", { tone: "danger", description: p.error }); };59  const poll = (jobId: string) => {60    if (pollRef.current) window.clearInterval(pollRef.current);61    pollRef.current = window.setInterval(async () => { try { const p = await api<Progress>(`/api/import?jobId=${jobId}`); setProg(p); if (p.status === "done" || p.status === "error") { window.clearInterval(pollRef.current!); pollRef.current = null; finish(p); } } catch (e) { window.clearInterval(pollRef.current!); pollRef.current = null; finish({ status: "error", progress: 0, error: (e as Error).message }); } }, 1500);62  };63  const upload = async (files: FileList | File[]) => {64    const list = Array.from(files); if (!list.length) return;65    for (const file of list) {66      setBusy(file.name); setProg({ status: "queued", progress: 0, message: "Uploading…", filename: file.name });67      const fd = new FormData(); fd.append("file", file);68      try {69        const r = await api<any>("/api/import", { method: "POST", body: fd });70        if (r.jobId) { setProg({ jobId: r.jobId, status: "queued", progress: 0.01, message: "Queued for the worker", filename: file.name, kind: r.kind }); poll(r.jobId); await new Promise<void>((res) => { const t = window.setInterval(() => { if (!pollRef.current) { window.clearInterval(t); res(); } }, 500); }); }71        else finish({ status: "done", progress: 1, pageId: r.pageId, stats: r.stats, filename: file.name, kind: r.kind });72      } catch (e) { finish({ status: "error", progress: 0, error: (e as Error).message, filename: file.name }); }73    }74  };75  return (76    <div className="space-y-3">77      <div role="button" tabIndex={0} aria-label="Import files" onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }} onClick={() => inputRef.current?.click()}78        onDragOver={(e) => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={(e) => { e.preventDefault(); setDrag(false); if (!busy) void upload(e.dataTransfer.files); }}79        className={cn("flex min-h-[112px] cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed p-4 text-center transition-colors", drag ? "border-accent bg-accent-soft" : "border-line bg-bg hover:bg-bg-sunk", busy && "pointer-events-none opacity-70")}>80        <input ref={inputRef} type="file" multiple accept={ACCEPT} className="hidden" onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />81        {busy ? <Spinner /> : <FileUp className="size-5 text-fg-faint" />}82        <div className="text-[13px] font-medium">{busy ? `Importing ${busy}…` : "Drop files here or click to choose"}</div>83        <div className="text-[12px] text-fg-muted">Markdown · Notion export ZIP · Markdown ZIP · DOCX · PDF · CSV → database · BibTeX → references · JSON backup</div>84      </div>85      {prog && (86        <div className="rounded-lg border border-line bg-bg p-3 text-[13px]">87          <div className="flex items-center justify-between gap-2"><span className="truncate font-medium">{prog.filename ?? "Import"}</span><Badge tone={prog.status === "done" ? "success" : prog.status === "error" ? "danger" : "accent"}>{prog.status}</Badge></div>88          {(prog.status === "queued" || prog.status === "running") && <div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-bg-sunk"><div className="h-full rounded-full bg-accent transition-all" style={{ width: `${Math.max(3, Math.round((prog.progress ?? 0) * 100))}%` }} /></div>}89          {prog.message && prog.status !== "done" && <div className="mt-1 text-[12px] text-fg-muted">{prog.message}</div>}90          {prog.status === "error" && <div className="mt-1 text-[12px] text-danger">{prog.error}</div>}91          {prog.status === "done" && <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-fg-muted">{prog.stats && <span>{prog.stats.pages} pages · {prog.stats.databases} databases · {prog.stats.rows} rows · {prog.stats.attachments} files · {prog.stats.references} refs</span>}{prog.pageId && <Link href={`/p/${prog.pageId}`} className="text-accent hover:underline">Open imported page →</Link>}{prog.stats?.skipped?.length ? <span className="text-warning">{prog.stats.skipped.length} note(s): {prog.stats.skipped.slice(0, 2).join("; ")}</span> : null}</div>}92        </div>93      )}94    </div>95  );96}9798function BackupsBlock() {99  const qc = useQueryClient();100  const q = useQuery<BackupsResp>({ queryKey: ["backups"], queryFn: () => api("/api/backups"), refetchInterval: (query) => (query.state.data?.jobs.some((j) => j.status === "running" || j.status === "queued") ? 4000 : false) });101  const [running, setRunning] = React.useState(false); const [verifying, setVerifying] = React.useState<string | null>(null);102  const run = async () => { setRunning(true); try { await api("/api/backups", { method: "POST", json: { action: "run" } }); toast("Backup queued", { description: "The worker runs scripts/backup.sh; this list refreshes automatically." }); qc.invalidateQueries({ queryKey: ["backups"] }); } catch (e) { toast("Could not start backup", { tone: "danger", description: (e as Error).message }); } finally { setRunning(false); } };103  const verify = async (id: string) => { setVerifying(id); try { const r = await api<any>("/api/backups", { method: "POST", json: { action: "verify", id } }); toast(r.ok ? "Archive verified" : "Verification failed", { tone: r.ok ? "success" : "danger", description: r.ok ? `${r.path} · ${fmtBytes(r.size)} · sha256 matches` : r.reason ?? (r.exists ? "sha256 or size mismatch" : "archive missing") }); } catch (e) { toast("Verify failed", { tone: "danger", description: (e as Error).message }); } finally { setVerifying(null); } };104  const d = q.data; const jobs = d?.jobs ?? [];105  const tone = (s: string) => (s === "ok" ? "success" : s === "failed" ? "danger" : s === "running" || s === "queued" ? "accent" : "neutral") as "success" | "danger" | "accent" | "neutral";106  return (107    <div className="space-y-3">108      <div className="grid gap-2 text-[12.5px] sm:grid-cols-3">109        <div className="rounded-lg border border-line bg-bg p-2.5"><div className="text-fg-faint">Last successful</div><div className="font-medium">{d?.lastOk ? `${timeAgo(d.lastOk.finishedAt ?? d.lastOk.startedAt)} · ${fmtBytes(d.lastOk.sizeBytes)}` : q.isLoading ? "…" : "never"}</div></div>110        <div className="rounded-lg border border-line bg-bg p-2.5"><div className="text-fg-faint">Schedule</div><div className="font-medium">{d?.config.schedule ?? "…"}</div><div className="text-fg-muted">keep {d?.config.keepDays ?? 30} days</div></div>111        <div className="rounded-lg border border-line bg-bg p-2.5"><div className="text-fg-faint">Off-site copy</div><div className="truncate font-medium" title={d?.config.offsite ?? ""}>{d?.config.offsite ?? "not configured"}</div>{d && !d.config.passphraseSet && <div className="text-danger">BACKUP_PASSPHRASE missing</div>}</div>112      </div>113      {q.isError ? <div className="text-[13px] text-danger">{(q.error as Error).message}</div> : jobs.length === 0 ? <div className="rounded-lg border border-dashed border-line p-4 text-center text-[13px] text-fg-muted">{q.isLoading ? <Spinner /> : "No backup yet — run one now."}</div> : (114        <ul className="divide-y divide-line rounded-lg border border-line bg-bg">115          {jobs.map((j) => (116            <li key={j.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-[13px]">117              <span className="w-[150px] shrink-0 tabular-nums">{fmtDate(j.startedAt, { dateStyle: "medium", timeStyle: "short" })}</span>118              <Badge tone={tone(j.status)}>{j.status}</Badge>119              <span className="w-[70px] tabular-nums text-fg-muted">{fmtBytes(j.sizeBytes)}</span>120              <Tip label={j.offsite ? "Copied and verified on the off-site host" : "No off-site copy"}><span className={cn("inline-flex items-center gap-1 text-[12px]", j.offsite ? "text-success" : "text-fg-faint")}><HardDrive className="size-3.5" />{j.offsite ? "off-site" : "local only"}</span></Tip>121              <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] text-fg-faint" title={j.error ?? j.path ?? ""}>{j.error ? <span className="text-danger">{j.error}</span> : j.path?.split("/").pop()}</span>122              {j.status === "ok" && j.path && <Button variant="ghost" size="xs" disabled={verifying === j.id} onClick={() => verify(j.id)}>{verifying === j.id ? <Spinner className="size-3" /> : <ShieldCheck />} Verify</Button>}123            </li>124          ))}125        </ul>126      )}127      <div className="flex flex-wrap items-center gap-2">128        <Button size="sm" onClick={run} disabled={running || jobs.some((j) => j.status === "running" || j.status === "queued")}>{running ? <Spinner className="size-3.5 border-t-white" /> : <Play />} Run backup now</Button>129        <Button size="sm" variant="ghost" onClick={() => q.refetch()}><RefreshCw /> Refresh</Button>130        <span className="text-[12px] text-fg-faint">pg_dump + uploads → AES-256 (pbkdf2) → sha256 → rsync. Restore test: <code className="rounded bg-bg-sunk px-1">scripts/restore.sh --latest --verify-only</code> (see docs/operations.md).</span>131      </div>132    </div>133  );134}135136export function DataSection() {137  return (138    <div className="space-y-4">139      <Section icon={<Download />} title="Export" hint="Your data in open formats — Markdown, CSV, BibTeX, JSON.">140        <ExportBlock />141      </Section>142      <Section icon={<Upload />} title="Import" hint="Small files are imported immediately; large ones run in the background worker with progress.">143        <ImportBlock />144      </Section>145      <Section icon={<Database />} title="Backups" hint="Nightly encrypted backup of the database and uploads, with an off-site copy.">146        <BackupsBlock />147      </Section>148    </div>149  );150}151
Files · main
Menu

Theme