Phase 10: command palette, settings, backup/restore, sample campaign
- Command palette (Ctrl/Cmd+K): fuzzy search over pages + active-campaign characters/notes/NPCs/quests + actions; keyboard nav; header ⌘K button - Settings page: theme, full backup export/import (restore replaces all in one transaction), load sample campaign, danger-zone clear-all - Backup lib (src/lib/io/backup.ts) snapshots every table; sample seeder (src/lib/sample.ts) builds a demo campaign - Header gets ⌘K + settings gear; routes + nav wired Backup round-trip unit tests + command-palette/settings e2e. (Deferred from this phase, noted in plan: audio, Discord/Foundry/Roll20, i18n, push notifications.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(async () => {
|
||||
indexedDB.deleteDatabase('ttrpg-manager');
|
||||
localStorage.clear();
|
||||
});
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
test('command palette navigates', async ({ page }) => {
|
||||
await page.keyboard.press('Control+k');
|
||||
const input = page.getByLabel('Command search');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill('Dice');
|
||||
await input.press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Dice' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('settings loads the sample campaign', async ({ page }) => {
|
||||
await page.getByLabel('Settings').click();
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||
// sample seeded characters (use the nav link, not the dashboard card)
|
||||
await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click();
|
||||
await expect(page.getByText('Lia the Brave')).toBeVisible();
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { CommandPalette } from '@/components/CommandPalette';
|
||||
import { useActiveCampaign, useCampaigns } from '@/features/campaigns/hooks';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
@@ -53,6 +55,19 @@ function ThemeToggle() {
|
||||
|
||||
export function RootLayout() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
// Global Ctrl/Cmd+K opens the command palette.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setPaletteOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -78,6 +93,10 @@ export function RootLayout() {
|
||||
})}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
|
||||
⌘K
|
||||
</Button>
|
||||
<Link to="/settings" className="rounded-md p-1.5 text-sm text-muted hover:text-ink" title="Settings" aria-label="Settings">⚙</Link>
|
||||
<CampaignSwitcher />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
@@ -90,6 +109,7 @@ export function RootLayout() {
|
||||
</main>
|
||||
|
||||
<RollTray />
|
||||
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import Fuse from 'fuse.js';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useNotes, useNpcs, useQuests } from '@/features/world/hooks';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
const NAV: { label: string; to: string }[] = [
|
||||
{ label: 'Campaigns', to: '/' },
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
{ label: 'Characters', to: '/characters' },
|
||||
{ label: 'Combat', to: '/combat' },
|
||||
{ label: 'Dice', to: '/dice' },
|
||||
{ label: 'Compendium', to: '/compendium' },
|
||||
{ label: 'Notes', to: '/notes' },
|
||||
{ label: 'NPCs', to: '/npcs' },
|
||||
{ label: 'Quests', to: '/quests' },
|
||||
{ label: 'Maps', to: '/maps' },
|
||||
{ label: 'Homebrew', to: '/homebrew' },
|
||||
{ label: 'Player View', to: '/play' },
|
||||
{ label: 'Settings', to: '/settings' },
|
||||
];
|
||||
|
||||
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const toggleTheme = useUiStore((s) => s.toggleTheme);
|
||||
const campaign = useActiveCampaign();
|
||||
const cid = campaign?.id ?? '';
|
||||
const characters = useCharacters(cid);
|
||||
const notes = useNotes(cid);
|
||||
const npcs = useNpcs(cid);
|
||||
const quests = useQuests(cid);
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
const [idx, setIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
|
||||
const go = (to: string, params?: Record<string, string>) => () => {
|
||||
void navigate(params ? { to, params } : { to });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const list: Command[] = NAV.map((n) => ({ id: `nav:${n.to}`, label: n.label, hint: 'Go', run: go(n.to) }));
|
||||
list.push({ id: 'act:theme', label: 'Toggle theme', hint: 'Action', run: () => { toggleTheme(); onClose(); } });
|
||||
for (const c of characters) list.push({ id: `char:${c.id}`, label: c.name, hint: 'Character', run: go('/characters/$characterId', { characterId: c.id }) });
|
||||
for (const n of notes) list.push({ id: `note:${n.id}`, label: n.title, hint: 'Note', run: go('/notes') });
|
||||
for (const n of npcs) list.push({ id: `npc:${n.id}`, label: n.name, hint: 'NPC', run: go('/npcs') });
|
||||
for (const qu of quests) list.push({ id: `quest:${qu.id}`, label: qu.title, hint: 'Quest', run: go('/quests') });
|
||||
return list;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [characters, notes, npcs, quests, toggleTheme]);
|
||||
|
||||
const fuse = useMemo(() => new Fuse(commands, { keys: ['label'], threshold: 0.4 }), [commands]);
|
||||
const results = useMemo(() => (q.trim() ? fuse.search(q.trim()).map((r) => r.item) : commands).slice(0, 50), [q, fuse, commands]);
|
||||
const clampedIdx = Math.min(idx, Math.max(0, results.length - 1));
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(results.length - 1, i + 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(0, i - 1)); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); results[clampedIdx]?.run(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-24" role="dialog" aria-modal aria-label="Command palette">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} aria-hidden />
|
||||
<div className="relative w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-2xl">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setIdx(0); }}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search pages, characters, notes…"
|
||||
aria-label="Command search"
|
||||
className="w-full border-b border-line bg-transparent px-4 py-3 text-sm text-ink placeholder:text-muted focus:outline-none"
|
||||
/>
|
||||
<ul className="max-h-80 overflow-auto py-1">
|
||||
{results.length === 0 ? (
|
||||
<li className="px-4 py-3 text-sm text-muted">No matches.</li>
|
||||
) : (
|
||||
results.map((c, i) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
onMouseEnter={() => setIdx(i)}
|
||||
onClick={() => c.run()}
|
||||
className={cn('flex w-full items-center justify-between px-4 py-2 text-left text-sm', i === clampedIdx ? 'bg-elevated text-ink' : 'text-muted')}
|
||||
>
|
||||
<span className="truncate">{c.label}</span>
|
||||
{c.hint && <span className="shrink-0 text-xs text-muted">{c.hint}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useUiStore, type Theme } from '@/stores/uiStore';
|
||||
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
|
||||
import { pickTextFile } from '@/lib/io/file';
|
||||
import { seedSampleCampaign } from '@/lib/sample';
|
||||
import { Page, PageHeader } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function SettingsPage() {
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const setTheme = useUiStore((s) => s.setTheme);
|
||||
const setActiveCampaign = useUiStore((s) => s.setActiveCampaign);
|
||||
const navigate = useNavigate();
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
const doImport = async () => {
|
||||
const text = await pickTextFile();
|
||||
if (text) setConfirmRestore(text);
|
||||
};
|
||||
const runRestore = async () => {
|
||||
if (!confirmRestore) return;
|
||||
try {
|
||||
await restoreBackup(confirmRestore);
|
||||
setMsg('Backup restored.');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof BackupError ? e.message : 'Restore failed.');
|
||||
}
|
||||
setConfirmRestore(null);
|
||||
};
|
||||
const loadSample = async () => {
|
||||
const id = await seedSampleCampaign();
|
||||
setActiveCampaign(id);
|
||||
void navigate({ to: '/dashboard' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title="Settings" subtitle="Appearance, backups, and data." />
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</h2>
|
||||
<div className="flex gap-2">
|
||||
{(['dark', 'light'] as Theme[]).map((t) => (
|
||||
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} onClick={() => setTheme(t)} className="capitalize">
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => void exportBackup()}>Export backup</Button>
|
||||
<Button variant="secondary" onClick={doImport}>Import backup…</Button>
|
||||
<Button variant="secondary" onClick={loadSample}>Load sample campaign</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">Backups include every campaign and all of its data, as a single JSON file.</p>
|
||||
{msg && <p className={cn('mt-2 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-danger/40 bg-danger/5 p-4">
|
||||
<h2 className="mb-1 text-sm font-semibold text-danger">Danger zone</h2>
|
||||
<p className="mb-3 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>
|
||||
<Button variant="danger" onClick={() => setConfirmClear(true)}>Clear all data</Button>
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
open={!!confirmRestore}
|
||||
onClose={() => setConfirmRestore(null)}
|
||||
title="Restore backup?"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirmRestore(null)}>Cancel</Button>
|
||||
<Button variant="danger" onClick={runRestore}>Replace all data</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-muted">Restoring <strong className="text-ink">replaces all current data</strong> with the backup. Export first if unsure.</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={confirmClear}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
title="Clear all data?"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirmClear(false)}>Cancel</Button>
|
||||
<Button variant="danger" onClick={async () => { await clearAllData(); setActiveCampaign(null); setConfirmClear(false); setMsg('All data cleared.'); }}>Delete everything</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-muted">This deletes every campaign, character, encounter, note, map, and homebrew entry. This cannot be undone.</p>
|
||||
</Modal>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { db } from '@/lib/db/db';
|
||||
import { campaignsRepo, charactersRepo, notesRepo } from '@/lib/db/repositories';
|
||||
import { buildBackup, restoreBackup, clearAllData } from './backup';
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete();
|
||||
await db.open();
|
||||
});
|
||||
|
||||
describe('backup round-trip', () => {
|
||||
it('exports all data and restores it after a wipe', async () => {
|
||||
const c = await campaignsRepo.create({ name: 'Backed Up', system: '5e', description: '' });
|
||||
await charactersRepo.create(c.id, { system: '5e', name: 'Saved Hero', kind: 'pc', ancestry: '', className: '', level: 2 });
|
||||
await notesRepo.create(c.id, 'Saved Note');
|
||||
|
||||
const backup = await buildBackup();
|
||||
await clearAllData();
|
||||
expect(await campaignsRepo.list()).toHaveLength(0);
|
||||
|
||||
await restoreBackup(JSON.stringify(backup));
|
||||
const campaigns = await campaignsRepo.list();
|
||||
expect(campaigns).toHaveLength(1);
|
||||
expect(campaigns[0]!.name).toBe('Backed Up');
|
||||
expect(await charactersRepo.listByCampaign(c.id)).toHaveLength(1);
|
||||
expect(await notesRepo.listByCampaign(c.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a non-backup file', async () => {
|
||||
await expect(restoreBackup('{"foo":1}')).rejects.toThrow();
|
||||
await expect(restoreBackup('not json')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { db } from '@/lib/db/db';
|
||||
import { downloadJson } from './file';
|
||||
|
||||
const TABLES = [
|
||||
'campaigns', 'characters', 'encounters', 'diceRolls',
|
||||
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew',
|
||||
] as const;
|
||||
|
||||
const FORMAT = 'ttrpg-manager:backup';
|
||||
|
||||
export class BackupError extends Error {}
|
||||
|
||||
/** Snapshot every table into a portable object. */
|
||||
export async function buildBackup(): Promise<{ format: string; version: number; data: Record<string, unknown[]> }> {
|
||||
const data: Record<string, unknown[]> = {};
|
||||
for (const t of TABLES) data[t] = await db.table(t).toArray();
|
||||
return { format: FORMAT, version: 1, data };
|
||||
}
|
||||
|
||||
/** Download a full backup of all campaigns and data. */
|
||||
export async function exportBackup(): Promise<void> {
|
||||
downloadJson('ttrpg-manager-backup.json', await buildBackup());
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a backup, REPLACING all current data. Runs in one transaction so a
|
||||
* failure rolls back cleanly.
|
||||
*/
|
||||
export async function restoreBackup(text: string): Promise<void> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new BackupError('That file is not valid JSON.');
|
||||
}
|
||||
const data = (parsed as { data?: Record<string, unknown[]> })?.data;
|
||||
if (!data || typeof data !== 'object') throw new BackupError('That file is not a TTRPG Manager backup.');
|
||||
|
||||
await db.transaction('rw', TABLES.map((t) => db.table(t)), async () => {
|
||||
for (const t of TABLES) {
|
||||
await db.table(t).clear();
|
||||
const rows = data[t];
|
||||
if (Array.isArray(rows) && rows.length) await db.table(t).bulkAdd(rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Wipe all data (danger zone). */
|
||||
export async function clearAllData(): Promise<void> {
|
||||
await db.transaction('rw', TABLES.map((t) => db.table(t)), async () => {
|
||||
for (const t of TABLES) await db.table(t).clear();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
campaignsRepo,
|
||||
charactersRepo,
|
||||
encountersRepo,
|
||||
notesRepo,
|
||||
npcsRepo,
|
||||
questsRepo,
|
||||
} from '@/lib/db/repositories';
|
||||
import { addCombatant } from '@/lib/combat/engine';
|
||||
import { newId } from '@/lib/ids';
|
||||
|
||||
/** Seed a small demo D&D 5e campaign so new users have something to explore. */
|
||||
export async function seedSampleCampaign(): Promise<string> {
|
||||
const campaign = await campaignsRepo.create({
|
||||
name: 'Sample: Lost Mine',
|
||||
system: '5e',
|
||||
description: 'A demo campaign to explore the app. Safe to delete.',
|
||||
});
|
||||
|
||||
const hero = await charactersRepo.create(campaign.id, {
|
||||
system: '5e', name: 'Lia the Brave', kind: 'pc', ancestry: 'Half-Elf', className: 'Fighter', level: 3,
|
||||
});
|
||||
await charactersRepo.update(hero.id, {
|
||||
abilities: { str: 16, dex: 14, con: 15, int: 10, wis: 12, cha: 8 },
|
||||
hp: { current: 28, max: 28, temp: 0 },
|
||||
skillRanks: { athletics: 'trained', perception: 'trained' },
|
||||
saveRanks: { str: 'trained', con: 'trained' },
|
||||
});
|
||||
await charactersRepo.create(campaign.id, {
|
||||
system: '5e', name: 'Pip Underbough', kind: 'pc', ancestry: 'Halfling', className: 'Rogue', level: 3,
|
||||
});
|
||||
|
||||
await npcsRepo.create(campaign.id, 'Sildar Hallwinter');
|
||||
await questsRepo.create(campaign.id, 'Find Gundren Rockseeker');
|
||||
await notesRepo.create(campaign.id, 'Phandalin');
|
||||
|
||||
const enc = await encountersRepo.create(campaign.id, 'Goblin Ambush');
|
||||
const withGoblins = [1, 2, 3].reduce(
|
||||
(e, _) =>
|
||||
addCombatant(e, {
|
||||
id: newId(), name: 'Goblin', kind: 'monster', initiative: 12, initBonus: 2, ac: 15,
|
||||
hp: { current: 7, max: 7, temp: 0 }, conditions: [], notes: '', cr: 0.25,
|
||||
}),
|
||||
enc,
|
||||
);
|
||||
await encountersRepo.save(withGoblins);
|
||||
|
||||
return campaign.id;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { CalendarPage } from '@/features/world/CalendarPage';
|
||||
import { MapsPage } from '@/features/world/MapsPage';
|
||||
import { PlayerViewPage } from '@/features/player/PlayerViewPage';
|
||||
import { HomebrewPage } from '@/features/world/HomebrewPage';
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage';
|
||||
|
||||
const rootRoute = createRootRoute({ component: RootLayout });
|
||||
|
||||
@@ -35,6 +36,7 @@ const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/cal
|
||||
const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
|
||||
const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage });
|
||||
const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage });
|
||||
const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
@@ -51,6 +53,7 @@ const routeTree = rootRoute.addChildren([
|
||||
mapsRoute,
|
||||
playRoute,
|
||||
homebrewRoute,
|
||||
settingsRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user