Files
PubScript/apps/web/src/features/entities/EntitiesView.tsx
T

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 "";
}
}