Major refactor

This commit is contained in:
2026-04-03 23:04:51 -04:00
parent e7f337c735
commit 35d07e8df1
124 changed files with 4929 additions and 2429 deletions

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Siren</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

22
ui/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "siren-grid",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.6.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}

195
ui/src/App.css Normal file
View File

@@ -0,0 +1,195 @@
/* ---- Full-viewport shell ---- */
.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* ---- Top header ---- */
.app-header {
flex-shrink: 0;
height: 48px;
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1rem;
background: #1f2937;
border-bottom: 1px solid #374151;
z-index: 10;
}
.app-brand {
font-size: 1.05rem;
font-weight: 700;
color: #f9fafb;
letter-spacing: 0.08em;
white-space: nowrap;
margin-right: 0.5rem;
}
.app-brand span {
color: #818cf8;
}
.app-map-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.map-select {
background: #111827;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
min-width: 160px;
max-width: 280px;
outline: none;
cursor: pointer;
}
.map-select:focus {
border-color: #6366f1;
}
.map-select option {
background: #1f2937;
}
.header-btn {
background: #374151;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
cursor: pointer;
font-size: 0.82rem;
padding: 0.3rem 0.65rem;
line-height: 1.4;
white-space: nowrap;
transition: background 0.12s;
}
.header-btn:hover {
background: #4b5563;
}
.header-btn.danger:hover {
background: #7f1d1d;
border-color: #ef4444;
color: #fca5a5;
}
.new-map-form {
display: flex;
gap: 0.3rem;
align-items: center;
}
.new-map-form input {
background: #111827;
border: 1px solid #6366f1;
border-radius: 6px;
color: #e5e7eb;
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
width: 160px;
outline: none;
}
.new-map-form button {
background: #6366f1;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 0.82rem;
padding: 0.3rem 0.65rem;
transition: background 0.12s;
}
.new-map-form button:hover {
background: #4f46e5;
}
.new-map-form .cancel-btn {
background: #374151;
border: 1px solid #4b5563;
}
.new-map-form .cancel-btn:hover {
background: #4b5563;
}
/* ---- Grid area (fills remainder) ---- */
.app-grid-area {
flex: 1;
position: relative;
overflow: hidden;
}
/* ── Floating panel stack bottom-left corner ── */
.floating-panels-container {
position: absolute;
bottom: 14px;
left: 14px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 20;
}
/* ---- No-map placeholder ---- */
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: #4b5563;
}
.empty-state p {
font-size: 1.1rem;
}
.empty-state .empty-hint {
font-size: 0.85rem;
color: #374151;
}
/* ---- Auth area (right side of header) ---- */
.app-auth {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
}
.app-username {
font-size: 0.82rem;
color: #9ca3af;
white-space: nowrap;
}
/* ---- Public checkbox in new-map form ---- */
.new-map-public {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.82rem;
color: #9ca3af;
cursor: pointer;
white-space: nowrap;
user-select: none;
}
.new-map-public input[type='checkbox'] {
accent-color: #6366f1;
cursor: pointer;
}

