Files
ttrpg_manager/src/features/world/NotesPage.tsx
T
NilsBriggen 895807d6c9 Polish sprint 2/2: layouts, UX consistency, pf2e conditions
Layout:
- CombatantRow: action controls (damage/AC/move/remove) grouped into one
  right-aligned wrapper so they wrap together instead of scattering on narrow
  widths.
- top bar: the campaign switcher now shrinks/truncates instead of overflowing
  on tablet widths.
- character sheet header: stat coins go full-width grid on mobile; the name
  truncates and scales down on small screens.

UX consistency:
- player Cast and resource Spend/Regain no longer fail silently — Cast shows the
  reason, resource −/+ disable at their bounds.
- the AI encounter builder now catches load failures and reports them; level-up
  advisor shows the human error message, not the error-kind enum.
- section headers standardized on the .smallcaps design token across 16 files
  (replacing an ad-hoc uppercase class).
- player HP bar uses the shared Meter primitive.

pf2e depth: condition-effects table gains drained, dazzled, enfeebled, fatigued.
FeatCard: dropped a dead text-xl wrapper left from the emoji purge.

Left as documented (functional, off-theme, refactor-risky): the native
confirm/prompt in Settings cloud-conflict and the session-password flow.

Gate: 293 unit + 35 e2e + 2 realtime; tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:38:24 +02:00

188 lines
6.9 KiB
TypeScript

import { useMemo, useState } from 'react';
import type { Campaign, Note } from '@/lib/schemas';
import { notesRepo } from '@/lib/db/repositories';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { extractWikiLinks, splitWikiLinks } from '@/lib/wikilinks';
import { useNotes } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
export function NotesPage() {
return <RequireCampaign>{(c) => <Notes campaign={c} />}</RequireCampaign>;
}
function Notes({ campaign }: { campaign: Campaign }) {
const notes = useNotes(campaign.id);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const selected = notes.find((n) => n.id === selectedId) ?? null;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q
? notes.filter((n) => n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q) || n.tags.some((t) => t.toLowerCase().includes(q)))
: notes;
return [...list].sort((a, b) => a.title.localeCompare(b.title));
}, [notes, query]);
const createNote = async (title = 'Untitled') => {
const note = await notesRepo.create(campaign.id, title);
setSelectedId(note.id);
};
const openByTitle = async (title: string) => {
const existing = notes.find((n) => n.title.toLowerCase() === title.toLowerCase());
if (existing) setSelectedId(existing.id);
else await createNote(title);
};
return (
<Page>
<PageHeader
title="Notes & Wiki"
subtitle={`${campaign.name} · link entries with [[double brackets]]`}
actions={<Button variant="primary" onClick={() => createNote()}>+ New note</Button>}
/>
{notes.length === 0 ? (
<EmptyState
title="No notes yet"
hint="Capture lore, session recaps, and locations. Use [[wiki links]] to connect them."
action={<Button variant="primary" onClick={() => createNote()}>+ New note</Button>}
/>
) : (
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<div>
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search notes…" aria-label="Search notes" />
<ul className="mt-2 max-h-[65vh] space-y-1 overflow-auto">
{filtered.map((n) => (
<li key={n.id}>
<button
onClick={() => setSelectedId(n.id)}
className={cn(
'w-full truncate rounded-md border px-3 py-2 text-left text-sm',
selected?.id === n.id ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
)}
>
{n.title}
</button>
</li>
))}
</ul>
</div>
{selected ? (
<NoteEditor key={selected.id} note={selected} allNotes={notes} onOpenTitle={openByTitle} onSelect={setSelectedId} />
) : (
<div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a note to edit.</div>
)}
</div>
)}
</Page>
);
}
function NoteEditor({
note,
allNotes,
onOpenTitle,
onSelect,
}: {
note: Note;
allNotes: Note[];
onOpenTitle: (title: string) => void;
onSelect: (id: string | null) => void;
}) {
const [title, setTitle] = useState(note.title);
const [body, setBody] = useState(note.body);
const [tags, setTags] = useState(note.tags.join(', '));
// Save the full snapshot so editing title + body quickly doesn't drop an edit.
const save = useDebouncedCallback((next: { title: string; body: string; tags: string }) => {
void notesRepo.update(note.id, {
title: next.title,
body: next.body,
tags: next.tags.split(',').map((t) => t.trim()).filter(Boolean),
});
}, 350);
const persist = (over: Partial<{ title: string; body: string; tags: string }>) =>
save({ title, body, tags, ...over });
const backlinks = allNotes.filter(
(n) => n.id !== note.id && extractWikiLinks(n.body).some((t) => t.toLowerCase() === note.title.toLowerCase()),
);
const titlesLower = new Set(allNotes.map((n) => n.title.toLowerCase()));
return (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="mb-3 flex items-center gap-2">
<Input
className="font-display text-lg font-semibold"
value={title}
aria-label="Note title"
onChange={(e) => { setTitle(e.target.value); persist({ title: e.target.value }); }}
/>
<Button
variant="ghost"
className="text-danger"
onClick={async () => { await notesRepo.remove(note.id); onSelect(null); }}
>
Delete
</Button>
</div>
<label className="mb-3 block text-xs text-muted">
Tags (comma-separated)
<Input
value={tags}
onChange={(e) => { setTags(e.target.value); persist({ tags: e.target.value }); }}
placeholder="lore, barovia, npc"
/>
</label>
<textarea
value={body}
onChange={(e) => { setBody(e.target.value); persist({ body: e.target.value }); }}
placeholder="Write here… link other notes with [[Note Title]]."
className="min-h-48 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
{/* Rendered preview with clickable links */}
<div className="mt-4">
<h3 className="mb-1 smallcaps">Preview</h3>
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
{splitWikiLinks(body).map((part, i) =>
part.link ? (
<button
key={i}
onClick={() => onOpenTitle(part.link!)}
className={cn('underline', titlesLower.has(part.link.toLowerCase()) ? 'text-accent' : 'text-warning')}
title={titlesLower.has(part.link.toLowerCase()) ? 'Open note' : 'Create this note'}
>
{part.text}
</button>
) : (
<span key={i}>{part.text}</span>
),
)}
</p>
</div>
{backlinks.length > 0 && (
<div className="mt-4">
<h3 className="mb-1 smallcaps">Linked from</h3>
<div className="flex flex-wrap gap-1">
{backlinks.map((n) => (
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
{n.title}
</button>
))}
</div>
</div>
)}
</div>
);
}