Phase 7: maps & VTT
- BattleMap entity (image dataURL, grid, tokens, fog) — Dexie v5, repo, hook, cascade-deleted with campaign - Maps page: upload image (8MB cap), per-campaign list - Map editor canvas: grid overlay (toggle + cell size), fog of war (canvas) with reveal/hide painting + reveal-all/hide-all, draggable tokens that snap to the grid (pointer events, touch+mouse), add/remove tokens; debounced autosave - Dashboard + routes get a Maps entry New maps e2e (upload, add token, toggle fog). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
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('maps: upload, place a token, toggle fog', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('VTT');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Maps' }).click();
|
||||||
|
|
||||||
|
// Upload an image into the hidden file input
|
||||||
|
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
|
||||||
|
|
||||||
|
// Editor loads (Move tool button present)
|
||||||
|
await expect(page.getByRole('button', { name: 'Move' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add a token
|
||||||
|
await page.getByRole('button', { name: '+ Token' }).click();
|
||||||
|
await expect(page.getByTitle(/double-click to remove/)).toBeVisible();
|
||||||
|
|
||||||
|
// Toggle fog on (no crash)
|
||||||
|
await page.getByText('Fog', { exact: true }).locator('input').check();
|
||||||
|
await expect(page.getByRole('button', { name: 'Reveal all' })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ const LINKS = [
|
|||||||
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' },
|
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' },
|
||||||
{ to: '/npcs', label: 'NPCs', icon: '🎭' },
|
{ to: '/npcs', label: 'NPCs', icon: '🎭' },
|
||||||
{ to: '/quests', label: 'Quests', icon: '🗺' },
|
{ to: '/quests', label: 'Quests', icon: '🗺' },
|
||||||
|
{ to: '/maps', label: 'Maps', icon: '🗺️' },
|
||||||
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
||||||
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
||||||
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { BattleMap, Campaign, MapToken } from '@/lib/schemas';
|
||||||
|
import { mapsRepo } from '@/lib/db/repositories';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
|
import { useMaps } from './hooks';
|
||||||
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
||||||
|
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726'];
|
||||||
|
|
||||||
|
export function MapsPage() {
|
||||||
|
return <RequireCampaign>{(c) => <Maps campaign={c} />}</RequireCampaign>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Maps({ campaign }: { campaign: Campaign }) {
|
||||||
|
const maps = useMaps(campaign.id);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const selected = maps.find((m) => m.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
const upload = (file: File) => {
|
||||||
|
setError(null);
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) { setError('Image is larger than 8 MB.'); return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
|
||||||
|
if (!dataUrl) return;
|
||||||
|
const map = await mapsRepo.create(campaign.id, file.name.replace(/\.[^.]+$/, ''), dataUrl);
|
||||||
|
setSelectedId(map.id);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader
|
||||||
|
title="Maps"
|
||||||
|
subtitle={campaign.name}
|
||||||
|
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }}
|
||||||
|
/>
|
||||||
|
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
|
||||||
|
|
||||||
|
{maps.length === 0 ? (
|
||||||
|
<EmptyState title="No maps yet" hint="Upload a battle map image, add a grid, drop tokens, and reveal it with fog of war." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[200px_1fr]">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{maps.map((m) => (
|
||||||
|
<li key={m.id} className="group flex items-center gap-1 rounded-md border border-line">
|
||||||
|
<button className={cn('flex-1 truncate px-3 py-2 text-left text-sm', selected?.id === m.id ? 'text-accent' : 'text-ink')} onClick={() => setSelectedId(m.id)}>{m.name}</button>
|
||||||
|
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(m.id); if (selectedId === m.id) setSelectedId(null); }} aria-label={`Delete ${m.name}`}>✕</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{selected ? <MapEditor key={selected.id} map={selected} /> : <div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a map.</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tool = 'move' | 'reveal' | 'hide';
|
||||||
|
|
||||||
|
function MapEditor({ map }: { map: BattleMap }) {
|
||||||
|
const [m, setM] = useState<BattleMap>(map);
|
||||||
|
const [tool, setTool] = useState<Tool>('move');
|
||||||
|
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
const fogRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const painting = useRef(false);
|
||||||
|
|
||||||
|
const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
|
||||||
|
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
|
||||||
|
|
||||||
|
// Load natural image size.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!m.image) return;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => setNatural({ w: img.naturalWidth, h: img.naturalHeight });
|
||||||
|
img.src = m.image;
|
||||||
|
}, [m.image]);
|
||||||
|
|
||||||
|
const displayW = natural ? Math.min(natural.w, 880) : 0;
|
||||||
|
const scale = natural ? displayW / natural.w : 1;
|
||||||
|
const displayH = natural ? natural.h * scale : 0;
|
||||||
|
const cellPx = m.gridSize * scale;
|
||||||
|
const cols = natural ? Math.ceil(natural.w / m.gridSize) : 0;
|
||||||
|
const rows = natural ? Math.ceil(natural.h / m.gridSize) : 0;
|
||||||
|
|
||||||
|
// Redraw fog whenever it changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = fogRef.current;
|
||||||
|
if (!canvas || !natural) return;
|
||||||
|
canvas.width = displayW;
|
||||||
|
canvas.height = displayH;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, displayW, displayH);
|
||||||
|
if (!m.fogEnabled) return;
|
||||||
|
const revealed = new Set(m.revealed);
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * cellPx, r * cellPx, cellPx, cellPx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [m.revealed, m.fogEnabled, displayW, displayH, cellPx, cols, rows, natural]);
|
||||||
|
|
||||||
|
// Stop painting on global pointer up.
|
||||||
|
useEffect(() => {
|
||||||
|
const stop = () => { painting.current = false; };
|
||||||
|
window.addEventListener('pointerup', stop);
|
||||||
|
return () => window.removeEventListener('pointerup', stop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cellAt = (e: React.PointerEvent) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
return { col: Math.floor((e.clientX - rect.left) / cellPx), row: Math.floor((e.clientY - rect.top) / cellPx) };
|
||||||
|
};
|
||||||
|
const paint = (e: React.PointerEvent) => {
|
||||||
|
if (tool === 'move') return;
|
||||||
|
const { col, row } = cellAt(e);
|
||||||
|
if (col < 0 || row < 0 || col >= cols || row >= rows) return;
|
||||||
|
const key = `${col},${row}`;
|
||||||
|
const set = new Set(m.revealed);
|
||||||
|
if (tool === 'reveal') set.add(key); else set.delete(key);
|
||||||
|
update({ revealed: [...set] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0 }] });
|
||||||
|
const patchToken = (id: string, p: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...p } : t)) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
||||||
|
<Input className="h-8 max-w-40" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['move', 'reveal', 'hide'] as Tool[]).map((t) => (
|
||||||
|
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} onClick={() => setTool(t)} aria-pressed={tool === t}>
|
||||||
|
{t === 'move' ? 'Move' : t === 'reveal' ? 'Reveal' : 'Hide'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted">Cell<NumberField className="w-16" value={m.gridSize} min={10} max={400} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => update({ revealed: [] })}>Hide all</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { const all: string[] = []; for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) all.push(`${c},${r}`); update({ revealed: all }); }}>Reveal all</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!natural ? (
|
||||||
|
<p className="text-sm text-muted">Loading map…</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-auto rounded-lg border border-line bg-surface p-2">
|
||||||
|
<div className="relative" style={{ width: displayW, height: displayH }}>
|
||||||
|
<img src={m.image} alt={m.name} className="absolute inset-0 h-full w-full select-none" draggable={false} />
|
||||||
|
{m.showGrid && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(to right, rgba(255,255,255,0.25) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.25) 1px, transparent 1px)',
|
||||||
|
backgroundSize: `${cellPx}px ${cellPx}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Fog + paint surface (also catches clicks in reveal/hide mode) */}
|
||||||
|
<canvas
|
||||||
|
ref={fogRef}
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ width: displayW, height: displayH, pointerEvents: tool === 'move' ? 'none' : 'auto', cursor: tool === 'move' ? 'default' : 'crosshair' }}
|
||||||
|
onPointerDown={(e) => { painting.current = true; paint(e); }}
|
||||||
|
onPointerMove={(e) => { if (painting.current) paint(e); }}
|
||||||
|
/>
|
||||||
|
{/* Tokens */}
|
||||||
|
{m.tokens.map((t) => (
|
||||||
|
<TokenChip key={t.id} token={t} cellPx={cellPx} draggable={tool === 'move'} cols={cols} rows={rows}
|
||||||
|
onMove={(col, row) => patchToken(t.id, { col, row })}
|
||||||
|
onRemove={() => update({ tokens: m.tokens.filter((x) => x.id !== t.id) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-muted">Tip: use <strong>Reveal</strong>/<strong>Hide</strong> to paint fog; <strong>Move</strong> to drag tokens.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TokenChip({ token, cellPx, draggable, cols, rows, onMove, onRemove }: {
|
||||||
|
token: MapToken; cellPx: number; draggable: boolean; cols: number; rows: number;
|
||||||
|
onMove: (col: number, row: number) => void; onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const drag = useRef<{ startX: number; startY: number; col: number; row: number } | null>(null);
|
||||||
|
const [drift, setDrift] = useState<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
|
||||||
|
|
||||||
|
const onDown = (e: React.PointerEvent) => {
|
||||||
|
if (!draggable) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
ref.current?.setPointerCapture(e.pointerId);
|
||||||
|
drag.current = { startX: e.clientX, startY: e.clientY, col: token.col, row: token.row };
|
||||||
|
};
|
||||||
|
const onMovePtr = (e: React.PointerEvent) => {
|
||||||
|
if (!drag.current) return;
|
||||||
|
setDrift({ dx: e.clientX - drag.current.startX, dy: e.clientY - drag.current.startY });
|
||||||
|
};
|
||||||
|
const onUp = (e: React.PointerEvent) => {
|
||||||
|
if (!drag.current) return;
|
||||||
|
ref.current?.releasePointerCapture(e.pointerId);
|
||||||
|
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / cellPx)));
|
||||||
|
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / cellPx)));
|
||||||
|
drag.current = null;
|
||||||
|
setDrift({ dx: 0, dy: 0 });
|
||||||
|
onMove(col, row);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onPointerDown={onDown}
|
||||||
|
onPointerMove={onMovePtr}
|
||||||
|
onPointerUp={onUp}
|
||||||
|
onDoubleClick={onRemove}
|
||||||
|
title={`${token.label} (double-click to remove)`}
|
||||||
|
className="absolute grid place-items-center rounded-full border-2 border-black/40 text-xs font-bold text-black shadow"
|
||||||
|
style={{
|
||||||
|
width: cellPx * 0.9, height: cellPx * 0.9,
|
||||||
|
left: token.col * cellPx + cellPx * 0.05 + drift.dx,
|
||||||
|
top: token.row * cellPx + cellPx * 0.05 + drift.dy,
|
||||||
|
background: token.color,
|
||||||
|
cursor: draggable ? 'grab' : 'default',
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { notesRepo, npcsRepo, questsRepo, calendarRepo } from '@/lib/db/repositories';
|
import { notesRepo, npcsRepo, questsRepo, calendarRepo, mapsRepo } from '@/lib/db/repositories';
|
||||||
import type { Note, Npc, Quest, Calendar } from '@/lib/schemas';
|
import type { Note, Npc, Quest, Calendar, BattleMap } from '@/lib/schemas';
|
||||||
|
|
||||||
export function useNotes(campaignId: string): Note[] {
|
export function useNotes(campaignId: string): Note[] {
|
||||||
return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []);
|
return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []);
|
||||||
@@ -14,3 +14,6 @@ export function useQuests(campaignId: string): Quest[] {
|
|||||||
export function useCalendar(campaignId: string): Calendar | undefined {
|
export function useCalendar(campaignId: string): Calendar | undefined {
|
||||||
return useLiveQuery(() => calendarRepo.get(campaignId), [campaignId], undefined);
|
return useLiveQuery(() => calendarRepo.get(campaignId), [campaignId], undefined);
|
||||||
}
|
}
|
||||||
|
export function useMaps(campaignId: string): BattleMap[] {
|
||||||
|
return useLiveQuery(() => mapsRepo.listByCampaign(campaignId), [campaignId], []);
|
||||||
|
}
|
||||||
|
|||||||
+5
-1
@@ -3,7 +3,7 @@ import type { Campaign } from '@/lib/schemas/campaign';
|
|||||||
import type { Character } from '@/lib/schemas/character';
|
import type { Character } from '@/lib/schemas/character';
|
||||||
import type { Encounter } from '@/lib/schemas/encounter';
|
import type { Encounter } from '@/lib/schemas/encounter';
|
||||||
import type { DiceRoll } from '@/lib/schemas/dice';
|
import type { DiceRoll } from '@/lib/schemas/dice';
|
||||||
import type { Note, Npc, Quest, Calendar } from '@/lib/schemas/world';
|
import type { Note, Npc, Quest, Calendar, BattleMap } from '@/lib/schemas/world';
|
||||||
import { characterDefaults } from '@/lib/schemas/character';
|
import { characterDefaults } from '@/lib/schemas/character';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +23,7 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
npcs!: EntityTable<Npc, 'id'>;
|
npcs!: EntityTable<Npc, 'id'>;
|
||||||
quests!: EntityTable<Quest, 'id'>;
|
quests!: EntityTable<Quest, 'id'>;
|
||||||
calendars!: EntityTable<Calendar, 'campaignId'>;
|
calendars!: EntityTable<Calendar, 'campaignId'>;
|
||||||
|
maps!: EntityTable<BattleMap, 'id'>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('ttrpg-manager');
|
super('ttrpg-manager');
|
||||||
@@ -62,6 +63,9 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
quests: 'id, campaignId, status',
|
quests: 'id, campaignId, status',
|
||||||
calendars: 'campaignId',
|
calendars: 'campaignId',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v5 — Phase 7 maps/VTT.
|
||||||
|
this.version(5).stores({ maps: 'id, campaignId' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
npcSchema,
|
npcSchema,
|
||||||
questSchema,
|
questSchema,
|
||||||
calendarSchema,
|
calendarSchema,
|
||||||
|
battleMapSchema,
|
||||||
type Campaign,
|
type Campaign,
|
||||||
type CampaignDraft,
|
type CampaignDraft,
|
||||||
type Character,
|
type Character,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
type Npc,
|
type Npc,
|
||||||
type Quest,
|
type Quest,
|
||||||
type Calendar,
|
type Calendar,
|
||||||
|
type BattleMap,
|
||||||
} from '@/lib/schemas';
|
} from '@/lib/schemas';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
|
|
||||||
@@ -57,7 +59,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.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps],
|
||||||
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();
|
||||||
@@ -65,6 +67,7 @@ export const campaignsRepo = {
|
|||||||
await db.notes.where('campaignId').equals(id).delete();
|
await db.notes.where('campaignId').equals(id).delete();
|
||||||
await db.npcs.where('campaignId').equals(id).delete();
|
await db.npcs.where('campaignId').equals(id).delete();
|
||||||
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.calendars.delete(id);
|
await db.calendars.delete(id);
|
||||||
await db.campaigns.delete(id);
|
await db.campaigns.delete(id);
|
||||||
},
|
},
|
||||||
@@ -264,3 +267,25 @@ export const calendarRepo = {
|
|||||||
await db.calendars.put(calendarSchema.parse(calendar));
|
await db.calendars.put(calendarSchema.parse(calendar));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------- Battle maps ----------------
|
||||||
|
export const mapsRepo = {
|
||||||
|
listByCampaign(campaignId: string): Promise<BattleMap[]> {
|
||||||
|
return db.maps.where('campaignId').equals(campaignId).toArray();
|
||||||
|
},
|
||||||
|
async create(campaignId: string, name: string, image: string): Promise<BattleMap> {
|
||||||
|
const ts = now();
|
||||||
|
const map = battleMapSchema.parse({
|
||||||
|
id: newId(), campaignId, name, image, gridSize: 50, showGrid: true,
|
||||||
|
fogEnabled: false, revealed: [], tokens: [], createdAt: ts, updatedAt: ts,
|
||||||
|
});
|
||||||
|
await db.maps.add(map);
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
async save(map: BattleMap): Promise<void> {
|
||||||
|
await db.maps.put(battleMapSchema.parse({ ...map, updatedAt: now() }));
|
||||||
|
},
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await db.maps.delete(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -66,3 +66,31 @@ export const calendarSchema = z.object({
|
|||||||
events: z.array(calendarEventSchema).default([]),
|
events: z.array(calendarEventSchema).default([]),
|
||||||
});
|
});
|
||||||
export type Calendar = z.infer<typeof calendarSchema>;
|
export type Calendar = z.infer<typeof calendarSchema>;
|
||||||
|
|
||||||
|
// ---------------- Battle maps (VTT) ----------------
|
||||||
|
export const mapTokenSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string().max(40).default(''),
|
||||||
|
color: z.string().max(20).default('#d4af37'),
|
||||||
|
/** grid cell coordinates */
|
||||||
|
col: int,
|
||||||
|
row: int,
|
||||||
|
});
|
||||||
|
export type MapToken = z.infer<typeof mapTokenSchema>;
|
||||||
|
|
||||||
|
export const battleMapSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
campaignId: z.string(),
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
/** image as a data URL (stored locally) */
|
||||||
|
image: z.string().default(''),
|
||||||
|
gridSize: int.min(10).max(400).default(50),
|
||||||
|
showGrid: z.boolean().default(true),
|
||||||
|
fogEnabled: z.boolean().default(false),
|
||||||
|
/** revealed cell keys "col,row"; everything else is fogged when enabled */
|
||||||
|
revealed: z.array(z.string()).default([]),
|
||||||
|
tokens: z.array(mapTokenSchema).default([]),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
export type BattleMap = z.infer<typeof battleMapSchema>;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { NotesPage } from '@/features/world/NotesPage';
|
|||||||
import { NpcsPage } from '@/features/world/NpcsPage';
|
import { NpcsPage } from '@/features/world/NpcsPage';
|
||||||
import { QuestsPage } from '@/features/world/QuestsPage';
|
import { QuestsPage } from '@/features/world/QuestsPage';
|
||||||
import { CalendarPage } from '@/features/world/CalendarPage';
|
import { CalendarPage } from '@/features/world/CalendarPage';
|
||||||
|
import { MapsPage } from '@/features/world/MapsPage';
|
||||||
|
|
||||||
const rootRoute = createRootRoute({ component: RootLayout });
|
const rootRoute = createRootRoute({ component: RootLayout });
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const notesRoute = createRoute({ getParentRoute: () => rootRoute, path: '/notes'
|
|||||||
const npcsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/npcs', component: NpcsPage });
|
const npcsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/npcs', component: NpcsPage });
|
||||||
const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quests', component: QuestsPage });
|
const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quests', component: QuestsPage });
|
||||||
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
|
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
|
||||||
|
const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
@@ -42,6 +44,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
npcsRoute,
|
npcsRoute,
|
||||||
questsRoute,
|
questsRoute,
|
||||||
calendarRoute,
|
calendarRoute,
|
||||||
|
mapsRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
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/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./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/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/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/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./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/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