297
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,297 @@
import { useState, useEffect, useRef } from 'react';
import type { GridMap, Tool, TokenClaims } from './types';
import type { GridHandle } from './components/Grid';
import { api, auth, getToken, setToken, decodeToken } from './api';
import ControlPanel from './components/ControlPanel.tsx';
import ColorPanel from './components/ColorPanel';
import Grid from './components/Grid';
import LoginButton from './components/LoginButton';
import './App.css';
/** Default colors shown before a map's own colors load from the server. */
const DEFAULT_COLORS = [
'#6b7280', // 1 stone
'#92400e', // 2 earth
'#15803d', // 3 grass
'#1d4ed8', // 4 water
'#7c3aed', // 5 arcane
'#dc2626', // 6 lava
'#ca8a04', // 7 sand
'#0f172a', // 8 void
'#f9fafb', // 9 white
];
/** Read the map ID from the current URL path (/map/:id). */
function getMapIdFromUrl(): string | null {
const match = window.location.pathname.match(/^\/map\/([^/]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/** Read a query parameter value from the current URL. */
function getQueryParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key);
}
/** Strip a query parameter from the current URL without causing a reload. */
function removeQueryParam(key: string) {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState(null, '', url.pathname + (url.search !== '?' ? url.search : ''));
}
export default function App() {
// ---- Auth state ----
const [user, setUser] = useState<TokenClaims | null>(() => {
const token = getToken();
return token ? decodeToken(token) : null;
});
// ---- Map state ----
const [maps, setMaps] = useState<GridMap[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
// Tool + unified active color (shared between draw and token)
const [tool, setTool] = useState<Tool>('pan');
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
// Per-map color palette (updated from WS state on map load / color edits)
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
// Ref to Grid so App can push color updates through the WS
const gridRef = useRef<GridHandle>(null);
// New-map form
const [showNewMap, setShowNewMap] = useState(false);
const [newMapName, setNewMapName] = useState('');
const [newMapPublic, setNewMapPublic] = useState(false);
const newMapInputRef = useRef<HTMLInputElement>(null);
// ---- Handle OAuth callback: ?token= or ?error= ----
useEffect(() => {
const token = getQueryParam('token');
const error = getQueryParam('error');
if (token) {
setToken(token);
const claims = decodeToken(token);
setUser(claims);
removeQueryParam('token');
} else if (error) {
console.error('OAuth error:', error);
removeQueryParam('error');
}
}, []);
// ---- Load map list ----
useEffect(() => {
api.listMaps().then(setMaps).catch(console.error);
}, [user]); // re-fetch when auth state changes
// Once maps load, validate the URL-sourced selectedId still exists
useEffect(() => {
if (maps.length === 0 && selectedId) {
// Maps are still loading — skip
return;
}
if (selectedId) {
const exists = maps.some(m => m.id === selectedId);
if (!exists) {
// Invalid or inaccessible map ID — reroute to /map
setSelectedId(null);
window.history.replaceState(null, '', '/map');
}
}
}, [maps]); // eslint-disable-line react-hooks/exhaustive-deps
// Keep the URL in sync with the selected map
useEffect(() => {
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : '/map';
window.history.replaceState(null, '', path);
}, [selectedId]);
useEffect(() => {
if (showNewMap) newMapInputRef.current?.focus();
}, [showNewMap]);
// Reset palette to defaults when no map is selected
useEffect(() => {
if (!selectedId) {
setMapColors(DEFAULT_COLORS);
setActiveColor(DEFAULT_COLORS[0]);
}
}, [selectedId]);
// ---- Derived state ----
const selectedMap = maps.find(m => m.id === selectedId) ?? null;
// The current user is considered the owner if their Discord ID matches owner_id
const isOwner = user !== null && selectedMap !== null && selectedMap.owner_id === user.sub;
// ---- Handlers ----
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const name = newMapName.trim();
if (!name) return;
try {
const m = await api.createMap(name, newMapPublic);
setMaps(prev => [m, ...prev]);
setSelectedId(m.id);
setShowNewMap(false);
setNewMapName('');
setNewMapPublic(false);
} catch (err) {
console.error('Failed to create map', err);
}
}
async function handleDelete() {
if (!selectedId) return;
if (!confirm('Delete this map? This cannot be undone.')) return;
try {
await api.deleteMap(selectedId);
setMaps(prev => prev.filter(m => m.id !== selectedId));
setSelectedId(null);
} catch (err) {
console.error('Failed to delete map', err);
}
}
/** Called by Grid when the WS state/colors_updated message arrives. */
function handleColorsLoaded(colors: string[]) {
setMapColors(colors);
setActiveColor(prev => colors.includes(prev) ? prev : colors[0]);
}
/** Called by ColorPanel when the user double-clicks and edits a swatch. */
function handleColorsChange(colors: string[]) {
setMapColors(colors);
gridRef.current?.sendColorUpdate(colors);
}
return (
<div className="app">
{/* ── Header ── */}
<header className="app-header">
<div className="app-brand">
<span>SIREN</span>
</div>
<div className="app-map-controls">
{/* Map selector */}
{maps.length > 0 && !showNewMap && (
<select
className="map-select"
value={selectedId ?? ''}
onChange={e => setSelectedId(e.target.value || null)}
>
<option value=""> Select a map </option>
{maps.map(m => (
<option key={m.id} value={m.id}>
{m.name}{m.is_public ? ' (Public)' : ' (Private)'}
</option>
))}
</select>
)}
{/* New map form — only for authenticated users */}
{user && (
showNewMap ? (
<form className="new-map-form" onSubmit={handleCreate}>
<input
ref={newMapInputRef}
type="text"
placeholder="Map name…"
value={newMapName}
onChange={e => setNewMapName(e.target.value)}
maxLength={60}
/>
<label className="new-map-public">
<input
type="checkbox"
checked={newMapPublic}
onChange={e => setNewMapPublic(e.target.checked)}
/>
Public
</label>
<button type="submit" disabled={!newMapName.trim()}>Create</button>
<button
type="button"
className="cancel-btn"
onClick={() => { setShowNewMap(false); setNewMapName(''); setNewMapPublic(false); }}
>
</button>
</form>
) : (
<button className="header-btn" onClick={() => setShowNewMap(true)}>
+ New Map
</button>
)
)}
{/* Delete current map — only for the owner */}
{isOwner && !showNewMap && (
<button className="header-btn danger" onClick={handleDelete} title="Delete this map">
Delete
</button>
)}
</div>
{/* ── Auth area ── */}
<div className="app-auth">
{user ? (
<>
<span className="app-username">{user.name}</span>
<button className="header-btn" onClick={() => { auth.logout(); setUser(null); }}>
Log out
</button>
</>
) : (
<LoginButton className="header-btn" />
)}
</div>
</header>
{/* ── Grid area ── */}
<div className="app-grid-area">
{selectedId ? (
<>
{/* key forces full remount (new WS + clear state) on map change */}
<Grid
key={selectedId}
ref={gridRef}
mapId={selectedId}
tool={tool}
paintColor={activeColor}
tokenColor={activeColor}
onColorsLoaded={handleColorsLoaded}
/>
<div className="floating-panels-container">
<ControlPanel
tool={tool}
onToolChange={setTool}
/>
<ColorPanel
colors={mapColors}
activeColor={activeColor}
onColorChange={setActiveColor}
onColorsChange={handleColorsChange}
/>
</div>
</>
) : (
<div className="empty-state">
<p>Select or create a map to begin</p>
<p className="empty-hint">
{!user
? 'Log in with Discord to create maps and access private maps'
: maps.length === 0
? 'Click "+ New Map" in the header to get started'
: 'Choose a map from the header dropdown'}
</p>
</div>
)}
</div>
</div>
);
}

126
ui/src/api.ts Normal file
View File

@@ -0,0 +1,126 @@
import type { GridMap, MapPermission, MapRole, MapState, TokenClaims } from './types';
const BASE = '/api/grid';
const AUTH_BASE = '/api/auth/discord';
// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------
const TOKEN_KEY = 'siren_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
/** Decode the JWT payload without verifying the signature (client-side only). */
export function decodeToken(token: string): TokenClaims | null {
try {
const payload = token.split('.')[1];
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json) as TokenClaims;
} catch {
return null;
}
}
/** Returns true if the stored token is present and not expired. */
// export function isAuthenticated(): boolean {
// const token = getToken();
// if (!token) return false;
// const claims = decodeToken(token);
// if (!claims) return false;
// return claims.exp > Date.now() / 1000;
// }
// ---------------------------------------------------------------------------
// Core fetch wrapper
// ---------------------------------------------------------------------------
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
...(init?.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(url, { ...init, headers });
if (res.status === 401) {
// Token expired or invalid — clear local storage
removeToken();
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// ---------------------------------------------------------------------------
// Grid map API
// ---------------------------------------------------------------------------
export const api = {
listMaps: (): Promise<GridMap[]> =>
request<GridMap[]>(`${BASE}/maps`),
createMap: (name: string, is_public = false): Promise<GridMap> =>
request<GridMap>(`${BASE}/maps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, is_public }),
}),
getMap: (id: string): Promise<MapState> =>
request<MapState>(`${BASE}/maps/${id}`),
deleteMap: (id: string): Promise<void> =>
request<void>(`${BASE}/maps/${id}`, { method: 'DELETE' }),
// ---- Permissions ----
listPermissions: (mapId: string): Promise<MapPermission[]> =>
request<MapPermission[]>(`${BASE}/maps/${mapId}/permissions`),
updatePermission: (mapId: string, userId: number, role: MapRole | null): Promise<void> =>
request<void>(`${BASE}/maps/${mapId}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, role }),
}),
};
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
export const auth = {
/** Fetches the Discord OAuth URL and redirects the browser to it.
* Passes the current page's origin + /map as the UI redirect URI so
* the backend knows where to send the browser after login completes.
*/
async login(): Promise<void> {
const redirectUri = encodeURIComponent(window.location.origin + '/map');
const url = await request<string>(`${AUTH_BASE}/authorize?redirect_uri=${redirectUri}`);
window.location.href = url;
},
logout(): void {
removeToken();
window.location.href = '/map';
},
};

