W4: object viewer
components-object: ObjectList, ObjectViewer (CatalogCard + ProvenancePanel + SupersessionBanner + OutlineNav + TextReader with citation highlight/scroll + load-more, image render, vault notice, action bar), CitationLink + parseCite. Demo browses collections → objects → viewer. Typechecks clean. Co-Authored-By: Claude Fable 5 (build) <noreply@anthropic.com>
This commit is contained in:
@@ -6,18 +6,30 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
CollectionForm,
|
CollectionForm,
|
||||||
CollectionList,
|
CollectionList,
|
||||||
|
ObjectList,
|
||||||
|
ObjectViewer,
|
||||||
MockKnowledgeTransport,
|
MockKnowledgeTransport,
|
||||||
type Collection,
|
type Collection,
|
||||||
type CollectionInput,
|
type CollectionInput,
|
||||||
type CollectionPatch,
|
type CollectionPatch,
|
||||||
type KnowledgeTransport,
|
type KnowledgeTransport,
|
||||||
|
type ObjectOutline,
|
||||||
|
type ObjectSummary,
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
|
|
||||||
const transport: KnowledgeTransport = new MockKnowledgeTransport();
|
const transport: KnowledgeTransport = new MockKnowledgeTransport();
|
||||||
|
|
||||||
|
type View =
|
||||||
|
| { kind: "collections" }
|
||||||
|
| { kind: "collection"; slug: string }
|
||||||
|
| { kind: "object"; id: string };
|
||||||
|
|
||||||
export default function KnowledgeDemo() {
|
export default function KnowledgeDemo() {
|
||||||
|
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>>({});
|
||||||
|
const [objects, setObjects] = useState<ObjectSummary[]>([]);
|
||||||
|
const [outline, setOutline] = useState<ObjectOutline | null>(null);
|
||||||
const [creating, setCreating] = useState<null | "account" | "tenant">(null);
|
const [creating, setCreating] = useState<null | "account" | "tenant">(null);
|
||||||
const [editing, setEditing] = useState<Collection | null>(null);
|
const [editing, setEditing] = useState<Collection | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -35,6 +47,11 @@ export default function KnowledgeDemo() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view.kind === "collection") void transport.listObjects(view.slug).then((p) => setObjects(p.objects));
|
||||||
|
if (view.kind === "object") void transport.outline(view.id).then(setOutline);
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
async function onCreate(value: CollectionInput | CollectionPatch) {
|
async function onCreate(value: CollectionInput | CollectionPatch) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
await transport.createCollection(value as CollectionInput);
|
await transport.createCollection(value as CollectionInput);
|
||||||
@@ -42,7 +59,6 @@ export default function KnowledgeDemo() {
|
|||||||
setCreating(null);
|
setCreating(null);
|
||||||
void refresh();
|
void refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEdit(value: CollectionInput | CollectionPatch) {
|
async function onEdit(value: CollectionInput | CollectionPatch) {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -54,33 +70,61 @@ export default function KnowledgeDemo() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl p-6">
|
<div className="mx-auto max-w-5xl p-6">
|
||||||
<header className="mb-6">
|
<button
|
||||||
<h1 className="text-xl font-semibold text-foreground">Knowledge</h1>
|
type="button"
|
||||||
<p className="text-sm text-muted-foreground">Your corpuses — browse, organise, and review.</p>
|
onClick={() => setView({ kind: "collections" })}
|
||||||
</header>
|
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Knowledge{view.kind !== "collections" ? " / …" : ""}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{view.kind === "collections" && (
|
||||||
|
<>
|
||||||
{creating && (
|
{creating && (
|
||||||
<div className="mb-6 rounded-xl border border-border bg-card p-4">
|
<div className="mb-6 rounded-xl border border-border bg-card p-4">
|
||||||
<h2 className="mb-3 font-medium">New corpus</h2>
|
<h2 className="mb-3 font-medium">New corpus</h2>
|
||||||
<CollectionForm mode="create" ownerType={creating} orgName="Acme" canManageTenant busy={busy} onSubmit={onCreate} onCancel={() => setCreating(null)} />
|
<CollectionForm mode="create" ownerType={creating} orgName="Acme" canManageTenant busy={busy} onSubmit={onCreate} onCancel={() => setCreating(null)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="mb-6 rounded-xl border border-border bg-card p-4">
|
<div className="mb-6 rounded-xl border border-border bg-card p-4">
|
||||||
<h2 className="mb-3 font-medium">Corpus settings</h2>
|
<h2 className="mb-3 font-medium">Corpus settings</h2>
|
||||||
<CollectionForm mode="edit" initial={editing} orgName="Acme" canManageTenant busy={busy} onSubmit={onEdit} onCancel={() => setEditing(null)} />
|
<CollectionForm mode="edit" initial={editing} orgName="Acme" canManageTenant busy={busy} onSubmit={onEdit} onCancel={() => setEditing(null)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CollectionList
|
<CollectionList
|
||||||
collections={collections}
|
collections={collections}
|
||||||
pendingCounts={pending}
|
pendingCounts={pending}
|
||||||
orgName="Acme"
|
orgName="Acme"
|
||||||
canManageTenant
|
canManageTenant
|
||||||
onCreate={(owner) => setCreating(owner)}
|
onCreate={(owner) => setCreating(owner)}
|
||||||
onOpen={(c) => setEditing(c)}
|
onOpen={(c) => setView({ kind: "collection", slug: c.slug })}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.kind === "collection" && (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold">{view.slug}</h1>
|
||||||
|
<button className="text-sm text-primary" onClick={() => setEditing(collections.find((c) => c.slug === view.slug) ?? null)}>Settings</button>
|
||||||
|
</div>
|
||||||
|
<ObjectList objects={objects} onOpen={(o) => setView({ kind: "object", id: o.object_id })} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.kind === "object" && outline && (
|
||||||
|
<ObjectViewer
|
||||||
|
outline={outline}
|
||||||
|
readText={(sel) => transport.readText(outline.object_id, sel)}
|
||||||
|
resolveBlobUrl={() => transport.blobUrl(outline.object_id)}
|
||||||
|
onOpenObject={(id) => setView({ kind: "object", id })}
|
||||||
|
actions={{
|
||||||
|
onVerify: () => transport.updateObject(outline.object_id, { verified_at: new Date().toISOString() }),
|
||||||
|
onDownload: () => transport.blobUrl(outline.object_id).then((u) => window.open(u)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,470 @@
|
|||||||
// Placeholder — filled in its workstream (W4/W5/W6).
|
// PURPOSE: Object viewer (spec §3.4) — catalog card + provenance + supersession
|
||||||
export {};
|
// + outline-driven text reader with citation highlighting, image render,
|
||||||
|
// vault notice, and object actions. Plus ObjectList + CitationLink.
|
||||||
|
// Pure: text is loaded via an injected readText callback, not fetched.
|
||||||
|
// ===========================================================================
|
||||||
|
import { useCallback, useEffect, useRef, useState, type FC, type ReactNode, type RefObject } from "react";
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
Sparkles,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ObjectOutline, ObjectSummary, OutlineSection, TextSlice } from "./types";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
SensitivityBadge,
|
||||||
|
StatusBadge,
|
||||||
|
cn,
|
||||||
|
formatBytes,
|
||||||
|
formatDate,
|
||||||
|
isImageMime,
|
||||||
|
} from "./_internal";
|
||||||
|
|
||||||
|
// ---- ObjectList -----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ObjectListProps {
|
||||||
|
objects: ObjectSummary[];
|
||||||
|
onOpen?: (object: ObjectSummary) => void;
|
||||||
|
emptyState?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ObjectList: FC<ObjectListProps> = ({ objects, onOpen, emptyState, className }) => {
|
||||||
|
if (objects.length === 0 && emptyState) return <>{emptyState}</>;
|
||||||
|
return (
|
||||||
|
<ul className={cn("flex flex-col divide-y divide-border rounded-xl border border-border bg-card", className)}>
|
||||||
|
{objects.map((o) => (
|
||||||
|
<li key={o.object_id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-open-object"
|
||||||
|
data-object-id={o.object_id}
|
||||||
|
onClick={() => onOpen?.(o)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-muted/50 focus:outline-none focus-visible:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{o.kind === "claim" ? <Sparkles className="size-4" /> : <FileText className="size-4" />}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className={cn("block truncate text-sm font-medium", o.status === "superseded" && "text-muted-foreground line-through")}>{o.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{o.effective_at ? `effective ${formatDate(o.effective_at)}` : `updated ${formatDate(o.updated_at)}`}</span>
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={o.status} />
|
||||||
|
<SensitivityBadge level={o.sensitivity} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- CitationLink ---------------------------------------------------------
|
||||||
|
|
||||||
|
export function parseCite(cite: string): { objectId: string; span?: { start: number; end: number } } | null {
|
||||||
|
const m = /^kb:\/\/([^#]+)(?:#(\d+)-(\d+))?$/.exec(cite);
|
||||||
|
if (!m) return null;
|
||||||
|
return { objectId: m[1], span: m[2] ? { start: Number(m[2]), end: Number(m[3]) } : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a `kb://<id>#s-e` handle as a click-through the app routes to the
|
||||||
|
* object viewer with ?start=&end=. `onOpen` receives (objectId, span). */
|
||||||
|
export interface CitationLinkProps {
|
||||||
|
cite: string;
|
||||||
|
label?: string;
|
||||||
|
onOpen?: (objectId: string, span?: { start: number; end: number }) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CitationLink: FC<CitationLinkProps> = ({ cite, label, onOpen, className }) => {
|
||||||
|
const parsed = parseCite(cite);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-citation"
|
||||||
|
onClick={() => parsed && onOpen?.(parsed.objectId, parsed.span)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground transition hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={cite}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label ?? cite}</span>
|
||||||
|
<ArrowUpRight className="size-3 shrink-0" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- CatalogCard ----------------------------------------------------------
|
||||||
|
|
||||||
|
export const CatalogCard: FC<{ outline: ObjectOutline; className?: string }> = ({ outline, className }) => (
|
||||||
|
<div className={cn("flex flex-col gap-3", className)}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<StatusBadge status={outline.status} />
|
||||||
|
<SensitivityBadge level={outline.sensitivity} />
|
||||||
|
{outline.language && <Badge tone="bg-muted text-muted-foreground">{outline.language.toUpperCase()}</Badge>}
|
||||||
|
{outline.mime && <Badge tone="bg-muted text-muted-foreground">{outline.mime}</Badge>}
|
||||||
|
{outline.byte_size != null && <span className="text-xs text-muted-foreground">{formatBytes(outline.byte_size)}</span>}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">{outline.title}</h1>
|
||||||
|
{outline.summary && <p className="text-sm leading-relaxed text-muted-foreground">{outline.summary}</p>}
|
||||||
|
{(outline.tags.length > 0 || outline.entities.length > 0) && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{outline.tags.map((t) => (
|
||||||
|
<Badge key={t} tone="bg-muted text-muted-foreground" icon={<Tag className="size-3" />}>
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{outline.entities.map((e) => (
|
||||||
|
<Badge key={e.name} tone="bg-primary/10 text-primary" title={e.type}>
|
||||||
|
{e.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- ProvenancePanel ------------------------------------------------------
|
||||||
|
|
||||||
|
export const ProvenancePanel: FC<{ outline: ObjectOutline; className?: string }> = ({ outline, className }) => {
|
||||||
|
const p = outline.provenance;
|
||||||
|
const rows: [string, ReactNode][] = [
|
||||||
|
["Source", p.source_ref ? `${p.source_type} · ${p.source_ref}` : p.source_type],
|
||||||
|
["Added by", p.created_by ?? "—"],
|
||||||
|
["Added", formatDate(p.inserted_at)],
|
||||||
|
["Effective", p.effective_at ? formatDate(p.effective_at) : "—"],
|
||||||
|
["Last verified", p.verified_at ? formatDate(p.verified_at) : "Not confirmed"],
|
||||||
|
["Card model", p.card_model ?? "— (mechanical)"],
|
||||||
|
["Extraction model", p.extraction_model ?? "— (native text)"],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-foreground">Provenance</h3>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||||
|
{rows.map(([k, v]) => (
|
||||||
|
<div key={k} className="contents">
|
||||||
|
<dt className="text-muted-foreground">{k}</dt>
|
||||||
|
<dd className="break-words text-foreground">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- SupersessionBanner ---------------------------------------------------
|
||||||
|
|
||||||
|
export const SupersessionBanner: FC<{
|
||||||
|
outline: ObjectOutline;
|
||||||
|
onOpen?: (objectId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ outline, onOpen, className }) => {
|
||||||
|
if (!outline.superseded_by && !outline.supersedes) return null;
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
|
{outline.superseded_by && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-[var(--warning)]/40 bg-[color-mix(in_oklab,var(--warning)_10%,transparent)] px-3 py-2 text-sm">
|
||||||
|
<span className="text-[var(--warning)]">A newer version of this document exists.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-open-newer"
|
||||||
|
onClick={() => onOpen?.(outline.superseded_by!)}
|
||||||
|
className="ml-auto inline-flex items-center gap-1 font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open newer <ArrowUpRight className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{outline.supersedes && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen?.(outline.supersedes!)}
|
||||||
|
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Replaces an earlier version <ArrowUpRight className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- OutlineNav -----------------------------------------------------------
|
||||||
|
|
||||||
|
export const OutlineNav: FC<{
|
||||||
|
sections: OutlineSection[];
|
||||||
|
activeId?: string;
|
||||||
|
onSelect?: (section: OutlineSection) => void;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ sections, activeId, onSelect, className }) => {
|
||||||
|
if (sections.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document outline">
|
||||||
|
{sections.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-outline-section"
|
||||||
|
onClick={() => onSelect?.(s)}
|
||||||
|
className={cn(
|
||||||
|
"truncate rounded-md px-2 py-1 text-left text-sm transition hover:bg-muted",
|
||||||
|
s.level > 1 && "pl-4 text-xs",
|
||||||
|
activeId === s.id ? "bg-muted font-medium text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.heading}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- TextReader -----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TextReaderProps {
|
||||||
|
outline: ObjectOutline;
|
||||||
|
readText: (sel?: { section?: string; start?: number; end?: number }) => Promise<TextSlice>;
|
||||||
|
/** A span to load, mark, and scroll to (citation resolver, spec §3.4). */
|
||||||
|
highlight?: { start: number; end: number };
|
||||||
|
activeSection?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight, activeSection, className }) => {
|
||||||
|
const [slice, setSlice] = useState<TextSlice | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const markRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (sel?: { section?: string; start?: number; end?: number }) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setSlice(await readText(sel));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[readText],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial + reactive loads: prefer an explicit highlight span, then an active
|
||||||
|
// section, else the head of the document.
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlight) {
|
||||||
|
const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end);
|
||||||
|
void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 });
|
||||||
|
} else if (activeSection) {
|
||||||
|
void load({ section: activeSection });
|
||||||
|
} else {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [highlight?.start, highlight?.end, activeSection, outline.object_id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (markRef.current) markRef.current.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
}, [slice]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!slice?.range) return;
|
||||||
|
const next = await readText({ start: slice.range.end });
|
||||||
|
setSlice((prev) =>
|
||||||
|
prev && prev.range && next.range
|
||||||
|
? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } }
|
||||||
|
: next,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !slice) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-2 py-8 text-sm text-muted-foreground", className)}>
|
||||||
|
<Loader2 className="size-4 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!slice) return null;
|
||||||
|
|
||||||
|
const canReadMore = outline.extracted_chars != null && slice.range != null && slice.range.end < outline.extracted_chars;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<article className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">
|
||||||
|
{renderWithHighlight(slice, highlight, markRef)}
|
||||||
|
</article>
|
||||||
|
{canReadMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-read-more"
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
className="mt-3 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted"
|
||||||
|
>
|
||||||
|
Read more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderWithHighlight(
|
||||||
|
slice: TextSlice,
|
||||||
|
highlight: { start: number; end: number } | undefined,
|
||||||
|
markRef: RefObject<HTMLSpanElement | null>,
|
||||||
|
): ReactNode {
|
||||||
|
const text = slice.text ?? "";
|
||||||
|
const base = slice.range?.start ?? 0;
|
||||||
|
if (!highlight) return text;
|
||||||
|
const from = highlight.start - base;
|
||||||
|
const to = highlight.end - base;
|
||||||
|
if (from < 0 || from >= text.length || to <= from) return text;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text.slice(0, from)}
|
||||||
|
<span ref={markRef} className="rounded bg-[color-mix(in_oklab,var(--warning)_28%,transparent)] px-0.5">
|
||||||
|
{text.slice(from, to)}
|
||||||
|
</span>
|
||||||
|
{text.slice(to)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ObjectActions --------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ObjectActions {
|
||||||
|
onEditMeta?: () => void;
|
||||||
|
onVerify?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
onSupersede?: () => void;
|
||||||
|
onExtractClaims?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionButton: FC<{ onClick?: () => void; icon: ReactNode; children: ReactNode; action: string; danger?: boolean }> = ({
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
action,
|
||||||
|
danger,
|
||||||
|
}) =>
|
||||||
|
onClick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action={action}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition",
|
||||||
|
danger
|
||||||
|
? "border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||||
|
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// ---- ObjectViewer (composite) --------------------------------------------
|
||||||
|
|
||||||
|
export interface ObjectViewerProps {
|
||||||
|
outline: ObjectOutline;
|
||||||
|
readText: TextReaderProps["readText"];
|
||||||
|
/** Resolve a browser-openable blob URL (for images + download). */
|
||||||
|
resolveBlobUrl?: () => Promise<string>;
|
||||||
|
highlight?: { start: number; end: number };
|
||||||
|
onOpenObject?: (objectId: string, span?: { start: number; end: number }) => void;
|
||||||
|
actions?: ObjectActions;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ObjectViewer: FC<ObjectViewerProps> = ({
|
||||||
|
outline,
|
||||||
|
readText,
|
||||||
|
resolveBlobUrl,
|
||||||
|
highlight,
|
||||||
|
onOpenObject,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [activeSection, setActiveSection] = useState<string | undefined>();
|
||||||
|
const [blob, setBlob] = useState<string | null>(null);
|
||||||
|
const isImage = isImageMime(outline.mime);
|
||||||
|
const isVault = outline.sensitivity === "vault";
|
||||||
|
const hasActions =
|
||||||
|
!!actions &&
|
||||||
|
(actions.onEditMeta || actions.onVerify || actions.onDownload || actions.onArchive || actions.onSupersede || actions.onExtractClaims);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
if (isImage && outline.has_blob && resolveBlobUrl) {
|
||||||
|
void resolveBlobUrl().then((u) => {
|
||||||
|
if (live) setBlob(u);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [isImage, outline.has_blob, outline.object_id, resolveBlobUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-5", className)}>
|
||||||
|
<SupersessionBanner outline={outline} onOpen={onOpenObject} />
|
||||||
|
<CatalogCard outline={outline} />
|
||||||
|
|
||||||
|
{hasActions && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ActionButton action="knowledge-edit-meta" onClick={actions?.onEditMeta} icon={<Tag className="size-3.5" />}>Edit title & tags</ActionButton>
|
||||||
|
<ActionButton action="knowledge-verify" onClick={actions?.onVerify} icon={<Check className="size-3.5" />}>Confirm still current</ActionButton>
|
||||||
|
{outline.has_blob && <ActionButton action="knowledge-download" onClick={actions?.onDownload} icon={<Download className="size-3.5" />}>Download original</ActionButton>}
|
||||||
|
<ActionButton action="knowledge-supersede" onClick={actions?.onSupersede} icon={<ArrowUpRight className="size-3.5" />}>Replace with newer</ActionButton>
|
||||||
|
{!isVault && <ActionButton action="knowledge-extract-claims" onClick={actions?.onExtractClaims} icon={<Sparkles className="size-3.5" />}>Extract claims</ActionButton>}
|
||||||
|
<ActionButton action="knowledge-archive" onClick={actions?.onArchive} icon={<Trash2 className="size-3.5" />} danger>Archive</ActionButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isVault && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-[color-mix(in_oklab,var(--destructive)_8%,transparent)] px-3 py-2 text-sm">
|
||||||
|
<Lock className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||||
|
<span className="text-foreground">
|
||||||
|
<span className="font-medium">Vault.</span> Never sent to AI models — the catalog is file metadata only. You can still read and download it here.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isImage && outline.has_blob && (
|
||||||
|
<figure className="overflow-hidden rounded-xl border border-border bg-muted/30">
|
||||||
|
{blob ? (
|
||||||
|
<img src={blob} alt={outline.title} className="max-h-[480px] w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-40 items-center justify-center text-muted-foreground">
|
||||||
|
<ImageIcon className="size-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[1fr_260px]">
|
||||||
|
<div className="min-w-0 rounded-xl border border-border bg-card p-4">
|
||||||
|
{outline.extracted_chars && outline.extracted_chars > 0 ? (
|
||||||
|
<TextReader outline={outline} readText={readText} highlight={highlight} activeSection={activeSection} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isImage ? "This image's description is its searchable text — see the summary above." : "No extracted text for this object."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<aside className="flex flex-col gap-4">
|
||||||
|
{outline.outline.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-3">
|
||||||
|
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Outline</h3>
|
||||||
|
<OutlineNav sections={outline.outline} activeId={activeSection} onSelect={(s) => setActiveSection(s.id)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ProvenancePanel outline={outline} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user