diff --git a/client/src/pages/Catalog.jsx b/client/src/pages/Catalog.jsx index a2e758049e..428d73e879 100644 --- a/client/src/pages/Catalog.jsx +++ b/client/src/pages/Catalog.jsx @@ -30,13 +30,44 @@ const TYPES = [ const TYPE_BY_ID = Object.fromEntries(TYPES.map((t) => [t.id, t])); +// Per-type primary content key used by the inline "New" form, so users can +// capture the body in one step instead of bouncing into the editor. Mirrors +// the labels in CatalogIngredient.jsx — characters land in `physicalDescription` +// (canon shape), place/object in `description`, light types in `summary`. +const PRIMARY_CONTENT_KEY = { + character: 'physicalDescription', + place: 'description', + object: 'description', + idea: 'summary', + scene: 'summary', + concept: 'summary', +}; +// Display label per content key — driven from PRIMARY_CONTENT_KEY so a future +// type→key remapping automatically picks up the right label and the form +// label can't silently lie about where content lands on submit. +const PRIMARY_CONTENT_LABEL = { + physicalDescription: 'Physical Description', + description: 'Description', + summary: 'Summary', +}; + // Pull a short snippet from the type-specific payload — first hit wins, -// trimmed and ellipsised to ~120 chars. Characters use `physicalDescription` -// (canon shape), so check it first to avoid rendering empty rows for -// bible-backfilled characters whose only narrative text lives there. +// trimmed and ellipsised to ~120 chars. Fallback chain covers every canon +// narrative field across the six ingredient types: characters +// (physicalDescription / personality / role), places/objects (description / +// significance), light types (summary / notes). Without the broader fallback, +// objects-with-only-significance and characters-with-only-personality render +// as snippet-less rows. function payloadSnippet(payload) { if (!payload || typeof payload !== 'object') return ''; - const raw = payload.physicalDescription || payload.description || payload.summary || payload.notes || ''; + const raw = payload.physicalDescription + || payload.description + || payload.summary + || payload.personality + || payload.significance + || payload.role + || payload.notes + || ''; const text = String(raw).trim().replace(/\s+/g, ' '); if (text.length <= 120) return text; return `${text.slice(0, 117)}…`; @@ -61,9 +92,12 @@ export default function Catalog() { // debounced value that actually drives the list fetch. 300ms gap. const [searchInput, setSearchInput] = useState(''); const [q, setQ] = useState(''); - // Inline create form + // Inline create form. `content` is a single freeform textarea that lands in + // the per-type primary content field on submit (physicalDescription for + // character, description for place/object, summary for idea/scene/concept) + // so users don't have to navigate to the editor just to capture the body. const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState({ type: 'character', name: '' }); + const [form, setForm] = useState({ type: 'character', name: '', content: '' }); const [creating, setCreating] = useState(false); // Armed-row id for two-click delete (no window.confirm). const [armedId, setArmedId] = useState(null); @@ -111,11 +145,16 @@ export default function Catalog() { e.preventDefault(); const name = form.name.trim(); if (!name) return; + const content = form.content.trim(); + const payload = {}; + if (content) { + payload[PRIMARY_CONTENT_KEY[form.type] || 'description'] = content; + } setCreating(true); const created = await createCatalogIngredient({ type: form.type, name, - payload: {}, + payload, tags: [], }, { silent: true }).catch((err) => { toast.error(err?.message || 'Failed to create ingredient'); @@ -124,11 +163,18 @@ export default function Catalog() { setCreating(false); if (!created) return; toast.success(`Created ${form.type} "${name}"`); - setForm({ type: form.type, name: '' }); - setShowForm(false); - // Update list locally (CLAUDE.md: prefer state update over refetch) but - // still refresh stats so the type-chip counts move. - setItems((prev) => [created, ...prev]); + closeForm(); + // Only prepend optimistically if the new row would actually pass the + // current filter — otherwise it appears in a filtered view it doesn't + // match and lingers until the next refetch. Search-text gating is best- + // effort (string includes vs server-side full-text); when in doubt, skip + // the optimistic insert so the user just sees a clean view + accurate stats. + const matchesType = !selectedType || created.type === selectedType; + const matchesSearch = !q + || (created.name || '').toLowerCase().includes(q.toLowerCase()); + if (matchesType && matchesSearch) { + setItems((prev) => [created, ...prev]); + } loadStats(); }; @@ -150,6 +196,14 @@ export default function Catalog() { loadStats(); }; + // Single reset path used by Cancel + toolbar toggle + post-submit so all + // three "form dismissed" paths leave the same clean state — without this, + // stale name/content text reappears the next time the form opens. + const closeForm = () => { + setForm((f) => ({ type: f.type, name: '', content: '' })); + setShowForm(false); + }; + return (
@@ -170,7 +224,7 @@ export default function Catalog() {
{showForm && ( -
-
+ +
-
- - -
+
+
+ +