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>
124 lines
5.2 KiB
TypeScript
124 lines
5.2 KiB
TypeScript
// 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>
|
|
);
|
|
};
|