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

@@ -20,8 +20,7 @@ it in three places:
"@crema/knowledge-ui": libSrc("knowledge-ui") + "/index.tsx",
"@crema/knowledge-ui/": libSrc("knowledge-ui") + "/",
```
The lib also imports `@crema/file-ui` (for uploads/previews), so alias that too
if not already, and make sure `lucide-react` is in your shared-dep dedupe list.
Make sure `lucide-react` is in your shared-dep dedupe list.
2. **`tsconfig.json`** — paths:
```json
@@ -34,6 +33,16 @@ it in three places:
@source "../../lib-knowledge-ui/src";
```
The lib is **fully self-contained** — it imports only `react` and `lucide-react`
(no other `@crema/*` libs), so a fresh consumer wires exactly this one alias. The
app may separately use `@crema/file-ui` for the upload dropzone on its collection
page (spec §4.5), but that's the app's choice, not a lib dependency.
The vite alias (step 1) also needs its own step — mirror step 2 in
`vite.config.ts` `resolve.alias`, and ensure `react` / `react-dom` /
`lucide-react` are deduped to the app's copies (sibling libs carry no
node_modules).
## Wiring the transport
The lib defines `KnowledgeTransport`; the app implements it over its KB client

View File

@@ -7,6 +7,7 @@ import {
ClaimsReviewQueue,
CollectionForm,
CollectionList,
ExportPanel,
ObjectList,
ObjectViewer,
MockKnowledgeTransport,
@@ -14,6 +15,8 @@ import {
type Collection,
type CollectionInput,
type CollectionPatch,
type ExportJob,
type ExportScope,
type KnowledgeTransport,
type ObjectOutline,
type ObjectSummary,
@@ -27,7 +30,9 @@ type View =
| { kind: "object"; id: string };
export default function KnowledgeDemo() {
const [tab, setTab] = useState<"browse" | "review">("browse");
const [tab, setTab] = useState<"browse" | "review" | "export">("browse");
const [exports, setExports] = useState<ExportJob[]>([]);
const [exportBusy, setExportBusy] = useState(false);
const [view, setView] = useState<View>({ kind: "collections" });
const [collections, setCollections] = useState<Collection[]>([]);
const [pending, setPending] = useState<Record<string, number>>({});
@@ -56,6 +61,23 @@ export default function KnowledgeDemo() {
void refresh();
}
// App-side polling of in-flight exports (the timer lives here, not in the lib).
async function requestExport(scope: ExportScope) {
setExportBusy(true);
const job = await transport.createExport(scope);
setExports((prev) => [job, ...prev]);
setExportBusy(false);
poll(job.id);
}
function poll(id: string) {
const tick = async () => {
const job = await transport.getExport(id);
setExports((prev) => prev.map((e) => (e.id === id ? job : e)));
if (job.status === "pending" || job.status === "running") setTimeout(tick, 1500);
};
setTimeout(tick, 800);
}
useEffect(() => {
void refresh();
}, []);
@@ -90,8 +112,19 @@ export default function KnowledgeDemo() {
<TabBtn active={tab === "review"} onClick={() => setTab("review")}>
Review{reviewCount > 0 ? ` (${reviewCount})` : ""}
</TabBtn>
<TabBtn active={tab === "export"} onClick={() => setTab("export")}>Export</TabBtn>
</div>
{tab === "export" && (
<ExportPanel
collections={collections}
jobs={exports}
busy={exportBusy}
onRequest={requestExport}
onDownload={(job) => job.download_url && window.open(job.download_url)}
/>
)}
{tab === "review" && (
<ClaimsReviewQueue
claims={claims}

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>
);
};

View File

@@ -16,8 +16,7 @@
"react": ["../arcadia-personal-cloud-web/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../arcadia-personal-cloud-web/node_modules/@types/react/jsx-runtime.d.ts"],
"react-dom": ["../arcadia-personal-cloud-web/node_modules/@types/react-dom/index.d.ts"],
"lucide-react": ["../arcadia-personal-cloud-web/node_modules/lucide-react/dist/lucide-react.d.ts"],
"@crema/file-ui": ["../lib-file-ui/src/index.tsx"]
"lucide-react": ["../arcadia-personal-cloud-web/node_modules/lucide-react/dist/lucide-react.d.ts"]
}
},
"include": ["src", "demo"]