W6: export panel

components-export: ExportPanel (scope picker + request) + ExportJobRow (status,
download on ready, error verbatim; timer lives in the app per the polling
contract). Demo Export tab polls getExport app-side. Lib confirmed
self-contained (react + lucide only); README/tsconfig updated. Typechecks clean.

Co-Authored-By: Claude Fable 5 (build) <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-07 10:05:46 +10:00
parent 40d2f94795
commit efbf4b2e61
4 changed files with 169 additions and 7 deletions

View File

@@ -1,2 +1,123 @@
// Placeholder — filled in its workstream (W4/W5/W6).
export {};
// PURPOSE: Export bundle flow (spec §3.4, §8). Request an export of the whole
// estate or one corpus; the app polls getExport and feeds jobs back in
// (the timer lives in the app, per the polling contract). Download on
// ready, error verbatim on failure. Props in, callbacks out.
// ===========================================================================
import { useState, type FC, type ReactNode } from "react";
import { Archive, CheckCircle2, Download, Loader2, XCircle } from "lucide-react";
import type { Collection, ExportJob, ExportScope } from "./types";
import { Spinner, cn, formatBytes, formatRelative } from "./_internal";
// ---- ExportJobRow ---------------------------------------------------------
export const ExportJobRow: FC<{ job: ExportJob; onDownload?: (job: ExportJob) => void; className?: string }> = ({
job,
onDownload,
className,
}) => {
const running = job.status === "pending" || job.status === "running";
return (
<div className={cn("flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3", className)}>
<StatusIcon status={job.status} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{job.scope === "all" ? "Whole knowledge base" : job.scope}
</p>
<p className="text-xs text-muted-foreground">
{running
? "Building…"
: job.status === "ready"
? `${job.object_count ?? 0} items · ${formatBytes(job.byte_size)} · ${formatRelative(job.updated_at)}`
: job.error || "Failed"}
</p>
</div>
{job.status === "ready" && onDownload && (
<button
type="button"
data-action="knowledge-export-download"
onClick={() => onDownload(job)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition hover:opacity-90"
>
<Download className="size-3.5" /> Download
</button>
)}
{running && <Spinner />}
</div>
);
};
const StatusIcon: FC<{ status: ExportJob["status"] }> = ({ status }) => {
if (status === "ready") return <CheckCircle2 className="size-5 text-[var(--success)]" />;
if (status === "failed") return <XCircle className="size-5 text-destructive" />;
return <Archive className="size-5 text-muted-foreground" />;
};
// ---- ExportPanel ----------------------------------------------------------
export interface ExportPanelProps {
/** Corpuses the caller may export (for the scope picker). */
collections: Collection[];
jobs: ExportJob[];
busy?: boolean;
onRequest: (scope: ExportScope) => void;
onDownload?: (job: ExportJob) => void;
intro?: ReactNode;
className?: string;
}
export const ExportPanel: FC<ExportPanelProps> = ({ collections, jobs, busy, onRequest, onDownload, intro, className }) => {
const [scope, setScope] = useState<ExportScope>("all");
return (
<div className={cn("flex flex-col gap-6", className)}>
<div className="rounded-xl border border-border bg-card p-4">
<h2 className="text-sm font-semibold text-foreground">Export a bundle</h2>
<p className="mt-1 text-sm text-muted-foreground">
{intro ?? (
<>
A self-contained zip your originals, extracted text, catalog cards, and claims, in open formats.
Citations resolve inside the bundle, so it stays readable with no service in the loop. It's yours to keep.
</>
)}
</p>
<div className="mt-4 flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Scope</span>
<select
data-action="knowledge-export-scope"
className="rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/30"
value={scope}
onChange={(e) => setScope(e.target.value)}
>
<option value="all">Whole knowledge base</option>
{collections.map((c) => (
<option key={c.slug} value={c.slug}>
{c.name}
</option>
))}
</select>
</label>
<button
type="button"
data-action="knowledge-export-request"
disabled={busy}
onClick={() => onRequest(scope)}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-50"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Archive className="size-4" />}
Request export
</button>
</div>
</div>
{jobs.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Exports</h3>
{jobs.map((j) => (
<ExportJobRow key={j.id} job={j} onDownload={onDownload} />
))}
</div>
)}
</div>
);
};