View File

@@ -0,0 +1,65 @@
.color-panel {
background: rgba(17, 24, 39, 0.92);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem;
user-select: none;
}
/* 3×3 grid of swatches */
.cp-swatches {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.3rem;
}
/* Wrapper provides a positioning context for the hidden color input */
.cp-swatch-wrapper {
position: relative;
}
.cp-swatch {
position: relative;
width: 30px;
height: 30px;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: transform 0.1s, border-color 0.1s;
overflow: hidden;
}
.cp-swatch:hover {
transform: scale(1.12);
}
.cp-swatch.selected {
border-color: #ffffff;
}
/* Small key-number hint in the bottom-right of each swatch */
.cp-key-hint {
position: absolute;
bottom: 1px;
right: 2px;
font-size: 0.55rem;
color: rgba(255, 255, 255, 0.75);
line-height: 1;
pointer-events: none;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9);
}
/* Hidden native color input triggered programmatically on double-click */
.cp-color-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react';
import './ColorPanel.css';
interface Props {
colors: string[];
activeColor: string;
onColorChange: (c: string) => void;
onColorsChange: (colors: string[]) => void;
}
export default function ColorPanel({ colors, activeColor, onColorChange, onColorsChange }: Props) {
// One hidden color input ref per slot
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
// Keys 19 select the corresponding color slot (plain, no shift)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const num = parseInt(e.key, 10);
if (num >= 1 && num <= colors.length) {
onColorChange(colors[num - 1]);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [colors, onColorChange]);
function handleDoubleClick(index: number) {
// Select the color first, then open the picker
onColorChange(colors[index]);
inputRefs.current[index]?.click();
}
function handleColorEdit(index: number, newColor: string) {
const updated = colors.map((c, i) => (i === index ? newColor : c));
onColorsChange(updated);
onColorChange(newColor);
}
return (
<div className="color-panel">
<div className="cp-swatches">
{colors.map((c, i) => (
<div key={i} className="cp-swatch-wrapper">
<button
className={`cp-swatch ${activeColor === c ? 'selected' : ''}`}
style={{ background: c }}
onClick={() => onColorChange(c)}
onDoubleClick={() => handleDoubleClick(i)}
title={`${c} (${i + 1}) — double-click to edit`}
>
<span className="cp-key-hint">{i + 1}</span>
</button>
{/* Hidden color picker for this slot */}
<input
ref={el => { inputRefs.current[i] = el; }}
type="color"
value={c}
onChange={e => handleColorEdit(i, e.target.value)}
className="cp-color-input"
tabIndex={-1}
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
.floating-panel {
background: rgba(17, 24, 39, 0.92);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.5rem;
user-select: none;
}
.fp-tool-btn {
background: rgba(255, 255, 255, 0.05);
border: 2px solid transparent;
border-radius: 8px;
color: #e5e7eb;
cursor: pointer;
font-size: 1.3rem;
padding: 0.45rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, border-color 0.12s;
}
.fp-tool-btn:hover {
background: rgba(255, 255, 255, 0.12);
}
.fp-tool-btn.active {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.25);
}

View File

@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from 'react-icons/md';
import type { Tool } from '../types';
import './ControlPanel.css';
interface Props {
tool: Tool;
onToolChange: (t: Tool) => void;
}
const TOOLS: { id: Tool; icon: React.ReactNode; title: string; shortcut: string }[] = [
{ id: 'pan', icon: <MdPanTool />, title: 'Pan drag to move the map', shortcut: 'Shift+1' },
{ id: 'zoom', icon: <MdZoomIn />, title: 'Zoom click to zoom in/out', shortcut: 'Shift+2' },
{ id: 'draw', icon: <MdBrush />, title: 'Draw left-click to paint, right-click to erase, Shift+click to fill', shortcut: 'Shift+3' },
{ id: 'token', icon: <MdPerson />, title: 'Token click to place, drag to move, right-click to delete', shortcut: 'Shift+4' },
];
export default function ControlPanel({ tool, onToolChange }: Props) {
// Keyboard shortcuts: Shift+1/2/3/4 for tools
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (!e.shiftKey) return;
switch (e.key) {
case '!': // Shift+1 on many layouts
case '1': onToolChange('pan'); break;
case '@': // Shift+2
case '2': onToolChange('zoom'); break;
case '#': // Shift+3
case '3': onToolChange('draw'); break;
case '$': // Shift+4
case '4': onToolChange('token'); break;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onToolChange]);
return (
<div className="floating-panel">
{TOOLS.map(t => (
<button
key={t.id}
className={`fp-tool-btn ${tool === t.id ? 'active' : ''}`}
onClick={() => onToolChange(t.id)}
title={`${t.title} (${t.shortcut})`}
>
{t.icon}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
.grid-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.grid-canvas {
display: block;
position: absolute;
inset: 0;
}

782
ui/src/components/Grid.tsx Normal file
View File

@@ -0,0 +1,782 @@
import {
useRef, useEffect, useCallback, useState,
forwardRef, useImperativeHandle,
} from 'react';
import type { GridCell, GridToken, Tool, ServerMessage, ClientMessage } from '../types';
import { useWebSocket } from '../hooks/useWebSocket';
import TokenDialog from './TokenDialog';
import './Grid.css';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_ZOOM = 40;
const MIN_ZOOM = 8;
const MAX_ZOOM = 160;
const ZOOM_STEP = 1.12;
const BG_COLOR = '#111827';
const GRID_COLOR = 'rgba(255,255,255,0.07)';
const GRID_COLOR_MAJOR = 'rgba(255,255,255,0.16)';
/** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
const MAX_FLOOD_CELLS = 2500;
/** World units per second for WASD keyboard panning. */
const WASD_PAN_SPEED = 12;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Camera {
offsetX: number;
offsetY: number;
zoom: number;
}
interface Props {
mapId: string;
tool: Tool;
paintColor: string;
tokenColor: string;
onColorsLoaded: (colors: string[]) => void;
}
export interface GridHandle {
sendColorUpdate: (colors: string[]) => void;
}
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
function canvasToCell(cx: number, cy: number, cam: Camera): { x: number; y: number } {
return {
x: Math.floor(cx / cam.zoom + cam.offsetX),
y: Math.floor(cy / cam.zoom + cam.offsetY),
};
}
function cellToCanvas(cellX: number, cellY: number, cam: Camera): { x: number; y: number } {
return {
x: (cellX - cam.offsetX) * cam.zoom,
y: (cellY - cam.offsetY) * cam.zoom,
};
}
function drawToken(
ctx: CanvasRenderingContext2D,
cellX: number,
cellY: number,
label: string,
color: string,
cam: Camera,
) {
const zoom = cam.zoom;
const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
const cx = px + zoom / 2;
const cy = py + zoom / 2;
const r = zoom * 0.38;
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowBlur = 5;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = Math.max(1, zoom * 0.04);
ctx.stroke();
ctx.shadowBlur = 0;
if (zoom >= 14) {
const words = label.trim().split(/\s+/);
const initials =
words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase()
: label.slice(0, 2).toUpperCase();
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(initials, cx, cy);
}
}
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
/**
* BFS flood fill from (startX, startY) through uncolored cells.
* Returns the list of cells to fill, or null if the region is unbounded
* (search exceeded MAX_FLOOD_CELLS).
*/
function floodFill(
startX: number,
startY: number,
cells: Map<string, GridCell>,
): Array<{ x: number; y: number }> | null {
const visited = new Set<string>();
const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
const found: Array<{ x: number; y: number }> = [];
visited.add(cellKey(startX, startY));
const dirs = [
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 },
];
while (queue.length > 0) {
const { x, y } = queue.shift()!;
found.push({ x, y });
if (found.length > MAX_FLOOD_CELLS) return null; // unbounded
for (const { dx, dy } of dirs) {
const nx = x + dx;
const ny = y + dy;
const key = cellKey(nx, ny);
if (!visited.has(key) && !cells.has(key)) {
visited.add(key);
queue.push({ x: nx, y: ny });
}
}
}
return found;
}
/**
* Clamp the camera so at least one colored cell or token remains visible in
* the current viewport. No-op when the map has no content yet.
*/
function clampCameraToContent(
cam: Camera,
cells: Map<string, GridCell>,
tokens: Map<string, GridToken>,
canvasW: number,
canvasH: number,
) {
if (cells.size === 0 && tokens.size === 0) return;
const viewLeft = cam.offsetX;
const viewRight = cam.offsetX + canvasW / cam.zoom;
const viewTop = cam.offsetY;
const viewBottom = cam.offsetY + canvasH / cam.zoom;
// Quick visibility check
let anyVisible = false;
cellLoop: for (const cell of cells.values()) {
if (
cell.x + 1 > viewLeft && cell.x < viewRight &&
cell.y + 1 > viewTop && cell.y < viewBottom
) {
anyVisible = true;
break cellLoop;
}
}
if (!anyVisible) {
for (const tok of tokens.values()) {
if (
tok.x + 1 > viewLeft && tok.x < viewRight &&
tok.y + 1 > viewTop && tok.y < viewBottom
) {
anyVisible = true;
break;
}
}
}
if (anyVisible) return;
// Find the bounding box of all content
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (const c of cells.values()) {
if (c.x < minX) minX = c.x;
if (c.x > maxX) maxX = c.x;
if (c.y < minY) minY = c.y;
if (c.y > maxY) maxY = c.y;
}
for (const t of tokens.values()) {
if (t.x < minX) minX = t.x;
if (t.x > maxX) maxX = t.x;
if (t.y < minY) minY = t.y;
if (t.y > maxY) maxY = t.y;
}
const viewW = canvasW / cam.zoom;
const viewH = canvasH / cam.zoom;
// X: bring the nearest content edge to the nearest viewport edge
if (maxX + 1 <= viewLeft) {
// All content is to the left — show rightmost cell at the left edge
cam.offsetX = maxX;
} else if (minX >= viewRight) {
// All content is to the right — show leftmost cell at the right edge
cam.offsetX = minX - viewW + 1;
}
// Y: same logic
if (maxY + 1 <= viewTop) {
cam.offsetY = maxY;
} else if (minY >= viewBottom) {
cam.offsetY = minY - viewH + 1;
}
}
// ---------------------------------------------------------------------------
// Grid component
// ---------------------------------------------------------------------------
const Grid = forwardRef<GridHandle, Props>(function Grid(
{ mapId, tool, paintColor, tokenColor, onColorsLoaded },
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cameraRef = useRef<Camera>({ offsetX: -2, offsetY: -2, zoom: DEFAULT_ZOOM });
const cellsRef = useRef<Map<string, GridCell>>(new Map());
const tokensRef = useRef<Map<string, GridToken>>(new Map());
const [tick, setTick] = useState(0);
const redraw = useCallback(() => setTick(n => n + 1), []);
// ---- Mouse interaction state (refs to avoid stale closures) ----
const isPanning = useRef(false);
const panStart = useRef<{ mx: number; my: number; ox: number; oy: number } | null>(null);
const isDrawing = useRef(false);
const isErasing = useRef(false);
const lastPainted = useRef<string | null>(null);
const isDragging = useRef(false);
const dragTokenId = useRef<string | null>(null);
const dragCellPos = useRef<{ x: number; y: number } | null>(null);
// ---- WASD state ----
const keysHeld = useRef<Set<string>>(new Set());
const rafId = useRef<number | null>(null);
const lastFrameTime = useRef<number | null>(null);
// ---- Stable send ref so handlers never go stale ----
const sendRef = useRef<(msg: ClientMessage) => void>(() => {});
const [cursor, setCursor] = useState<string>('default');
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(null);
// -------------------------------------------------------------------------
// Imperative handle — lets App.tsx trigger a color WS update
// -------------------------------------------------------------------------
useImperativeHandle(ref, () => ({
sendColorUpdate(colors: string[]) {
sendRef.current({ type: 'update_colors', colors });
},
}));
// -------------------------------------------------------------------------
// Resize canvas to fill container
// -------------------------------------------------------------------------
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const resize = () => {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
redraw();
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
return () => observer.disconnect();
}, [redraw]);
// -------------------------------------------------------------------------
// WebSocket
// -------------------------------------------------------------------------
// Keep a stable ref to the callback so handleMessage doesn't re-create
const onColorsLoadedRef = useRef(onColorsLoaded);
useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; }, [onColorsLoaded]);
const handleMessage = useCallback((msg: ServerMessage) => {
switch (msg.type) {
case 'state': {
cellsRef.current.clear();
tokensRef.current.clear();
msg.cells.forEach(c => cellsRef.current.set(cellKey(c.x, c.y), c));
msg.tokens.forEach(t => tokensRef.current.set(t.id, t));
onColorsLoadedRef.current(msg.colors);
redraw();
break;
}
case 'cell_painted': {
const key = cellKey(msg.x, msg.y);
cellsRef.current.set(key, {
map_id: mapId,
x: msg.x, y: msg.y, color: msg.color,
});
redraw();
break;
}
case 'cells_batch_painted': {
msg.cells.forEach(c => {
const key = cellKey(c.x, c.y);
cellsRef.current.set(key, {
map_id: mapId,
x: c.x, y: c.y, color: c.color,
});
});
redraw();
break;
}
case 'cell_erased': {
cellsRef.current.delete(cellKey(msg.x, msg.y));
redraw();
break;
}
case 'token_added': {
tokensRef.current.set(msg.id, {
id: msg.id, map_id: mapId,
x: msg.x, y: msg.y, label: msg.label, color: msg.color,
});
redraw();
break;
}
case 'token_moved': {
const tok = tokensRef.current.get(msg.id);
if (tok) {
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
redraw();
}
break;
}
case 'token_deleted': {
tokensRef.current.delete(msg.id);
redraw();
break;
}
case 'colors_updated': {
onColorsLoadedRef.current(msg.colors);
break;
}
case 'error':
console.error('[Grid WS]', msg.message);
break;
}
}, [mapId, redraw]);
const { send } = useWebSocket(mapId, handleMessage);
useEffect(() => { sendRef.current = send; }, [send]);
// -------------------------------------------------------------------------
// Canvas draw
// -------------------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
const cam = cameraRef.current;
const { offsetX, offsetY, zoom } = cam;
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, W, H);
const startCX = Math.floor(offsetX) - 1;
const endCX = Math.ceil(offsetX + W / zoom) + 1;
const startCY = Math.floor(offsetY) - 1;
const endCY = Math.ceil(offsetY + H / zoom) + 1;
// Painted cells
cellsRef.current.forEach(cell => {
if (cell.x < startCX || cell.x > endCX || cell.y < startCY || cell.y > endCY) return;
const { x: px, y: py } = cellToCanvas(cell.x, cell.y, cam);
ctx.fillStyle = cell.color;
ctx.fillRect(px, py, zoom, zoom);
});
// Grid lines
if (zoom >= 5) {
ctx.lineWidth = 1;
for (let x = startCX; x <= endCX; x++) {
ctx.strokeStyle = x % 5 === 0 ? GRID_COLOR_MAJOR : GRID_COLOR;
const px = (x - offsetX) * zoom + 0.5;
ctx.beginPath();
ctx.moveTo(px, 0);
ctx.lineTo(px, H);
ctx.stroke();
}
for (let y = startCY; y <= endCY; y++) {
ctx.strokeStyle = y % 5 === 0 ? GRID_COLOR_MAJOR : GRID_COLOR;
const py = (y - offsetY) * zoom + 0.5;
ctx.beginPath();
ctx.moveTo(0, py);
ctx.lineTo(W, py);
ctx.stroke();
}
}
// Tokens (skip the one being dragged)
tokensRef.current.forEach(token => {
if (isDragging.current && dragTokenId.current === token.id) return;
drawToken(ctx, token.x, token.y, token.label, token.color, cam);
});
// Drag ghost
if (isDragging.current && dragCellPos.current && dragTokenId.current) {
const tok = tokensRef.current.get(dragTokenId.current);
if (tok) {
ctx.globalAlpha = 0.6;
drawToken(ctx, dragCellPos.current.x, dragCellPos.current.y, tok.label, tok.color, cam);
ctx.globalAlpha = 1;
}
}
}, [tick]);
// -------------------------------------------------------------------------
// Camera helpers
// -------------------------------------------------------------------------
function applyClampAndRedraw() {
const canvas = canvasRef.current;
if (canvas) {
clampCameraToContent(
cameraRef.current, cellsRef.current, tokensRef.current,
canvas.width, canvas.height,
);
}
redraw();
}
function applyZoom(canvasX: number, canvasY: number, factor: number) {
const cam = cameraRef.current;
const worldX = canvasX / cam.zoom + cam.offsetX;
const worldY = canvasY / cam.zoom + cam.offsetY;
const newZoom = clamp(cam.zoom * factor, MIN_ZOOM, MAX_ZOOM);
cam.offsetX = worldX - canvasX / newZoom;
cam.offsetY = worldY - canvasY / newZoom;
cam.zoom = newZoom;
applyClampAndRedraw();
}
// -------------------------------------------------------------------------
// Wheel → zoom
// -------------------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
applyZoom(cx, cy, factor);
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return () => canvas.removeEventListener('wheel', onWheel);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// WASD panning — requestAnimationFrame loop
// -------------------------------------------------------------------------
useEffect(() => {
function rafTick(timestamp: number) {
const keys = keysHeld.current;
if (keys.size === 0) {
lastFrameTime.current = null;
rafId.current = null;
return;
}
const dt = lastFrameTime.current !== null
? (timestamp - lastFrameTime.current) / 1000
: 0;
lastFrameTime.current = timestamp;
const cam = cameraRef.current;
const speed = WASD_PAN_SPEED;
if (keys.has('a')) cam.offsetX -= speed * dt;
if (keys.has('d')) cam.offsetX += speed * dt;
if (keys.has('w')) cam.offsetY -= speed * dt;
if (keys.has('s')) cam.offsetY += speed * dt;
const canvas = canvasRef.current;
if (canvas) {
clampCameraToContent(cam, cellsRef.current, tokensRef.current, canvas.width, canvas.height);
}
setTick(n => n + 1);
rafId.current = requestAnimationFrame(rafTick);
}
function onKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
// Don't intercept WASD when modifier keys are held (e.g. Shift+keys are for tool shortcuts)
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
const key = e.key.toLowerCase();
if (['w', 'a', 's', 'd'].includes(key)) {
e.preventDefault();
keysHeld.current.add(key);
if (rafId.current === null) {
lastFrameTime.current = null;
rafId.current = requestAnimationFrame(rafTick);
}
}
}
function onKeyUp(e: KeyboardEvent) {
keysHeld.current.delete(e.key.toLowerCase());
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
if (rafId.current !== null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// Mouse helpers
// -------------------------------------------------------------------------
function getCanvasPoint(e: React.MouseEvent) {
const rect = canvasRef.current!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function tokenAtCell(x: number, y: number): GridToken | null {
for (const t of tokensRef.current.values()) {
if (t.x === x && t.y === y) return t;
}
return null;
}
// -------------------------------------------------------------------------
// Mouse handlers
// -------------------------------------------------------------------------
function handleMouseDown(e: React.MouseEvent) {
e.preventDefault();
const { x: mx, y: my } = getCanvasPoint(e);
const cell = canvasToCell(mx, my, cameraRef.current);
// ---- Pan tool ----
if (tool === 'pan' && e.button === 0) {
isPanning.current = true;
panStart.current = { mx, my, ox: cameraRef.current.offsetX, oy: cameraRef.current.offsetY };
setCursor('grabbing');
return;
}
// ---- Zoom tool ----
if (tool === 'zoom') {
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP));
return;
}
// ---- Draw tool ----
if (tool === 'draw') {
if (e.button === 0) {
if (e.shiftKey) {
// Shift+click → flood fill uncolored region
const key = cellKey(cell.x, cell.y);
if (!cellsRef.current.has(key)) {
const region = floodFill(cell.x, cell.y, cellsRef.current);
if (region === null || region.length === 1) {
// Unbounded or trivially single cell → paint one cell
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
} else {
// Bounded enclosed region → batch paint
sendRef.current({
type: 'paint_cells',
cells: region.map(({ x, y }) => ({ x, y, color: paintColor })),
});
}
}
} else {
isDrawing.current = true;
lastPainted.current = cellKey(cell.x, cell.y);
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
}
} else if (e.button === 2) {
isErasing.current = true;
const key = cellKey(cell.x, cell.y);
lastPainted.current = key;
if (cellsRef.current.has(key)) {
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
}
}
return;
}
// ---- Token tool ----
if (tool === 'token') {
if (e.button === 2) {
const tok = tokenAtCell(cell.x, cell.y);
if (tok) sendRef.current({ type: 'delete_token', id: tok.id });
return;
}
if (e.button === 0) {
const tok = tokenAtCell(cell.x, cell.y);
if (tok) {
isDragging.current = true;
dragTokenId.current = tok.id;
dragCellPos.current = { x: cell.x, y: cell.y };
redraw();
} else {
setDialogPos({ x: cell.x, y: cell.y });
}
}
}
}
function handleMouseMove(e: React.MouseEvent) {
const { x: mx, y: my } = getCanvasPoint(e);
// Pan
if (isPanning.current && panStart.current) {
const cam = cameraRef.current;
const dx = (mx - panStart.current.mx) / cam.zoom;
const dy = (my - panStart.current.my) / cam.zoom;
cam.offsetX = panStart.current.ox - dx;
cam.offsetY = panStart.current.oy - dy;
applyClampAndRedraw();
return;
}
// Draw / erase stroke
if (isDrawing.current || isErasing.current) {
const cell = canvasToCell(mx, my, cameraRef.current);
const key = cellKey(cell.x, cell.y);
if (lastPainted.current !== key) {
lastPainted.current = key;
if (isDrawing.current) {
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
} else if (isErasing.current && cellsRef.current.has(key)) {
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
}
}
return;
}
// Token drag
if (isDragging.current && dragCellPos.current) {
const cell = canvasToCell(mx, my, cameraRef.current);
if (dragCellPos.current.x !== cell.x || dragCellPos.current.y !== cell.y) {
dragCellPos.current = { x: cell.x, y: cell.y };
redraw();
}
}
}
function handleMouseUp(_e: React.MouseEvent) {
if (isPanning.current) {
isPanning.current = false;
panStart.current = null;
setCursor('grab');
return;
}
if (isDrawing.current || isErasing.current) {
isDrawing.current = false;
isErasing.current = false;
lastPainted.current = null;
return;
}
if (isDragging.current && dragTokenId.current && dragCellPos.current) {
const tok = tokensRef.current.get(dragTokenId.current);
if (tok && (tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)) {
sendRef.current({
type: 'move_token',
id: dragTokenId.current,
x: dragCellPos.current.x,
y: dragCellPos.current.y,
});
}
isDragging.current = false;
dragTokenId.current = null;
dragCellPos.current = null;
redraw();
}
}
function handleMouseLeave() {
isPanning.current = false;
panStart.current = null;
isDrawing.current = false;
isErasing.current = false;
lastPainted.current = null;
if (isDragging.current) {
isDragging.current = false;
dragTokenId.current = null;
dragCellPos.current = null;
redraw();
}
}
// Sync cursor CSS to active tool
useEffect(() => {
switch (tool) {
case 'pan': setCursor('grab'); break;
case 'zoom': setCursor('zoom-in'); break;
case 'draw': setCursor('crosshair'); break;
case 'token': setCursor('crosshair'); break;
}
}, [tool]);
function handleAddToken(label: string, color: string) {
if (!dialogPos) return;
sendRef.current({ type: 'add_token', x: dialogPos.x, y: dialogPos.y, label, color });
setDialogPos(null);
}
return (
<div ref={containerRef} className="grid-container">
<canvas
ref={canvasRef}
className="grid-canvas"
style={{ cursor }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onContextMenu={e => e.preventDefault()}
/>
{dialogPos && (
<TokenDialog
defaultColor={tokenColor}
onConfirm={handleAddToken}
onCancel={() => setDialogPos(null)}
/>
)}
</div>
);
});
export default Grid;

View File

@@ -0,0 +1,21 @@
import { auth } from '../api';
interface Props {
className?: string;
}
export default function LoginButton({ className }: Props) {
async function handleLogin() {
try {
await auth.login();
} catch (err) {
console.error('Failed to initiate login:', err);
}
}
return (
<button className={className} onClick={handleLogin}>
Log in with Discord
</button>
);
}

View File

@@ -0,0 +1,127 @@
.map-list {
width: 220px;
flex-shrink: 0;
background: #1f2937;
border-right: 1px solid #374151;
display: flex;
flex-direction: column;
overflow: hidden;
}
.map-list-header {
padding: 1rem;
border-bottom: 1px solid #374151;
}
.map-list-header h2 {
font-size: 0.95rem;
font-weight: 600;
color: #f3f4f6;
letter-spacing: 0.02em;
}
.map-create-form {
display: flex;
gap: 0.25rem;
padding: 0.75rem;
border-bottom: 1px solid #374151;
}
.map-create-form input {
flex: 1;
min-width: 0;
background: #111827;
border: 1px solid #4b5563;
border-radius: 4px;
color: #e5e7eb;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
outline: none;
}
.map-create-form input:focus {
border-color: #6366f1;
}
.map-create-form button {
background: #6366f1;
color: white;
border: none;
border-radius: 4px;
padding: 0.35rem 0.6rem;
font-size: 1rem;
cursor: pointer;
line-height: 1;
}
.map-create-form button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.map-entries {
list-style: none;
flex: 1;
overflow-y: auto;
padding: 0.25rem 0;
}
.map-empty {
padding: 1rem;
color: #6b7280;
font-size: 0.85rem;
text-align: center;
}
.map-entry {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.1s;
border-radius: 4px;
margin: 0.1rem 0.25rem;
}
.map-entry:hover {
background: #374151;
}
.map-entry.selected {
background: #4338ca;
}
.map-name {
flex: 1;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.map-dims {
font-size: 0.7rem;
color: #9ca3af;
flex-shrink: 0;
}
.map-entry.selected .map-dims {
color: #c7d2fe;
}
.map-delete {
background: none;
border: none;
cursor: pointer;
padding: 0.1rem;
opacity: 0;
transition: opacity 0.15s;
font-size: 0.85rem;
line-height: 1;
flex-shrink: 0;
}
.map-entry:hover .map-delete {
opacity: 1;
}

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import type { GridMap } from '../types';
import './MapList.css';
interface Props {
maps: GridMap[];
selectedMapId: string | null;
onSelect: (id: string) => void;
onCreate: (name: string) => void;
onDelete: (id: string) => void;
}
export default function MapList({ maps, selectedMapId, onSelect, onCreate, onDelete }: Props) {
const [newName, setNewName] = useState('');
function handleCreate(e: React.FormEvent) {
e.preventDefault();
const name = newName.trim();
if (!name) return;
onCreate(name);
setNewName('');
}
function handleDeleteClick(e: React.MouseEvent, id: string) {
e.stopPropagation();
if (confirm('Delete this map? This cannot be undone.')) {
onDelete(id);
}
}
return (
<aside className="map-list">
<div className="map-list-header">
<h2>Maps</h2>
</div>
<form className="map-create-form" onSubmit={handleCreate}>
<input
type="text"
placeholder="New map name…"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
<button type="submit" disabled={!newName.trim()}>+</button>
</form>
<ul className="map-entries">
{maps.length === 0 && (
<li className="map-empty">No maps yet</li>
)}
{maps.map(map => (
<li
key={map.id}
className={`map-entry ${map.id === selectedMapId ? 'selected' : ''}`}
onClick={() => onSelect(map.id)}
>
<span className="map-name">{map.name}</span>
<button
className="map-delete"
onClick={e => handleDeleteClick(e, map.id)}
title="Delete map"
>
Delete
</button>
</li>
))}
</ul>
</aside>
);
}

View File

@@ -0,0 +1,105 @@
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: #1f2937;
border: 1px solid #374151;
border-radius: 10px;
padding: 1.5rem;
width: 280px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
}
.dialog h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #f9fafb;
}
.dialog form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dialog label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.8rem;
color: #9ca3af;
}
.dialog label input[type='text'] {
background: #111827;
border: 1px solid #4b5563;
border-radius: 5px;
color: #e5e7eb;
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
outline: none;
}
.dialog label input[type='text']:focus {
border-color: #6366f1;
}
.dialog label input[type='color'] {
width: 48px;
height: 32px;
padding: 0;
border: 1px solid #4b5563;
border-radius: 5px;
cursor: pointer;
background: none;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.btn-primary {
background: #6366f1;
color: white;
border: none;
border-radius: 5px;
padding: 0.45rem 1rem;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.12s;
}
.btn-primary:hover {
background: #4f46e5;
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
background: #374151;
color: #e5e7eb;
border: none;
border-radius: 5px;
padding: 0.45rem 1rem;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.12s;
}
.btn-secondary:hover {
background: #4b5563;
}

View File

@@ -0,0 +1,66 @@
import { useState, useEffect, useRef } from 'react';
import './TokenDialog.css';
interface Props {
defaultColor: string;
onConfirm: (label: string, color: string) => void;
onCancel: () => void;
}
export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props) {
const [label, setLabel] = useState('');
const [color, setColor] = useState(defaultColor);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const name = label.trim();
if (!name) return;
onConfirm(name, color);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') onCancel();
}
return (
<div className="dialog-overlay" onClick={onCancel} onKeyDown={handleKeyDown}>
<div className="dialog" onClick={e => e.stopPropagation()}>
<h3>Add Token</h3>
<form onSubmit={handleSubmit}>
<label>
Name
<input
ref={inputRef}
type="text"
placeholder="e.g. Strahd von Zarovich"
value={label}
onChange={e => setLabel(e.target.value)}
maxLength={30}
/>
</label>
<label>
Colour
<input
type="color"
value={color}
onChange={e => setColor(e.target.value)}
/>
</label>
<div className="dialog-actions">
<button type="button" onClick={onCancel} className="btn-secondary">
Cancel
</button>
<button type="submit" className="btn-primary" disabled={!label.trim()}>
Place Token
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef, useCallback } from 'react';
import type { ServerMessage, ClientMessage } from '../types';
import { getToken } from '../api';
export function useWebSocket(
mapId: string,
onMessage: (msg: ServerMessage) => void,
) {
const wsRef = useRef<WebSocket | null>(null);
// Keep onMessage stable via a ref to avoid reconnecting on every render
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken();
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws${tokenParam}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
console.log(`[WS] Connected to map ${mapId}`);
};
ws.onmessage = (event: MessageEvent) => {
try {
const msg: ServerMessage = JSON.parse(event.data as string);
onMessageRef.current(msg);
} catch (err) {
console.error('[WS] Failed to parse message:', err);
}
};
ws.onerror = (err) => {
console.error('[WS] Error:', err);
};
ws.onclose = () => {
console.log(`[WS] Disconnected from map ${mapId}`);
};
return () => {
ws.close();
wsRef.current = null;
};
}, [mapId]);
const send = useCallback((msg: ClientMessage) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}, []);
return { send };
}

