feat: resolve all 22 items in Features and Bugs.txt and complete UI polish
This commit is contained in:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user