376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
/**
|
|
* 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<TabId>("all");
|
|
const [query, setQuery] = React.useState("");
|
|
const [draft, setDraft] = React.useState<Entity | null>(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 (
|
|
<div className="ps-entities-view">
|
|
<div className="ps-entities-pinned-header">
|
|
<div className="ps-entity-toolbar">
|
|
<div className="ps-entity-tabs">
|
|
{TABS.map((t) => (
|
|
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
|
|
<button
|
|
type="button"
|
|
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
|
onClick={() => setTab(t.id)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
{t.createKind && (
|
|
<button
|
|
type="button"
|
|
className="ps-entity-tab-add"
|
|
title={t.createLabel ?? `Add to ${t.label}`}
|
|
aria-label={t.createLabel ?? `Add to ${t.label}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void handleCreateKind(t.createKind!, t.id);
|
|
}}
|
|
>
|
|
+
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="ps-entity-new-group">
|
|
{activeTabDef.createKind ? (
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
|
|
>
|
|
{activeTabDef.createLabel}
|
|
</Button>
|
|
) : tab === "all" ? (
|
|
<>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => void handleCreateKind("character", "people")}
|
|
>
|
|
+ Person
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => void handleCreateKind("world", "places")}
|
|
>
|
|
+ World Item
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => void handleCreateKind("note", "notes")}
|
|
>
|
|
+ Note
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ps-entity-search">
|
|
<Input
|
|
className="ps-entity-search-input"
|
|
placeholder="Search people, places, world items, and notes…"
|
|
value={query}
|
|
onChange={(ev) => setQuery(ev.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ps-entities-body">
|
|
<div className="ps-entities-main">
|
|
{tab === "consistency" ? (
|
|
<ConsistencyPanel
|
|
issues={issues}
|
|
onOpenEntity={(id) => {
|
|
const e = entities.find((x) => x.id === id);
|
|
if (e) {
|
|
setDraft(e);
|
|
setOpen(true);
|
|
}
|
|
}}
|
|
/>
|
|
) : visible.length === 0 ? (
|
|
<div className="ps-emptystate">
|
|
<div className="ps-emptystate-title">No entities in this view yet</div>
|
|
<p className="ps-muted">
|
|
{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."}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="ps-entity-grid">
|
|
{visible.map((e) => (
|
|
<button
|
|
key={e.id}
|
|
type="button"
|
|
className={`ps-entity-card${open && draft?.id === e.id ? " is-selected" : ""}`}
|
|
onClick={() => openEntity(e)}
|
|
>
|
|
<div className="ps-entity-card-top">
|
|
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
|
|
</div>
|
|
<div className="ps-entity-card-name">{entityLabel(e)}</div>
|
|
<div className="ps-entity-card-sub">{subFor(e)}</div>
|
|
{tagsFor(e).length > 0 && (
|
|
<div className="ps-entity-card-tags">
|
|
{tagsFor(e).slice(0, 4).map((t) => (
|
|
<span key={t} className="ps-entity-tag">
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<EntityEditor
|
|
entity={draft}
|
|
entities={entities}
|
|
open={open}
|
|
onClose={() => setOpen(false)}
|
|
onSave={save}
|
|
onDelete={remove}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="ps-consistency-empty">
|
|
<div className="ps-consistency-empty-icon">✓</div>
|
|
<p className="ps-muted">No inconsistencies found. Your entity registry and chapters are consistent.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="ps-consistency-panel">
|
|
<div className="ps-consistency-header">
|
|
<span className="ps-consistency-count">{issues.length} issue{issues.length === 1 ? "" : "s"}</span>
|
|
</div>
|
|
<ul className="ps-consistency-list">
|
|
{issues.map((issue, i) => (
|
|
<li key={`${issue.kind}-${i}`} className="ps-consistency-item">
|
|
<span className="ps-consistency-icon">{iconFor(issue)}</span>
|
|
<div className="ps-consistency-body">
|
|
<div className="ps-consistency-title">{titleFor(issue)}</div>
|
|
<div className="ps-consistency-detail">{detailFor(issue)}</div>
|
|
</div>
|
|
{(issue.kind === "orphan-mention" || issue.kind === "dead-character-mentioned") && (
|
|
<button
|
|
type="button"
|
|
className="ps-consistency-open"
|
|
onClick={() => onOpenEntity(issue.entityId)}
|
|
>
|
|
Open
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 "";
|
|
}
|
|
}
|