Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 129 additions & 54 deletions client/src/pages/Catalog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}…`;
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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();
};

Expand All @@ -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 (
<div>
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
Expand All @@ -170,7 +224,7 @@ export default function Catalog() {
</Link>
<button
type="button"
onClick={() => setShowForm((v) => !v)}
onClick={() => (showForm ? closeForm() : setShowForm(true))}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-port-accent hover:bg-port-accent/90 text-white text-sm font-medium"
>
<Plus size={16} aria-hidden="true" />
Expand Down Expand Up @@ -221,8 +275,8 @@ export default function Catalog() {
</div>

{showForm && (
<form onSubmit={handleCreate} className="mb-6 p-4 bg-port-card border border-port-border rounded-lg">
<div className="grid grid-cols-1 sm:grid-cols-[180px_1fr_auto] gap-3 items-end">
<form onSubmit={handleCreate} className="mb-6 p-4 bg-port-card border border-port-border rounded-lg space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-[180px_1fr] gap-3">
<div>
<label htmlFor="catalog-new-type" className="block text-xs uppercase tracking-wider text-gray-500 mb-1">
Type
Expand Down Expand Up @@ -253,23 +307,37 @@ export default function Catalog() {
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={creating || !form.name.trim()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-port-accent text-white text-sm font-medium disabled:opacity-50"
>
{creating ? <Loader2 size={14} className="animate-spin" /> : null}
Create
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="px-3 py-2 rounded-lg text-gray-400 hover:text-white text-sm"
>
Cancel
</button>
</div>
</div>
<div>
<label htmlFor="catalog-new-content" className="block text-xs uppercase tracking-wider text-gray-500 mb-1">
{PRIMARY_CONTENT_LABEL[PRIMARY_CONTENT_KEY[form.type]] || 'Description'}
<span className="normal-case text-gray-500"> (optional)</span>
</label>
<textarea
id="catalog-new-content"
rows={4}
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
placeholder="Capture the idea here — you can flesh it out in the editor."
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm focus:outline-none focus:border-port-accent"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeForm}
className="px-3 py-2 rounded-lg text-gray-400 hover:text-white text-sm"
>
Cancel
</button>
<button
type="submit"
disabled={creating || !form.name.trim()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-port-accent text-white text-sm font-medium disabled:opacity-50"
>
{creating ? <Loader2 size={14} className="animate-spin" /> : null}
Create
</button>
</div>
</form>
)}
Expand All @@ -289,18 +357,36 @@ export default function Catalog() {
{items.map((it) => {
const armed = armedId === it.id;
const name = it.name || '(untitled)';
// The card is one big Link so clicking anywhere (name, badge, tags,
// snippet, empty body) opens the editor. The delete control sits in
// absolute-positioned overlay so it intercepts clicks before they
// bubble to the link.
return (
<li key={it.id} className="bg-port-card border border-port-border rounded-lg p-3 flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<Link
to={`/catalog/${encodeURIComponent(it.type)}/${encodeURIComponent(it.id)}`}
className="text-white font-medium hover:text-port-accent transition-colors min-w-0 flex-1 truncate"
>
{name}
</Link>
<li key={it.id} className="relative bg-port-card border border-port-border rounded-lg hover:border-port-accent/60 transition-colors">
<Link
to={`/catalog/${encodeURIComponent(it.type)}/${encodeURIComponent(it.id)}`}
className={`flex flex-col gap-2 p-3 min-h-[88px] ${armed ? 'pr-32' : 'pr-10'}`}
>
<span className="block text-white font-medium truncate">{name}</span>
<span className="flex items-center gap-1.5 flex-wrap">
<TypeBadge type={it.type} />
{(it.tags || []).slice(0, 4).map((tag) => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-port-bg border border-port-border text-gray-400">
{tag}
</span>
))}
</span>
{payloadSnippet(it.payload) ? (
<span className="text-xs text-gray-400 line-clamp-3">{payloadSnippet(it.payload)}</span>
) : null}
</Link>
<div className="absolute top-2 right-2">
{/* The delete control is a sibling of the <Link>, not a
descendant, so a click on these buttons never traverses
the anchor — no preventDefault/stopPropagation needed. */}
{armed ? (
<span className="inline-flex items-center gap-1 text-xs">
<span className="text-gray-400">Delete?</span>
<span className="inline-flex items-center gap-1 text-xs bg-port-card border border-port-border rounded px-1 py-0.5 shadow-sm">
<span className="text-gray-400 pl-1">Delete?</span>
<button
type="button"
onClick={() => confirmDelete(it)}
Expand All @@ -320,25 +406,14 @@ export default function Catalog() {
<button
type="button"
onClick={() => setArmedId(it.id)}
className="p-1.5 rounded text-gray-500 hover:text-port-error"
className="p-1.5 rounded text-gray-500 hover:text-port-error bg-port-card"
aria-label={`Delete ${name}`}
title="Delete ingredient"
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<TypeBadge type={it.type} />
{(it.tags || []).slice(0, 4).map((tag) => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-port-bg border border-port-border text-gray-400">
{tag}
</span>
))}
</div>
{payloadSnippet(it.payload) ? (
<p className="text-xs text-gray-400 line-clamp-3">{payloadSnippet(it.payload)}</p>
) : null}
</li>
);
})}
Expand Down
26 changes: 25 additions & 1 deletion server/services/embeddings.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,35 @@ export async function embedText(text, options = {}) {
* the seed is consistent across commit, manual create, manual edit, and
* the admin backfill.
*
* Character canon shape uses `physicalDescription` + `personality` (per
* `sanitizeCharacter` in storyBible.js); without these in the seed, a
* character's main descriptive text is never embedded and semantic search
* misses every character whose content was captured through the inline New
* form or the writers-room/bible extractor — only the name would match.
*
* Returns `''` when there's nothing worth embedding so callers can skip.
*/
export function ingredientEmbedSeed({ name, payload } = {}) {
const p = payload || {};
return [name, p.description, p.summary, p.notes, p.background]
// Include every narrative-weight field across the six ingredient types:
// character → role/physicalDescription/personality/background/motivations;
// place → slugline/era/description; object → description/significance;
// light types (idea/scene/concept) → summary/description/notes. Missing any
// of these leaves an ingredient effectively name-only in vector search.
return [
name,
p.role,
p.description,
p.physicalDescription,
p.personality,
p.background,
p.motivations,
p.summary,
p.notes,
p.slugline,
p.era,
p.significance,
]
.filter(Boolean)
.join(' ')
.slice(0, 4000);
Expand Down