12
ui/src/index.css Normal file
View File

@@ -0,0 +1,12 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
font-family: system-ui, -apple-system, sans-serif;
background: #111827;
color: #e5e7eb;
}

10
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

79
ui/src/types.ts Normal file
View File

@@ -0,0 +1,79 @@
export interface User {
id: string; // Discord snowflake (stored as string)
username: string;
avatar?: string;
}
export type MapRole = 'owner' | 'editor' | 'viewer';
export interface MapPermission {
map_id: string;
user_id: number;
role: MapRole;
}
export interface GridMap {
id: string;
name: string;
is_public: boolean;
owner_id: number;
colors: string[];
created_at: string;
updated_at: string;
}
export interface GridCell {
map_id: string;
x: number;
y: number;
color: string;
}
export interface GridToken {
id: string;
map_id: string;
x: number;
y: number;
label: string;
color: string;
}
export interface MapState {
map: GridMap;
cells: GridCell[];
tokens: GridToken[];
}
export type Tool = 'pan' | 'zoom' | 'draw' | 'token';
// ---- WebSocket message types ------------------------------------------------
export type ClientMessage =
| { type: 'paint_cell'; x: number; y: number; color: string }
| { type: 'paint_cells'; cells: Array<{ x: number; y: number; color: string }> }
| { type: 'erase_cell'; x: number; y: number }
| { type: 'add_token'; x: number; y: number; label: string; color: string }
| { type: 'move_token'; id: string; x: number; y: number }
| { type: 'delete_token'; id: string }
| { type: 'update_colors'; colors: string[] };
export type ServerMessage =
| { type: 'state'; cells: GridCell[]; tokens: GridToken[]; colors: string[] }
| { type: 'cell_painted'; x: number; y: number; color: string }
| { type: 'cells_batch_painted'; cells: Array<{ x: number; y: number; color: string }> }
| { type: 'cell_erased'; x: number; y: number }
| { type: 'token_added'; id: string; x: number; y: number; label: string; color: string }
| { type: 'token_moved'; id: string; x: number; y: number }
| { type: 'token_deleted'; id: string }
| { type: 'colors_updated'; colors: string[] }
| { type: 'error'; message: string };
// ---- Auth token payload (JWT claims) ----------------------------------------
export interface TokenClaims {
sub: number; // Discord user ID
name: string;
iat: number;
exp: number;
jti: string;
}

20
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

22
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
// Proxy REST calls and WebSocket upgrades to the Axum backend
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
ws: true,
},
},
allowedHosts: ['localhost', '127.0.0.1', 'sirensong.app', 'inartificial-fishier-ngoc.ngrok-free.dev'],
},
build: {
outDir: 'dist',
},
});