From f2e4259c84b6f68d5a98c23c1d256d851ed589af Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 17:37:52 +0200 Subject: [PATCH] UX safety: confirm destructive deletes, scope dice clear, back up sessionLog, add 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/ui/useConfirm.tsx | 51 +++++++++++++++++++++++++++++ src/features/dice/DicePage.tsx | 15 +++++++-- src/features/world/HomebrewPage.tsx | 5 ++- src/features/world/MapsPage.tsx | 5 ++- src/features/world/NotesPage.tsx | 5 ++- src/features/world/QuestsPage.tsx | 5 ++- src/lib/db/repositories.ts | 3 +- src/lib/io/backup.ts | 4 ++- src/router.tsx | 15 +++++++-- tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 src/components/ui/useConfirm.tsx diff --git a/src/components/ui/useConfirm.tsx b/src/components/ui/useConfirm.tsx new file mode 100644 index 0000000..f69928e --- /dev/null +++ b/src/components/ui/useConfirm.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { Modal } from './Modal'; +import { Button } from './Button'; + +interface ConfirmOptions { + title: string; + message: React.ReactNode; + confirmLabel?: string; + danger?: boolean; +} + +/** + * Promise-based confirmation using the themed Modal (no native window.confirm). + * + * const { confirm, confirmElement } = useConfirm(); + * ... + * + + + } + > +
{state.message}
+ + ) : null; + + return { confirm, confirmElement }; +} diff --git a/src/features/dice/DicePage.tsx b/src/features/dice/DicePage.tsx index 0ef75f1..46e438a 100644 --- a/src/features/dice/DicePage.tsx +++ b/src/features/dice/DicePage.tsx @@ -11,6 +11,7 @@ import { useRollStore } from '@/stores/rollStore'; import { useSessionStore } from '@/stores/sessionStore'; import { Page, PageHeader } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; +import { useConfirm } from '@/components/ui/useConfirm'; import { Input } from '@/components/ui/Input'; import { Badge } from '@/components/ui/Codex'; import { cn } from '@/lib/cn'; @@ -34,11 +35,20 @@ function DieGlyph({ sides, size = 44 }: { sides: number; size?: number }) { export function DicePage() { const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined; + const { confirm, confirmElement } = useConfirm(); const [expr, setExpr] = useState('1d20'); const [last, setLast] = useState(null); const [rollCount, setRollCount] = useState(0); const [error, setError] = useState(null); + // Only ever clear the active campaign's history — never every campaign's rolls. + const clearHistory = async () => { + if (!campaignId) return; + if (await confirm({ title: 'Clear roll history?', message: 'Delete this campaign’s dice-roll history? This can’t be undone.', confirmLabel: 'Clear' })) { + void diceRepo.clear(campaignId); + } + }; + const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []); const macroKey = campaignId ?? ''; @@ -75,13 +85,14 @@ export function DicePage() { return ( + {confirmElement} 0 ? ( - ) : null diff --git a/src/features/world/HomebrewPage.tsx b/src/features/world/HomebrewPage.tsx index 48d4c60..102ee90 100644 --- a/src/features/world/HomebrewPage.tsx +++ b/src/features/world/HomebrewPage.tsx @@ -10,6 +10,7 @@ import { HOMEBREW_FIELDS, HOMEBREW_KIND_LABEL } from './homebrew'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; +import { useConfirm } from '@/components/ui/useConfirm'; import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; const KINDS: HomebrewKind[] = ['monster', 'spell', 'item', 'feat', 'condition']; @@ -94,6 +95,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) { function HomebrewCard({ hb }: { hb: Homebrew }) { const [h, setH] = useState(hb); + const { confirm, confirmElement } = useConfirm(); // Save the full snapshot (not partial patches) so editing several fields // quickly doesn't drop earlier edits under the debounce. const save = useDebouncedCallback((next: Homebrew) => void homebrewRepo.update(next.id, { name: next.name, fields: next.fields, description: next.description }), 350); @@ -104,8 +106,9 @@ function HomebrewCard({ hb }: { hb: Homebrew }) {
update({ name: e.target.value })} aria-label="Homebrew name" /> - +
+ {confirmElement} {HOMEBREW_FIELDS[hb.kind].length > 0 && (
{HOMEBREW_FIELDS[hb.kind].map((f) => ( diff --git a/src/features/world/MapsPage.tsx b/src/features/world/MapsPage.tsx index 0e72149..46572dd 100644 --- a/src/features/world/MapsPage.tsx +++ b/src/features/world/MapsPage.tsx @@ -10,6 +10,7 @@ import { useUiStore } from '@/stores/uiStore'; import { useMaps } from './hooks'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; +import { useConfirm } from '@/components/ui/useConfirm'; import { cn } from '@/lib/cn'; import { MapEditor } from './map/MapEditor'; @@ -22,6 +23,7 @@ export function MapsPage() { function Maps({ campaign }: { campaign: Campaign }) { const maps = useMaps(campaign.id); + const { confirm, confirmElement } = useConfirm(); const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(null); const fileRef = useRef(null); @@ -92,13 +94,14 @@ function Maps({ campaign }: { campaign: Campaign }) { {mp.name} {activeMapId === mp.id && } - ); })} + {confirmElement} {selected ? (
diff --git a/src/features/world/NotesPage.tsx b/src/features/world/NotesPage.tsx index e0f73b5..444c83f 100644 --- a/src/features/world/NotesPage.tsx +++ b/src/features/world/NotesPage.tsx @@ -7,6 +7,7 @@ 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() { @@ -98,6 +99,7 @@ function NoteEditor({ 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 }) => { @@ -127,11 +129,12 @@ function NoteEditor({
+ {confirmElement}
+ {confirmElement}