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") + "/index.tsx",
|
||||||
"@crema/knowledge-ui/": libSrc("knowledge-ui") + "/",
|
"@crema/knowledge-ui/": libSrc("knowledge-ui") + "/",
|
||||||
```
|
```
|
||||||
The lib also imports `@crema/file-ui` (for uploads/previews), so alias that too
|
Make sure `lucide-react` is in your shared-dep dedupe list.
|
||||||
if not already, and make sure `lucide-react` is in your shared-dep dedupe list.
|
|
||||||
|
|
||||||
2. **`tsconfig.json`** — paths:
|
2. **`tsconfig.json`** — paths:
|
||||||
```json
|
```json
|
||||||
@@ -34,6 +33,16 @@ it in three places:
|
|||||||
@source "../../lib-knowledge-ui/src";
|
@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
|
## Wiring the transport
|
||||||
|
|
||||||
The lib defines `KnowledgeTransport`; the app implements it over its KB client
|
The lib defines `KnowledgeTransport`; the app implements it over its KB client
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ClaimsReviewQueue,
|
ClaimsReviewQueue,
|
||||||
CollectionForm,
|
CollectionForm,
|
||||||
CollectionList,
|
CollectionList,
|
||||||
|
ExportPanel,
|
||||||
ObjectList,
|
ObjectList,
|
||||||
ObjectViewer,
|
ObjectViewer,
|
||||||
MockKnowledgeTransport,
|
MockKnowledgeTransport,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
type Collection,
|
type Collection,
|
||||||
type CollectionInput,
|
type CollectionInput,
|
||||||
type CollectionPatch,
|
type CollectionPatch,
|
||||||
|
type ExportJob,
|
||||||
|
type ExportScope,
|
||||||
type KnowledgeTransport,
|
type KnowledgeTransport,
|
||||||
type ObjectOutline,
|
type ObjectOutline,
|
||||||
type ObjectSummary,
|
type ObjectSummary,
|
||||||
@@ -27,7 +30,9 @@ type View =
|
|||||||
| { kind: "object"; id: string };
|
| { kind: "object"; id: string };
|
||||||
|
|
||||||
export default function KnowledgeDemo() {
|
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 [view, setView] = useState<View>({ kind: "collections" });
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
const [pending, setPending] = useState<Record<string, number>>({});
|
const [pending, setPending] = useState<Record<string, number>>({});
|
||||||
@@ -56,6 +61,23 @@ export default function KnowledgeDemo() {
|
|||||||
void refresh();
|
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(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -90,8 +112,19 @@ export default function KnowledgeDemo() {
|
|||||||
<TabBtn active={tab === "review"} onClick={() => setTab("review")}>
|
<TabBtn active={tab === "review"} onClick={() => setTab("review")}>
|
||||||
Review{reviewCount > 0 ? ` (${reviewCount})` : ""}
|
Review{reviewCount > 0 ? ` (${reviewCount})` : ""}
|
||||||
</TabBtn>
|
</TabBtn>
|
||||||
|
<TabBtn active={tab === "export"} onClick={() => setTab("export")}>Export</TabBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "export" && (
|
||||||
|
<ExportPanel
|
||||||
|
collections={collections}
|
||||||
|
jobs={exports}
|
||||||
|
busy={exportBusy}
|
||||||
|
onRequest={requestExport}
|
||||||
|
onDownload={(job) => job.download_url && window.open(job.download_url)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "review" && (
|
{tab === "review" && (
|
||||||
<ClaimsReviewQueue
|
<ClaimsReviewQueue
|
||||||
claims={claims}
|
claims={claims}
|
||||||
|
|||||||
@@ -1,2 +1,123 @@
|
|||||||
// Placeholder — filled in its workstream (W4/W5/W6).
|
// PURPOSE: Export bundle flow (spec §3.4, §8). Request an export of the whole
|
||||||
export {};
|
// 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": ["../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/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"],
|
"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"],
|
"lucide-react": ["../arcadia-personal-cloud-web/node_modules/lucide-react/dist/lucide-react.d.ts"]
|
||||||
"@crema/file-ui": ["../lib-file-ui/src/index.tsx"]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "demo"]
|
"include": ["src", "demo"]
|
||||||
|
|||||||
Reference in New Issue
Block a user