895807d6c9
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>
188 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|