feat: resolve all 22 items in Features and Bugs.txt and complete UI polish

This commit is contained in:
Schwitzz
2026-10-30 10:57:13 -05:00
parent 7214cee8cb
commit 4add429469
42 changed files with 6360 additions and 1805 deletions
+116 -53
View File
@@ -2,14 +2,14 @@
* 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.
* 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, Select } from "@pubscript/ui";
import { Button, Input } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js";
import { EntityEditor } from "./EntityEditor.js";
import { entityLabel } from "./RelationshipEditor.js";
@@ -19,6 +19,9 @@ 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. */
@@ -27,18 +30,30 @@ interface Tab {
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 },
{
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 },
];
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) {
@@ -62,16 +77,12 @@ export function EntitiesView() {
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);
// Run every consistency check over the current entities and chapters.
const chapters = useProjectStore((s) => s.chapters);
const issues = React.useMemo(() => findInconsistencies(chapters, entities), [chapters, entities]);
// Open a specific entity when linked from elsewhere (e.g. the writing-view
// context rail: `/characters?entity=<id>`).
React.useEffect(() => {
const id = searchParams.get("entity");
if (!id) return;
@@ -80,17 +91,22 @@ export function EntitiesView() {
setDraft(e);
setOpen(true);
}
// Clear the param so a reload doesn't re-open the drawer.
navigate("/characters", { replace: true });
}, [searchParams, entities, navigate]);
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 activeTabDef = React.useMemo(
() => TABS.find((t) => t.id === tab) ?? TABS[0]!,
[tab],
);
const openNew = async () => {
const e = await createEntity(newKind);
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);
};
@@ -111,47 +127,102 @@ export function EntitiesView() {
};
return (
<div className="ps-entities-view">
<div className="ps-entities-view" style={{ padding: "20px 24px", overflowY: "auto" }}>
<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 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">
<Select
className="ps-entity-new-kind"
value={newKind}
options={KIND_OPTIONS}
onChange={(ev) => setNewKind(ev.target.value)}
/>
<Button onClick={openNew}>+ New</Button>
{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 entities…"
placeholder="Search people, places, world items, and notes…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</div>
{tab === "consistency" ? (
<ConsistencyPanel issues={issues} onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) { setDraft(e); setOpen(true); }
}} />
<ConsistencyPanel
issues={issues}
onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) {
setDraft(e);
setOpen(true);
}
}}
/>
) : visible.length === 0 ? (
<p className="ps-muted">No entities match.</p>
<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) => (
@@ -219,11 +290,6 @@ interface ConsistencyPanelProps {
onOpenEntity: (id: string) => void;
}
/**
* Consistency tab — shows the combined output of `findInconsistencies()` as a
* list of warnings. Each issue names the affected entity, which can be opened
* in the editor drawer.
*/
function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
if (issues.length === 0) {
return (
@@ -263,7 +329,6 @@ function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
);
}
/** Severity icon for an issue. */
function iconFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
@@ -277,7 +342,6 @@ function iconFor(issue: ConsistencyIssue): string {
}
}
/** Primary label for an issue. */
function titleFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
@@ -291,7 +355,6 @@ function titleFor(issue: ConsistencyIssue): string {
}
}
/** Secondary explanation for an issue. */
function detailFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":