Skip to content

Commit dfcc363

Browse files
author
Chris E
committed
feat: db export/import
1 parent c50a563 commit dfcc363

4 files changed

Lines changed: 234 additions & 6 deletions

File tree

src/lib/db.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,137 @@ export async function deleteGame(id: string) {
188188
await db.gameHirelings.where('gameId').equals(id).delete();
189189
await db.games.delete(id);
190190
}
191+
192+
// Export/Import functions
193+
export interface DatabaseExport {
194+
version: number;
195+
exportedAt: string;
196+
games: Game[];
197+
players: Player[];
198+
gameParticipants: GameParticipant[];
199+
gameLandmarks: GameLandmark[];
200+
gameHirelings: GameHireling[];
201+
}
202+
203+
export async function exportDatabase(): Promise<DatabaseExport> {
204+
const [games, players, gameParticipants, gameLandmarks, gameHirelings] = await Promise.all([
205+
db.games.toArray(),
206+
db.players.toArray(),
207+
db.gameParticipants.toArray(),
208+
db.gameLandmarks.toArray(),
209+
db.gameHirelings.toArray()
210+
]);
211+
212+
return {
213+
version: 1,
214+
exportedAt: new Date().toISOString(),
215+
games,
216+
players,
217+
gameParticipants,
218+
gameLandmarks,
219+
gameHirelings
220+
};
221+
}
222+
223+
export async function importDatabase(data: DatabaseExport, merge = false): Promise<{ imported: number; skipped: number }> {
224+
if (!merge) {
225+
// Clear existing data
226+
await Promise.all([
227+
db.games.clear(),
228+
db.players.clear(),
229+
db.gameParticipants.clear(),
230+
db.gameLandmarks.clear(),
231+
db.gameHirelings.clear()
232+
]);
233+
}
234+
235+
let imported = 0;
236+
let skipped = 0;
237+
238+
// Import players first (to handle references)
239+
for (const player of data.players) {
240+
try {
241+
if (merge) {
242+
const existing = await db.players.get(player.id);
243+
if (existing) {
244+
skipped++;
245+
continue;
246+
}
247+
}
248+
await db.players.add(player);
249+
imported++;
250+
} catch {
251+
skipped++;
252+
}
253+
}
254+
255+
// Import games
256+
for (const game of data.games) {
257+
try {
258+
if (merge) {
259+
const existing = await db.games.get(game.id);
260+
if (existing) {
261+
skipped++;
262+
continue;
263+
}
264+
}
265+
await db.games.add(game);
266+
imported++;
267+
} catch {
268+
skipped++;
269+
}
270+
}
271+
272+
// Import game participants
273+
for (const participant of data.gameParticipants) {
274+
try {
275+
if (merge) {
276+
const existing = await db.gameParticipants.get(participant.id);
277+
if (existing) {
278+
skipped++;
279+
continue;
280+
}
281+
}
282+
await db.gameParticipants.add(participant);
283+
imported++;
284+
} catch {
285+
skipped++;
286+
}
287+
}
288+
289+
// Import landmarks
290+
for (const landmark of data.gameLandmarks) {
291+
try {
292+
if (merge) {
293+
const existing = await db.gameLandmarks.get(landmark.id);
294+
if (existing) {
295+
skipped++;
296+
continue;
297+
}
298+
}
299+
await db.gameLandmarks.add(landmark);
300+
imported++;
301+
} catch {
302+
skipped++;
303+
}
304+
}
305+
306+
// Import hirelings
307+
for (const hireling of data.gameHirelings) {
308+
try {
309+
if (merge) {
310+
const existing = await db.gameHirelings.get(hireling.id);
311+
if (existing) {
312+
skipped++;
313+
continue;
314+
}
315+
}
316+
await db.gameHirelings.add(hireling);
317+
imported++;
318+
} catch {
319+
skipped++;
320+
}
321+
}
322+
323+
return { imported, skipped };
324+
}

