/** * EntitiesView β€” the entity engine directory (Chunk #4). * * A tabbed, fuzzy-searchable directory of people, places, and notes. Each card * opens the create/edit drawer. Each individual entity tab provides its own * direct "+" add action rather than a catch-all dropdown. */ import React from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import type { ConsistencyIssue, Entity } from "@pubscript/core"; import { fuzzySort, findInconsistencies } from "@pubscript/core"; import { Button, Input } from "@pubscript/ui"; import { useProjectStore } from "../../store/project-store.js"; import { EntityEditor } from "./EntityEditor.js"; import { entityLabel } from "./RelationshipEditor.js"; type TabId = "all" | "people" | "places" | "notes" | "consistency"; interface Tab { id: TabId; label: string; /** Entity kind created when clicking "+" on this tab, if applicable. */ createKind?: "character" | "world" | "note"; createLabel?: string; /** Filter predicate over entities, or null for "all". */ match: (e: Entity) => boolean; /** When true, the tab shows a special view instead of entity cards. */ special?: boolean; } const TABS: Tab[] = [ { id: "all", label: "All", match: () => true }, { id: "people", label: "People", createKind: "character", createLabel: "+ Add Person", match: (e) => "relationships" in e, }, { id: "places", label: "Places & World", createKind: "world", createLabel: "+ Add World Item", match: (e) => "fields" in e, }, { id: "notes", label: "Notes", createKind: "note", createLabel: "+ Add Note", match: (e) => "body" in e, }, { id: "consistency", label: "Consistency", match: () => true, special: true }, ]; /** Human-readable text used for fuzzy matching. */ function searchText(e: Entity): string { if ("fields" in e) { return [e.name, e.kind, ...Object.values(e.fields).filter((v) => typeof v === "string")].join(" "); } if ("body" in e) { return [e.body, ...e.tags].join(" "); } const c = e; const rel = (c.relationships ?? []).map((r) => r.kind).join(" "); return [c.name, c.role, c.bio, c.appearance, c.voice, c.arc, c.status, rel, ...c.tags].join(" "); } export function EntitiesView() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const entities = useProjectStore((s) => s.entities); const createEntity = useProjectStore((s) => s.createEntity); const updateEntity = useProjectStore((s) => s.updateEntity); const deleteEntity = useProjectStore((s) => s.deleteEntity); const [tab, setTab] = React.useState("all"); const [query, setQuery] = React.useState(""); const [draft, setDraft] = React.useState(null); const [open, setOpen] = React.useState(false); const chapters = useProjectStore((s) => s.chapters); const issues = React.useMemo(() => findInconsistencies(chapters, entities), [chapters, entities]); React.useEffect(() => { const id = searchParams.get("entity"); if (!id) return; const e = entities.find((x) => x.id === id); if (e) { setDraft(e); setOpen(true); } navigate("/characters", { replace: true }); }, [searchParams, entities, navigate]); const activeTabDef = React.useMemo( () => TABS.find((t) => t.id === tab) ?? TABS[0]!, [tab], ); const visible = React.useMemo(() => { const filtered = entities.filter((e) => activeTabDef.match(e)); return query ? fuzzySort(filtered, query, searchText) : filtered; }, [entities, activeTabDef, query]); const handleCreateKind = async (kind: "character" | "world" | "note", switchToTab?: TabId) => { if (switchToTab) setTab(switchToTab); const e = await createEntity(kind); setDraft(e); setOpen(true); }; const openEntity = (e: Entity) => { setDraft(e); setOpen(true); }; const save = (e: Entity) => { void updateEntity(e); }; const remove = async (id: string) => { await deleteEntity(id); setOpen(false); setDraft(null); }; return (
{TABS.map((t) => (
{t.createKind && ( )}
))}
{activeTabDef.createKind ? ( ) : tab === "all" ? ( <> ) : null}
setQuery(ev.target.value)} />
{tab === "consistency" ? ( { const e = entities.find((x) => x.id === id); if (e) { setDraft(e); setOpen(true); } }} /> ) : visible.length === 0 ? (
No entities in this view yet

{activeTabDef.createKind ? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.` : "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}

) : (
{visible.map((e) => ( ))}
)}
setOpen(false)} onSave={save} onDelete={remove} />
); } /** Short badge label for a card. */ function badgeFor(e: Entity): string { if ("fields" in e) return e.kind; if ("body" in e) return "note"; return "person"; } /** Secondary line shown under the name. */ function subFor(e: Entity): string { if ("fields" in e) return e.kind; if ("body" in e) return e.body.slice(0, 80) || "Note"; const c = e; return c.role || c.status || ""; } /** Tags for a card. */ function tagsFor(e: Entity): string[] { if ("fields" in e) return []; if ("body" in e) return e.tags; return e.tags; } interface ConsistencyPanelProps { issues: ConsistencyIssue[]; onOpenEntity: (id: string) => void; } function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) { if (issues.length === 0) { return (
βœ“

No inconsistencies found. Your entity registry and chapters are consistent.

); } return (
{issues.length} issue{issues.length === 1 ? "" : "s"}
    {issues.map((issue, i) => (
  • {iconFor(issue)}
    {titleFor(issue)}
    {detailFor(issue)}
    {(issue.kind === "orphan-mention" || issue.kind === "dead-character-mentioned") && ( )}
  • ))}
); } function iconFor(issue: ConsistencyIssue): string { switch (issue.kind) { case "orphan-mention": return "⚠"; case "dead-character-mentioned": return "πŸ’€"; case "relationship-asymmetry": return "↔"; default: return "β€’"; } } function titleFor(issue: ConsistencyIssue): string { switch (issue.kind) { case "orphan-mention": return `Orphaned mention in β€œ${issue.chapterTitle}”`; case "dead-character-mentioned": return `Dead character mentioned in β€œ${issue.chapterTitle}”`; case "relationship-asymmetry": return `Unreciprocated ${issue.relationship} relationship`; default: return "Consistency issue"; } } function detailFor(issue: ConsistencyIssue): string { switch (issue.kind) { case "orphan-mention": return `A mention references an entity that no longer exists.`; case "dead-character-mentioned": return `${issue.name ?? "A character"} is marked dead but still appears in the chapter.`; case "relationship-asymmetry": return `${issue.fromName} lists ${issue.toName} as β€œ${issue.relationship}”, but the feeling isn't recorded on the other side.`; default: return ""; } }