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>
This commit is contained in:
@@ -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();
|
||||||
|
* ...
|
||||||
|
* <Button onClick={async () => { if (await confirm({ title, message })) doIt(); }} />
|
||||||
|
* {confirmElement}
|
||||||
|
*/
|
||||||
|
export function useConfirm() {
|
||||||
|
const [state, setState] = useState<(ConfirmOptions & { resolve: (ok: boolean) => void }) | null>(null);
|
||||||
|
|
||||||
|
const confirm = (opts: ConfirmOptions) =>
|
||||||
|
new Promise<boolean>((resolve) => setState({ ...opts, resolve }));
|
||||||
|
|
||||||
|
const settle = (ok: boolean) => {
|
||||||
|
state?.resolve(ok);
|
||||||
|
setState(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmElement = state ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => settle(false)}
|
||||||
|
title={state.title}
|
||||||
|
className="max-w-md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => settle(false)}>Cancel</Button>
|
||||||
|
<Button variant={state.danger === false ? 'primary' : 'danger'} onClick={() => settle(true)}>
|
||||||
|
{state.confirmLabel ?? 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-muted">{state.message}</div>
|
||||||
|
</Modal>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return { confirm, confirmElement };
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useRollStore } from '@/stores/rollStore';
|
|||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
import { Page, PageHeader } from '@/components/ui/Page';
|
import { Page, PageHeader } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { useConfirm } from '@/components/ui/useConfirm';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Badge } from '@/components/ui/Codex';
|
import { Badge } from '@/components/ui/Codex';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
@@ -34,11 +35,20 @@ function DieGlyph({ sides, size = 44 }: { sides: number; size?: number }) {
|
|||||||
|
|
||||||
export function DicePage() {
|
export function DicePage() {
|
||||||
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
|
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
|
||||||
|
const { confirm, confirmElement } = useConfirm();
|
||||||
const [expr, setExpr] = useState('1d20');
|
const [expr, setExpr] = useState('1d20');
|
||||||
const [last, setLast] = useState<RollResult | null>(null);
|
const [last, setLast] = useState<RollResult | null>(null);
|
||||||
const [rollCount, setRollCount] = useState(0);
|
const [rollCount, setRollCount] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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 history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []);
|
||||||
|
|
||||||
const macroKey = campaignId ?? '';
|
const macroKey = campaignId ?? '';
|
||||||
@@ -75,13 +85,14 @@ export function DicePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
{confirmElement}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Cast the bones"
|
eyebrow="Cast the bones"
|
||||||
title="Dice"
|
title="Dice"
|
||||||
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
|
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
|
||||||
actions={
|
actions={
|
||||||
history.length > 0 ? (
|
history.length > 0 && campaignId ? (
|
||||||
<Button variant="ghost" onClick={() => void diceRepo.clear(campaignId)}>
|
<Button variant="ghost" onClick={() => void clearHistory()}>
|
||||||
Clear history
|
Clear history
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { HOMEBREW_FIELDS, HOMEBREW_KIND_LABEL } from './homebrew';
|
|||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
|
import { useConfirm } from '@/components/ui/useConfirm';
|
||||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
|
|
||||||
const KINDS: HomebrewKind[] = ['monster', 'spell', 'item', 'feat', 'condition'];
|
const KINDS: HomebrewKind[] = ['monster', 'spell', 'item', 'feat', 'condition'];
|
||||||
@@ -94,6 +95,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) {
|
|||||||
|
|
||||||
function HomebrewCard({ hb }: { hb: Homebrew }) {
|
function HomebrewCard({ hb }: { hb: Homebrew }) {
|
||||||
const [h, setH] = useState(hb);
|
const [h, setH] = useState(hb);
|
||||||
|
const { confirm, confirmElement } = useConfirm();
|
||||||
// Save the full snapshot (not partial patches) so editing several fields
|
// Save the full snapshot (not partial patches) so editing several fields
|
||||||
// quickly doesn't drop earlier edits under the debounce.
|
// 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);
|
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 }) {
|
|||||||
<div className="rounded-lg border border-line bg-panel p-4">
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
|
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}><X size={14} aria-hidden /></Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={async () => { if (await confirm({ title: 'Delete entry?', message: <>Delete <strong className="text-ink">{h.name || 'this entry'}</strong>? This can’t be undone.</> })) void homebrewRepo.remove(hb.id); }} aria-label={`Delete ${h.name}`}><X size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
{confirmElement}
|
||||||
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
|
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
{HOMEBREW_FIELDS[hb.kind].map((f) => (
|
{HOMEBREW_FIELDS[hb.kind].map((f) => (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useUiStore } from '@/stores/uiStore';
|
|||||||
import { useMaps } from './hooks';
|
import { useMaps } from './hooks';
|
||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { useConfirm } from '@/components/ui/useConfirm';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { MapEditor } from './map/MapEditor';
|
import { MapEditor } from './map/MapEditor';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export function MapsPage() {
|
|||||||
|
|
||||||
function Maps({ campaign }: { campaign: Campaign }) {
|
function Maps({ campaign }: { campaign: Campaign }) {
|
||||||
const maps = useMaps(campaign.id);
|
const maps = useMaps(campaign.id);
|
||||||
|
const { confirm, confirmElement } = useConfirm();
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -92,13 +94,14 @@ function Maps({ campaign }: { campaign: Campaign }) {
|
|||||||
<span className="truncate font-display font-medium">{mp.name}</span>
|
<span className="truncate font-display font-medium">{mp.name}</span>
|
||||||
{activeMapId === mp.id && <Cast size={13} className="ml-auto shrink-0 text-accent-deep" aria-hidden />}
|
{activeMapId === mp.id && <Cast size={13} className="ml-auto shrink-0 text-accent-deep" aria-hidden />}
|
||||||
</button>
|
</button>
|
||||||
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); }} aria-label={`Delete ${mp.name}`}>
|
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { if (await confirm({ title: 'Delete map?', message: <>Delete <strong className="text-ink">{mp.name || 'this map'}</strong>? This can’t be undone.</> })) { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); } }} aria-label={`Delete ${mp.name}`}>
|
||||||
<X size={14} aria-hidden />
|
<X size={14} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
{confirmElement}
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2">
|
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useNotes } from './hooks';
|
|||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { useConfirm } from '@/components/ui/useConfirm';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
export function NotesPage() {
|
export function NotesPage() {
|
||||||
@@ -98,6 +99,7 @@ function NoteEditor({
|
|||||||
const [title, setTitle] = useState(note.title);
|
const [title, setTitle] = useState(note.title);
|
||||||
const [body, setBody] = useState(note.body);
|
const [body, setBody] = useState(note.body);
|
||||||
const [tags, setTags] = useState(note.tags.join(', '));
|
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.
|
// Save the full snapshot so editing title + body quickly doesn't drop an edit.
|
||||||
const save = useDebouncedCallback((next: { title: string; body: string; tags: string }) => {
|
const save = useDebouncedCallback((next: { title: string; body: string; tags: string }) => {
|
||||||
@@ -127,11 +129,12 @@ function NoteEditor({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-danger"
|
className="text-danger"
|
||||||
onClick={async () => { await notesRepo.remove(note.id); onSelect(null); }}
|
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
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{confirmElement}
|
||||||
|
|
||||||
<label className="mb-3 block text-xs text-muted">
|
<label className="mb-3 block text-xs text-muted">
|
||||||
Tags (comma-separated)
|
Tags (comma-separated)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useEncounters } from '@/features/combat/hooks';
|
|||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
|
import { useConfirm } from '@/components/ui/useConfirm';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed'];
|
const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed'];
|
||||||
@@ -89,6 +90,7 @@ function Quests({ campaign }: { campaign: Campaign }) {
|
|||||||
function QuestCard({ quest }: { quest: Quest }) {
|
function QuestCard({ quest }: { quest: Quest }) {
|
||||||
const [q, setQ] = useState(quest);
|
const [q, setQ] = useState(quest);
|
||||||
const [newObj, setNewObj] = useState('');
|
const [newObj, setNewObj] = useState('');
|
||||||
|
const { confirm, confirmElement } = useConfirm();
|
||||||
const save = useDebouncedCallback((next: Quest) => void questsRepo.update(next.id, {
|
const save = useDebouncedCallback((next: Quest) => void questsRepo.update(next.id, {
|
||||||
title: next.title, status: next.status, description: next.description, reward: next.reward, objectives: next.objectives,
|
title: next.title, status: next.status, description: next.description, reward: next.reward, objectives: next.objectives,
|
||||||
}), 350);
|
}), 350);
|
||||||
@@ -110,8 +112,9 @@ function QuestCard({ quest }: { quest: Quest }) {
|
|||||||
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
|
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
|
||||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={async () => { if (await confirm({ title: 'Delete quest?', message: <>Delete <strong className="text-ink">{q.title || 'this quest'}</strong>? This can’t be undone.</> })) void questsRepo.remove(quest.id); }} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
{confirmElement}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={q.description}
|
value={q.description}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const campaignsRepo = {
|
|||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await db.transaction(
|
await db.transaction(
|
||||||
'rw',
|
'rw',
|
||||||
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew],
|
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew, db.sessionLog],
|
||||||
async () => {
|
async () => {
|
||||||
await db.characters.where('campaignId').equals(id).delete();
|
await db.characters.where('campaignId').equals(id).delete();
|
||||||
await db.encounters.where('campaignId').equals(id).delete();
|
await db.encounters.where('campaignId').equals(id).delete();
|
||||||
@@ -75,6 +75,7 @@ export const campaignsRepo = {
|
|||||||
await db.quests.where('campaignId').equals(id).delete();
|
await db.quests.where('campaignId').equals(id).delete();
|
||||||
await db.maps.where('campaignId').equals(id).delete();
|
await db.maps.where('campaignId').equals(id).delete();
|
||||||
await db.homebrew.where('campaignId').equals(id).delete();
|
await db.homebrew.where('campaignId').equals(id).delete();
|
||||||
|
await db.sessionLog.where('campaignId').equals(id).delete();
|
||||||
await db.calendars.delete(id);
|
await db.calendars.delete(id);
|
||||||
await db.campaigns.delete(id);
|
await db.campaigns.delete(id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { db } from '@/lib/db/db';
|
|||||||
import {
|
import {
|
||||||
campaignSchema, characterSchema, encounterSchema, diceRollSchema,
|
campaignSchema, characterSchema, encounterSchema, diceRollSchema,
|
||||||
noteSchema, npcSchema, questSchema, calendarSchema, battleMapSchema, homebrewSchema,
|
noteSchema, npcSchema, questSchema, calendarSchema, battleMapSchema, homebrewSchema,
|
||||||
|
sessionLogEntrySchema,
|
||||||
} from '@/lib/schemas';
|
} from '@/lib/schemas';
|
||||||
import { downloadJson } from './file';
|
import { downloadJson } from './file';
|
||||||
|
|
||||||
const TABLES = [
|
const TABLES = [
|
||||||
'campaigns', 'characters', 'encounters', 'diceRolls',
|
'campaigns', 'characters', 'encounters', 'diceRolls',
|
||||||
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew',
|
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew', 'sessionLog',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Schema per table, so restored rows are validated + backfilled with defaults
|
/** Schema per table, so restored rows are validated + backfilled with defaults
|
||||||
@@ -17,6 +18,7 @@ const SCHEMA: Record<(typeof TABLES)[number], ZodTypeAny> = {
|
|||||||
campaigns: campaignSchema, characters: characterSchema, encounters: encounterSchema,
|
campaigns: campaignSchema, characters: characterSchema, encounters: encounterSchema,
|
||||||
diceRolls: diceRollSchema, notes: noteSchema, npcs: npcSchema, quests: questSchema,
|
diceRolls: diceRollSchema, notes: noteSchema, npcs: npcSchema, quests: questSchema,
|
||||||
calendars: calendarSchema, maps: battleMapSchema, homebrew: homebrewSchema,
|
calendars: calendarSchema, maps: battleMapSchema, homebrew: homebrewSchema,
|
||||||
|
sessionLog: sessionLogEntrySchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORMAT = 'ttrpg-manager:backup';
|
const FORMAT = 'ttrpg-manager:backup';
|
||||||
|
|||||||
+13
-2
@@ -1,4 +1,4 @@
|
|||||||
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
|
import { createRootRoute, createRoute, createRouter, Link } from '@tanstack/react-router';
|
||||||
import { RootLayout } from '@/app/RootLayout';
|
import { RootLayout } from '@/app/RootLayout';
|
||||||
import { CampaignsPage } from '@/features/campaigns/CampaignsPage';
|
import { CampaignsPage } from '@/features/campaigns/CampaignsPage';
|
||||||
import { CharactersPage } from '@/features/characters/CharactersPage';
|
import { CharactersPage } from '@/features/characters/CharactersPage';
|
||||||
@@ -62,7 +62,18 @@ const routeTree = rootRoute.addChildren([
|
|||||||
assistantRoute,
|
assistantRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
/** Shown for any URL outside the routes above (previously rendered a blank shell). */
|
||||||
|
function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="p-10 text-center">
|
||||||
|
<h1 className="font-display text-2xl font-semibold text-ink">Page not found</h1>
|
||||||
|
<p className="mt-2 text-muted">That page doesn’t exist or has moved.</p>
|
||||||
|
<Link to="/" className="mt-4 inline-block text-accent hover:underline">Back to campaigns</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface Register {
|
interface Register {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user