src/routes/+page.svelte

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
<script lang="ts">
22
import { onMount } from 'svelte';
3-
import { getAllGames, getAllPlayers } from '$lib/db';
3+
import { getAllGames, getAllPlayers, exportDatabase, importDatabase, type DatabaseExport } from '$lib/db';
4+
import { resolve } from '$app/paths';
45
56
type GameWithDetails = Awaited<ReturnType<typeof getAllGames>>[number];
67
type Player = Awaited<ReturnType<typeof getAllPlayers>>[number];
78
89
let games = $state<GameWithDetails[]>([]);
910
let players = $state<Player[]>([]);
1011
let loading = $state(true);
12+
let importMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
1113
1214
function getMapClass(map: string): string {
1315
const mapName = map.toLowerCase();
@@ -18,6 +20,61 @@
1820
return 'map-autumn';
1921
}
2022
23+
async function handleExport() {
24+
try {
25+
const data = await exportDatabase();
26+
const json = JSON.stringify(data, null, 2);
27+
const blob = new Blob([json], { type: 'application/json' });
28+
const url = URL.createObjectURL(blob);
29+
const a = document.createElement('a');
30+
a.href = url;
31+
a.download = `root-games-${new Date().toISOString().split('T')[0]}.json`;
32+
a.click();
33+
URL.revokeObjectURL(url);
34+
} catch (error) {
35+
alert('Failed to export data');
36+
}
37+
}
38+
39+
async function handleImport(event: Event) {
40+
const input = event.target as HTMLInputElement;
41+
const file = input.files?.[0];
42+
if (!file) return;
43+
44+
try {
45+
const text = await file.text();
46+
const data = JSON.parse(text) as DatabaseExport;
47+
48+
if (!data.version || !data.games || !data.players) {
49+
throw new Error('Invalid file format');
50+
}
51+
52+
const merge = confirm('Do you want to merge with existing data?\n\nClick OK to merge (keep existing + add new)\nClick Cancel to replace all data');
53+
54+
const result = await importDatabase(data, merge);
55+
56+
// Refresh data
57+
games = await getAllGames();
58+
players = await getAllPlayers();
59+
60+
importMessage = {
61+
type: 'success',
62+
text: `Imported ${result.imported} records${result.skipped > 0 ? `, skipped ${result.skipped} duplicates` : ''}`
63+
};
64+
65+
setTimeout(() => importMessage = null, 5000);
66+
} catch (error) {
67+
importMessage = {
68+
type: 'error',
69+
text: 'Failed to import: Invalid file format'
70+
};
71+
setTimeout(() => importMessage = null, 5000);
72+
}
73+
74+
// Reset input
75+
input.value = '';
76+
}
77+
2178
onMount(async () => {
2279
games = await getAllGames();
2380
players = await getAllPlayers();
@@ -38,13 +95,13 @@
3895

3996
<div class="mb-8 flex justify-center gap-4">
4097
<a
41-
href="/games/new"
98+
href="{resolve("/games/new")}"
4299
class="rounded-lg bg-amber-600 px-6 py-3 font-semibold text-white shadow-md transition hover:bg-amber-700"
43100
>
44101
+ New Game
45102
</a>
46103
<a
47-
href="/stats"
104+
href="{resolve("/stats")}"
48105
class="rounded-lg bg-amber-100 px-6 py-3 font-semibold text-amber-800 shadow-md transition hover:bg-amber-200"
49106
>
50107
📊 Stats
@@ -66,7 +123,7 @@
66123
{:else}
67124
<div class="space-y-4">
68125
{#each games as game}
69-
<a href="/games/{game.id}" class="block">
126+
<a href="{resolve(`/games/${game.id}`)}" class="block">
70127
<div class="map-card-bg {getMapClass(game.map)} rounded-lg bg-white p-6 shadow transition hover:shadow-lg">
71128
<div class="mb-4 flex items-center justify-between">
72129
<span class="font-medium text-amber-800">{game.map}</span>
@@ -129,6 +186,37 @@
129186
{/if}
130187
</div>
131188
</section>
189+
190+
<section class="mt-8">
191+
<h2 class="mb-4 text-2xl font-semibold text-amber-800">Data Management</h2>
192+
<div class="rounded-lg bg-white p-6 shadow">
193+
{#if importMessage}
194+
<div class="mb-4 rounded-lg p-3 {importMessage.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
195+
{importMessage.text}
196+
</div>
197+
{/if}
198+
<div class="flex flex-wrap gap-4">
199+
<button
200+
onclick={handleExport}
201+
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition hover:bg-blue-700"
202+
>
203+
📤 Export Data
204+
</button>
205+
<label class="cursor-pointer rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition hover:bg-green-700">
206+
📥 Import Data
207+
<input
208+
type="file"
209+
accept=".json"
210+
onchange={handleImport}
211+
class="hidden"
212+
/>
213+
</label>
214+
</div>
215+
<p class="mt-3 text-sm text-gray-500">
216+
Export your game data as JSON to backup or transfer to another device.
217+
</p>
218+
</div>
219+
</section>
132220
{/if}
133221
</div>
134222
</div>

src/routes/games/[id]/+page.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { page } from '$app/stores';
44
import { goto } from '$app/navigation';
55
import { getGame, deleteGame as deleteGameFromDb } from '$lib/db';
6+
import { resolve } from '$app/paths';
67
78
type GameWithDetails = NonNullable<Awaited<ReturnType<typeof getGame>>>;
89
@@ -14,6 +15,10 @@
1415
1516
onMount(async () => {
1617
const id = $page.params.id;
18+
if (!id) {
19+
loading = false;
20+
return;
21+
}
1722
game = await getGame(id);
1823
loading = false;
1924
});
@@ -37,7 +42,7 @@
3742
<div class="page-background min-h-screen bg-amber-50">
3843
<div class="mx-auto max-w-4xl px-4 py-8">
3944
<header class="mb-8">
40-
<a href="/" class="text-amber-600 hover:text-amber-800">← Back to Home</a>
45+
<a href="{resolve("/")}" class="text-amber-600 hover:text-amber-800">← Back to Home</a>
4146
{#if game}
4247
<div class="mt-4 flex items-center justify-between">
4348
<h1 class="text-3xl font-bold text-amber-900">Game Details</h1>

src/routes/games/new/+page.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { goto } from '$app/navigation';
44
import { factions, maps, landmarks, hirelings } from '$lib/gameData';
55
import { getAllPlayers, createGame } from '$lib/db';
6+
import { resolve } from '$app/paths';
67
78
interface Participant {
89
playerName: string;
@@ -77,7 +78,7 @@
7778
<div class="page-background min-h-screen bg-amber-50">
7879
<div class="mx-auto max-w-4xl px-4 py-8">
7980
<header class="mb-8">
80-
<a href="/" class="text-amber-600 hover:text-amber-800">← Back to Home</a>
81+
<a href="{resolve("/")}" class="text-amber-600 hover:text-amber-800">← Back to Home</a>
8182
<h1 class="mt-4 text-3xl font-bold text-amber-900">Record New Game</h1>
8283
</header>
8384

0 commit comments

Comments
 (0)