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:
13
README.md
13
README.md
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user