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 {(c) => }; } function Maps({ campaign }: { campaign: Campaign }) { const maps = useMaps(campaign.id); const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(null); const fileRef = useRef(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 ( fileRef.current?.click()}>+ Upload map} /> { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} /> {error &&

{error}

} {maps.length === 0 ? ( fileRef.current?.click()}>+ Upload map} /> ) : (
    {maps.map((m) => (
  • ))}
{selected ? :
Select a map.
}
)}
); } type Tool = 'move' | 'reveal' | 'hide'; function MapEditor({ map }: { map: BattleMap }) { const [m, setM] = useState(map); const [tool, setTool] = useState('move'); const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); const fogRef = useRef(null); const painting = useRef(false); const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400); const update = (patch: Partial) => 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) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...p } : t)) }); return (
{/* Toolbar */}
update({ name: e.target.value })} aria-label="Map name" />
{(['move', 'reveal', 'hide'] as Tool[]).map((t) => ( ))}
{!natural ? (

Loading map…

) : (
{m.name} {m.showGrid && (
)} {/* Fog + paint surface (also catches clicks in reveal/hide mode) */} { painting.current = true; paint(e); }} onPointerMove={(e) => { if (painting.current) paint(e); }} /> {/* Tokens */} {m.tokens.map((t) => ( patchToken(t.id, { col, row })} onRemove={() => update({ tokens: m.tokens.filter((x) => x.id !== t.id) })} /> ))}
)}

Tip: use Reveal/Hide to paint fog; Move to drag tokens.

); } 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(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 (
{token.label}
); }