Files
ttrpg_manager/src/features/world/NotesPage.tsx
T
NilsBriggen f2e4259c84 UX safety: confirm destructive deletes, scope dice clear, back up sessionLog, add 404
- New themed useConfirm() hook (no native dialogs). World deletes (notes, quests,
  homebrew, maps) now confirm before removing.
- Dice 'Clear history' is disabled with no active campaign (it used to wipe EVERY
  campaign's rolls) and now confirms; only ever clears the active campaign.
- sessionLog is included in backup/restore and the campaign cascade-delete — it was
  silently dropped on backup and orphaned on campaign delete.
- Unknown URLs render a themed 'Page not found' with a link home instead of a blank shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:37:52 +02:00

191 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useConfirm } from '@/components/ui/useConfirm';
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(', '));
const { confirm, confirmElement } = useConfirm();
// 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 () => { if (await confirm({ title: 'Delete note?', message: <>Delete <strong className="text-ink">{title || 'this note'}</strong>? This cant be undone.</> })) { await notesRepo.remove(note.id); onSelect(null); } }}
>
Delete
</Button>
</div>
{confirmElement}
<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>
);
}