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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user