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:
@@ -1,2 +1,470 @@
|
||||
// Placeholder — filled in its workstream (W4/W5/W6).
|
||||
export {};
|
||||
// PURPOSE: Object viewer (spec §3.4) — catalog card + provenance + supersession
|
||||
// + 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