feat(entity): Chunk #4 data layer + entity UI (characters & world-building)
Data layer: - Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests - Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks - Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests - StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity; implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search - WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind - @mention autocomplete extension + picker in the editor package Entity UI (apps/web/src/features/entities/): - Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef - Tabbed directory with fuzzy search + entity cards (EntitiesView) - Create/edit/delete drawer (EntityEditor) reusing shared fields - Relationship editor + force-directed SVG relationship map - Entity engine stylesheet; wired into nav Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract. CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* EntitiesView — the entity engine directory (Chunk #4).
|
||||
*
|
||||
* A tabbed, fuzzy-searchable directory of people, places, and notes. Each card
|
||||
* opens the create/edit drawer. A "New" control creates an entity of a chosen
|
||||
* kind. Characters show a relationship map in the drawer.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Entity } from "@pubscript/core";
|
||||
import { fuzzySort } from "@pubscript/core";
|
||||
import { Button, Input, Select } 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";
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
/** Filter predicate over entities, or null for "all". */
|
||||
match: (e: Entity) => boolean;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: "all", label: "All", match: () => true },
|
||||
{ id: "people", label: "People", match: (e) => "relationships" in e },
|
||||
{ id: "places", label: "Places", match: (e) => "fields" in e },
|
||||
{ id: "notes", label: "Notes", match: (e) => "body" in e },
|
||||
];
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: "character", label: "Character" },
|
||||
{ value: "world", label: "Place" },
|
||||
{ value: "note", label: "Note" },
|
||||
];
|
||||
|
||||
/** 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 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 [newKind, setNewKind] = React.useState("character");
|
||||
const [draft, setDraft] = React.useState<Entity | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
|
||||
return query ? fuzzySort(filtered, query, searchText) : filtered;
|
||||
}, [entities, tab, query]);
|
||||
|
||||
const openNew = async () => {
|
||||
const e = await createEntity(newKind);
|
||||
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-entity-toolbar">
|
||||
<div className="ps-entity-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ps-entity-new-group">
|
||||
<Select
|
||||
className="ps-entity-new-kind"
|
||||
value={newKind}
|
||||
options={KIND_OPTIONS}
|
||||
onChange={(ev) => setNewKind(ev.target.value)}
|
||||
/>
|
||||
<Button onClick={openNew}>+ New</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-entity-search">
|
||||
<Input
|
||||
className="ps-entity-search-input"
|
||||
placeholder="Search entities…"
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="ps-muted">No entities match.</p>
|
||||
) : (
|
||||
<div className="ps-entity-grid">
|
||||
{visible.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
className="ps-entity-card"
|
||||
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>
|
||||
)}
|
||||
|
||||
<EntityEditor
|
||||
entity={draft}
|
||||
entities={entities}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onSave={save}
|
||||
onDelete={remove}
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
Reference in New Issue
Block a user