f2e4259c84
- 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>
191 lines
7.2 KiB
TypeScript
191 lines
7.2 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 { 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 can’t 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>
|
||
);
|
||
}
|