Updating auth

This commit is contained in:
2026-04-04 08:28:43 -04:00
parent 35d07e8df1
commit f17e5061cd
78 changed files with 5266 additions and 1380 deletions

View File

@@ -1,4 +1,4 @@
/* ---- Full-viewport shell ---- */
/* ── Full-viewport shell ── */
.app {
display: flex;
flex-direction: column;
@@ -6,132 +6,21 @@
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;
/* ── App body (everything below the header) ── */
.app-body {
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;
overflow: hidden;
}
.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) ---- */
/* ── Grid area (fills the app body) ── */
.app-grid-area {
flex: 1;
position: relative;
overflow: hidden;
}
/* ── Floating panel stack bottom-left corner ── */
/* ── Floating control panels bottom-left corner ── */
.floating-panels-container {
position: absolute;
bottom: 14px;
@@ -142,7 +31,7 @@
z-index: 20;
}
/* ---- No-map placeholder ---- */
/* ── Empty state placeholder ── */
.empty-state {
height: 100%;
display: flex;
@@ -151,9 +40,11 @@
justify-content: center;
gap: 0.75rem;
color: #4b5563;
user-select: none;
}
.empty-state p {
margin: 0;
font-size: 1.1rem;
}
@@ -162,34 +53,78 @@
color: #374151;
}
/* ---- Auth area (right side of header) ---- */
.app-auth {
/* ── Access denied state ── */
.access-denied-state {
height: 100%;
display: flex;
flex-direction: column;
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;
justify-content: center;
gap: 1rem;
color: #4b5563;
user-select: none;
padding: 2rem;
text-align: center;
}
.new-map-public input[type='checkbox'] {
accent-color: #6366f1;
cursor: pointer;
.access-denied-title {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
color: #9ca3af;
}
.access-denied-hint {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
}
.access-request-sent {
color: #34d399;
}
.access-request-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.access-request-btns {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
justify-content: center;
}
.btn-request-access {
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: 6px;
color: #818cf8;
cursor: pointer;
font-size: 0.85rem;
padding: 0.45rem 1.1rem;
transition:
background 0.12s,
border-color 0.12s;
}
.btn-request-access:hover {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.6);
}
.link-btn {
background: none;
border: none;
color: #818cf8;
cursor: pointer;
font-size: inherit;
padding: 0;
text-decoration: underline;
transition: color 0.12s;
}
.link-btn:hover {
color: #a5b4fc;
}

View File

@@ -1,297 +1,371 @@
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';
import { useState, useEffect, useRef } from "react";
import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types";
import type { GridHandle } from "./components/Grid";
import { api, auth } from "./api";
import Header from "./components/Header";
import ControlPanel from "./components/ControlPanel";
import ColorPanel from "./components/ColorPanel";
import Grid from "./components/Grid";
import LoginModal from "./components/LoginModal";
import AccountPanel from "./components/AccountPanel";
import FloatingMapControls from "./components/FloatingMapControls";
import NewMapModal from "./components/NewMapModal";
import EditMapModal from "./components/EditMapModal";
import MapListModal from "./components/MapListModal";
import "./components/Modal.css";
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
"#6b7280",
"#92400e",
"#15803d",
"#1d4ed8",
"#7c3aed",
"#dc2626",
"#ca8a04",
"#0f172a",
"#f9fafb",
];
/** 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 : ''));
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;
});
// ── Auth state ──
const [user, setUser] = useState<UserInfo | null>(null);
const [authLoading, setAuthLoading] = useState(true);
// ---- Map state ----
const [maps, setMaps] = useState<GridMap[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
// ── Map state ──
const [maps, setMaps] = useState<ListedMap[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
/** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */
const [directMapInfo, setDirectMapInfo] = useState<GridMap | null>(null);
/** True when the current selectedId returned 403 (no access). */
const [accessDenied, setAccessDenied] = useState(false);
const [accessRequestSent, setAccessRequestSent] = useState(false);
// 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
// ── Tool + color ──
const [tool, setTool] = useState<Tool>("pan");
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
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);
// ── Modal visibility ──
const [showLoginModal, setShowLoginModal] = useState(false);
const [showAccountPanel, setShowAccountPanel] = useState(false);
const [showNewMap, setShowNewMap] = useState(false);
const [showEditMap, setShowEditMap] = useState(false);
const [showMapList, setShowMapList] = useState(false);
// ---- Handle OAuth callback: ?token= or ?error= ----
// ── Derived ──
const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null;
const selectedMapInfo: GridMap | ListedMap | null =
selectedMapFromList ?? directMapInfo;
const isOwner =
user !== null &&
selectedMapInfo !== null &&
selectedMapInfo.owner_id === user.id;
// ── On mount: load session + handle OAuth errors ──
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');
auth.me().then((u) => {
setUser(u);
setAuthLoading(false);
});
const error = getQueryParam("error");
if (error) {
console.error("OAuth error:", error);
removeQueryParam("error");
}
}, []);
// ---- Load map list ----
// ── Load map list after auth resolves ──
useEffect(() => {
api.listMaps().then(setMaps).catch(console.error);
}, [user]); // re-fetch when auth state changes
if (!authLoading) {
api.listMaps().then(setMaps).catch(console.error);
}
}, [user, authLoading]);
// Once maps load, validate the URL-sourced selectedId still exists
// ── Direct fetch for URL-accessed maps not in the user's list ──
useEffect(() => {
if (maps.length === 0 && selectedId) {
// Maps are still loading — skip
if (!selectedId || authLoading) {
setDirectMapInfo(null);
setAccessDenied(false);
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');
}
const inList = maps.some((m) => m.id === selectedId);
if (inList) {
setDirectMapInfo(null);
setAccessDenied(false);
return;
}
}, [maps]); // eslint-disable-line react-hooks/exhaustive-deps
// Keep the URL in sync with the selected map
setDirectMapInfo(null);
setAccessDenied(false);
setAccessRequestSent(false);
api
.getMap(selectedId)
.then((state) => {
setDirectMapInfo(state.map);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg.startsWith("403")) {
setAccessDenied(true);
} else {
// 404 or unknown — clear invalid URL
setSelectedId(null);
window.history.replaceState(null, "", "/map");
}
});
}, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Keep URL in sync ──
useEffect(() => {
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : '/map';
window.history.replaceState(null, '', path);
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
// ── Reset palette + access state when map deselected ──
useEffect(() => {
if (!selectedId) {
setMapColors(DEFAULT_COLORS);
setActiveColor(DEFAULT_COLORS[0]);
setAccessRequestSent(false);
}
}, [selectedId]);
// ---- Derived state ----
const selectedMap = maps.find(m => m.id === selectedId) ?? null;
// ── Handlers ──
// 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 handleCreate(name: string, publicAccess: PublicAccess) {
const m = await api.createMap(name, publicAccess);
// Optimistically add to list as an owner entry
const listed: ListedMap = {
...m,
owner_username: user!.username,
user_role: "owner",
is_favorited: false,
};
setMaps((prev) => [listed, ...prev]);
setSelectedId(m.id);
}
async function handleDelete() {
if (!selectedId) return;
if (!confirm('Delete this map? This cannot be undone.')) return;
if (!confirm("Delete this map? This cannot be undone.")) return;
try {
await api.deleteMap(selectedId);
setMaps(prev => prev.filter(m => m.id !== selectedId));
setMaps((prev) => prev.filter((m) => m.id !== selectedId));
setSelectedId(null);
} catch (err) {
console.error('Failed to delete map', 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]);
function handleMapUpdated(updated: GridMap) {
setMaps((prev) =>
prev.map((m) =>
m.id === updated.id
? {
...m,
name: updated.name,
public_access: updated.public_access,
updated_at: updated.updated_at,
}
: m,
),
);
if (directMapInfo?.id === updated.id) {
setDirectMapInfo(updated);
}
}
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);
}
async function handleUserRefresh() {
const u = await auth.me();
setUser(u);
}
async function handleRequestAccess(role: "viewer" | "editor") {
if (!selectedId) return;
try {
await api.requestAccess(selectedId, role);
setAccessRequestSent(true);
} catch (err) {
console.error("Failed to request access", err);
}
}
// ── Render ──
return (
<div className="app">
{/* ── Header ── */}
<header className="app-header">
<div className="app-brand">
<span>SIREN</span>
</div>
<Header
user={user}
authLoading={authLoading}
selectedMapName={selectedMapInfo?.name ?? null}
onLoginClick={() => setShowLoginModal(true)}
onAccountClick={() => setShowAccountPanel(true)}
/>
<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>
)}
<div className="app-body">
<div className="app-grid-area">
{/* Top-left floating map controls */}
<FloatingMapControls
isLoggedIn={!!user}
hasSelectedMap={!!selectedId}
isOwner={isOwner}
onNewMap={() => setShowNewMap(true)}
onViewMaps={() => setShowMapList(true)}
onEditMap={() => setShowEditMap(true)}
onDeleteMap={handleDelete}
/>
{/* 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 ? (
{selectedId && !accessDenied ? (
<>
<span className="app-username">{user.name}</span>
<button className="header-btn" onClick={() => { auth.logout(); setUser(null); }}>
Log out
</button>
<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>
</>
) : accessDenied ? (
<div className="access-denied-state">
<p className="access-denied-title">
You don't have access to this map
</p>
{!user ? (
<p className="access-denied-hint">
<button
className="link-btn"
onClick={() => setShowLoginModal(true)}
>
Log in
</button>{" "}
to request access or view your permissions.
</p>
) : accessRequestSent ? (
<p className="access-denied-hint access-request-sent">
✓ Access request sent! The map owner will be notified.
</p>
) : (
<div className="access-request-actions">
<p className="access-denied-hint">
Request access from the map owner:
</p>
<div className="access-request-btns">
<button
className="btn-request-access"
onClick={() => handleRequestAccess("viewer")}
>
Request Viewer Access
</button>
<button
className="btn-request-access"
onClick={() => handleRequestAccess("editor")}
>
Request Editor Access
</button>
</div>
</div>
)}
</div>
) : (
<LoginButton className="header-btn" />
<div className="empty-state">
<p>Select or create a map to begin</p>
<p className="empty-hint">
{!user
? "Log in to create maps and access private maps"
: maps.length === 0
? 'Click "+ New Map" in the top-left to get started'
: 'Click "Maps" in the top-left to choose a map'}
</p>
</div>
)}
</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>
{/* ── Global modals (always available regardless of page) ── */}
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onLogin={async (u) => {
setUser(u);
api.listMaps().then(setMaps).catch(console.error);
}}
/>
)}
{showAccountPanel && user && (
<AccountPanel
user={user}
onClose={() => setShowAccountPanel(false)}
onRefresh={handleUserRefresh}
/>
)}
{showNewMap && (
<NewMapModal
onClose={() => setShowNewMap(false)}
onCreate={handleCreate}
/>
)}
{showEditMap && selectedMapInfo && (
<EditMapModal
map={selectedMapInfo}
onClose={() => setShowEditMap(false)}
onUpdated={handleMapUpdated}
/>
)}
{showMapList && (
<MapListModal
maps={maps}
selectedMapId={selectedId}
onSelect={(id) => setSelectedId(id)}
onClose={() => setShowMapList(false)}
onMapsChange={setMaps}
/>
)}
</div>
);
}

View File

@@ -1,126 +1,232 @@
import type { GridMap, MapPermission, MapRole, MapState, TokenClaims } from './types';
import type {
GridMap,
ListedMap,
MapAccessRequest,
MapPermission,
MapRole,
MapState,
PublicAccess,
UserInfo,
} 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
// ---------------------------------------------------------------------------
const GRID_BASE = "/api/grid";
const AUTH_BASE = "/api/auth";
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();
}
const res = await fetch(url, {
...init,
credentials: "include",
headers: {
...(init?.headers as Record<string, string>),
},
});
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();
// Read the bdoy if it exists
const text = await res.text();
if (!text) return undefined as T;
// Parse JSON if it exists
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return JSON.parse(text) as T;
} else if (contentType.includes("text/plain")) {
return text as unknown as T;
}
throw new Error(`Expected JSON or text but got: ${contentType}`);
}
// ---------------------------------------------------------------------------
// Grid map API
// ---------------------------------------------------------------------------
export const api = {
listMaps: (): Promise<GridMap[]> =>
request<GridMap[]>(`${BASE}/maps`),
/** List maps where the authenticated user has a direct role or has favorited. */
listMaps: (): Promise<ListedMap[]> =>
request<ListedMap[]>(`${GRID_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 }),
/** Create a new map (authenticated). */
createMap: (
name: string,
public_access: PublicAccess = "private",
): Promise<GridMap> =>
request<GridMap>(`${GRID_BASE}/maps`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, public_access }),
}),
/** Get full map state (cells, tokens, colors). */
getMap: (id: string): Promise<MapState> =>
request<MapState>(`${BASE}/maps/${id}`),
request<MapState>(`${GRID_BASE}/maps/${id}`),
/** Update map name and/or public_access (owner only). */
updateMap: (
id: string,
payload: { name?: string; public_access?: PublicAccess },
): Promise<GridMap> =>
request<GridMap>(`${GRID_BASE}/maps/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
/** Delete a map (owner only). */
deleteMap: (id: string): Promise<void> =>
request<void>(`${BASE}/maps/${id}`, { method: 'DELETE' }),
request<void>(`${GRID_BASE}/maps/${id}`, { method: "DELETE" }),
// ---- Permissions ----
/** List all permissions for a map including usernames (owner only). */
listPermissions: (mapId: string): Promise<MapPermission[]> =>
request<MapPermission[]>(`${BASE}/maps/${mapId}/permissions`),
request<MapPermission[]>(`${GRID_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 }),
/**
* Add or update a user's role by username.
* Pass `role: null` to remove the user's permission entirely.
*/
updatePermission: (
mapId: string,
username: string,
role: MapRole | null,
): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/permissions`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, role }),
}),
/** Favorite a map (adds it to the user's map list). */
favoriteMap: (id: string): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "POST" }),
/** Un-favorite a map. */
unfavoriteMap: (id: string): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "DELETE" }),
/** Request viewer or editor access to a map. */
requestAccess: (mapId: string, role: "editor" | "viewer"): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role }),
}),
/** List pending access requests for a map (owner only). */
listAccessRequests: (mapId: string): Promise<MapAccessRequest[]> =>
request<MapAccessRequest[]>(`${GRID_BASE}/maps/${mapId}/access-requests`),
/** Approve or deny a pending access request (owner only). */
resolveAccessRequest: (
mapId: string,
requestId: string,
action: "approve" | "deny",
): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests/${requestId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
}),
};
// ---------------------------------------------------------------------------
// 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;
/** Fetch the currently authenticated user's info. Returns null if not logged in. */
async me(): Promise<UserInfo | null> {
try {
return await request<UserInfo>(`${AUTH_BASE}/me`);
} catch {
return null;
}
},
logout(): void {
removeToken();
window.location.href = '/map';
/** Register a new local account. */
async register(username: string, password: string): Promise<void> {
await request<void>(`${AUTH_BASE}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
},
/** Login with username and password. */
async loginLocal(username: string, password: string): Promise<void> {
await request<void>(`${AUTH_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
},
/** Start Discord OAuth login flow (anonymous). */
async loginDiscord(redirectUri?: string): Promise<void> {
const target = encodeURIComponent(redirectUri ?? window.location.href);
const response = await request<string>(
`${AUTH_BASE}/discord/authorize?redirect_uri=${target}`,
);
window.location.href = JSON.parse(response);
},
/** Start Discord OAuth connect flow (authenticated users only). */
async connectDiscord(redirectUri?: string): Promise<void> {
const target = encodeURIComponent(
redirectUri ?? window.location.origin + "/account",
);
const response = await request<string>(
`${AUTH_BASE}/discord/connect?redirect_uri=${target}`,
);
window.location.href = JSON.parse(response);
},
/** Clear the session cookie server-side and reload. */
async logout(): Promise<void> {
try {
await request<void>(`${AUTH_BASE}/logout`, { method: "POST" });
} catch {
// Ignore errors
}
window.location.href = "/map";
},
/**
* Update the user's first and/or last name.
* Pass an empty string to clear a field; pass undefined to leave it unchanged.
*/
async updateProfile(
firstName?: string,
lastName?: string,
): Promise<UserInfo> {
return request<UserInfo>(`${AUTH_BASE}/profile`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ first_name: firstName, last_name: lastName }),
});
},
/**
* Change or set the account password.
* `currentPassword` is required when the account already has a password,
* and can be omitted (null/undefined) for OAuth-only accounts setting a
* password for the first time.
*/
async changePassword(
currentPassword: string | null,
newPassword: string,
): Promise<void> {
await request<void>(`${AUTH_BASE}/change-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_password: currentPassword ?? undefined,
new_password: newPassword,
}),
});
},
/** Disconnect an OAuth provider (requires a password to be set first). */
async disconnectProvider(provider: string): Promise<void> {
await request<void>(`${AUTH_BASE}/connections/${provider}`, {
method: "DELETE",
});
},
};

View File

@@ -0,0 +1,316 @@
/* ── Backdrop ── */
.account-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* ── Panel card ── */
.account-panel {
background: #1e2130;
border: 1px solid #2e3348;
border-radius: 10px;
padding: 1.5rem;
width: 440px;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Header ── */
.account-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.account-header h2 {
margin: 0;
font-size: 1.1rem;
color: #e2e8f0;
}
.account-close {
background: none;
border: none;
color: #8892a4;
font-size: 1rem;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.account-close:hover {
color: #e2e8f0;
}
/* ── Section ── */
.account-section {
border-top: 1px solid #2e3348;
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.account-section h3 {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8892a4;
}
.section-header-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-header-row h3 {
margin: 0;
}
/* ── Profile form ── */
.profile-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.profile-name-row {
display: grid;
grid-template-columns: 1fr;
gap: 0.65rem;
}
.account-field-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.75rem;
color: #8892a4;
font-weight: 500;
}
.account-field-label input {
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
outline: none;
transition: border-color 0.12s;
}
.account-field-label input:focus {
border-color: #6366f1;
}
/* ── Read-only fields ── */
.readonly-field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.account-label {
font-size: 0.8rem;
color: #8892a4;
min-width: 70px;
}
.account-value {
font-size: 0.9rem;
color: #e2e8f0;
}
/* ── Profile save / cancel row ── */
.profile-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.6rem;
margin-top: 0.25rem;
}
.btn-save {
background: #6366f1;
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
padding: 0.35rem 0.9rem;
transition: background 0.12s;
}
.btn-save:hover {
background: #4f46e5;
}
.btn-save:disabled {
opacity: 0.5;
cursor: default;
}
.btn-text {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
font-size: 0.82rem;
padding: 0.25rem 0.4rem;
transition: color 0.12s;
}
.btn-text:hover {
color: #9ca3af;
}
/* ── Password form ── */
.password-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
/* ── Messages ── */
.account-error {
margin: 0;
font-size: 0.8rem;
color: #f87171;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 5px;
padding: 0.35rem 0.6rem;
}
.account-success {
margin: 0;
font-size: 0.8rem;
color: #34d399;
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 5px;
padding: 0.35rem 0.6rem;
}
/* ── Connection row ── */
.account-connection {
display: flex;
align-items: center;
gap: 0.75rem;
background: #141622;
border: 1px solid #2e3348;
border-radius: 8px;
padding: 0.75rem 1rem;
}
.connection-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.discord-icon {
color: #5865f2;
}
.connection-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.connection-name {
font-size: 0.85rem;
font-weight: 600;
color: #e2e8f0;
}
.connection-linked {
font-size: 0.75rem;
color: #4ade80;
}
.connection-unlinked {
font-size: 0.75rem;
color: #8892a4;
}
.connection-hint {
margin: 0;
font-size: 0.75rem;
color: #f59e0b;
padding: 0 0.25rem;
}
.btn-connect-discord {
background: #5865f2;
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0.35rem 0.75rem;
transition: background 0.15s;
white-space: nowrap;
}
.btn-connect-discord:hover {
background: #4752c4;
}
.btn-disconnect {
background: transparent;
border: 1px solid #4a5568;
border-radius: 6px;
color: #8892a4;
cursor: pointer;
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
transition:
color 0.15s,
border-color 0.15s;
white-space: nowrap;
}
.btn-disconnect:hover:not(:disabled) {
border-color: #ef4444;
color: #f87171;
}
.btn-disconnect:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Footer ── */
.account-footer {
border-top: 1px solid #2e3348;
padding-top: 1rem;
display: flex;
justify-content: flex-end;
}
.btn-logout {
background: transparent;
border: 1px solid #4a5568;
border-radius: 6px;
color: #8892a4;
cursor: pointer;
font-size: 0.85rem;
padding: 0.4rem 1rem;
transition:
color 0.15s,
border-color 0.15s;
}
.btn-logout:hover {
border-color: #ef4444;
color: #ef4444;
}

View File

@@ -0,0 +1,358 @@
import { useState } from "react";
import { auth } from "../api";
import type { UserInfo } from "../types";
import "./AccountPanel.css";
interface Props {
user: UserInfo;
onClose: () => void;
onRefresh: () => void;
}
export default function AccountPanel({ user, onClose, onRefresh }: Props) {
const discordConnection = user.connections.find(
(c) => c.provider === "discord",
);
// ── Profile editing ──
const [firstName, setFirstName] = useState(user.first_name ?? "");
const [lastName, setLastName] = useState(user.last_name ?? "");
const [profileDirty, setProfileDirty] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [profileError, setProfileError] = useState<string | null>(null);
const [profileSuccess, setProfileSuccess] = useState(false);
// ── Password change ──
const [pwCurrent, setPwCurrent] = useState("");
const [pwNew, setPwNew] = useState("");
const [pwConfirm, setPwConfirm] = useState("");
const [pwSaving, setPwSaving] = useState(false);
const [pwError, setPwError] = useState<string | null>(null);
const [pwSuccess, setPwSuccess] = useState(false);
const [showPasswordSection, setShowPasswordSection] = useState(false);
function handleFirstNameChange(v: string) {
setFirstName(v);
setProfileDirty(true);
setProfileSuccess(false);
}
function handleLastNameChange(v: string) {
setLastName(v);
setProfileDirty(true);
setProfileSuccess(false);
}
async function handleSaveProfile(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setProfileSaving(true);
setProfileError(null);
setProfileSuccess(false);
try {
await auth.updateProfile(firstName, lastName);
setProfileDirty(false);
setProfileSuccess(true);
await onRefresh();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setProfileError(
msg.replace(/^\d+:\s*/, "").trim() || "Failed to save profile",
);
} finally {
setProfileSaving(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPwError(null);
setPwSuccess(false);
if (pwNew !== pwConfirm) {
setPwError("Passwords do not match");
return;
}
if (pwNew.length < 8) {
setPwError("Password must be at least 8 characters");
return;
}
setPwSaving(true);
try {
await auth.changePassword(user.has_password ? pwCurrent : null, pwNew);
setPwCurrent("");
setPwNew("");
setPwConfirm("");
setPwSuccess(true);
setShowPasswordSection(false);
await onRefresh();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setPwError(
msg.replace(/^\d+:\s*/, "").trim() || "Failed to change password",
);
} finally {
setPwSaving(false);
}
}
async function handleConnectDiscord() {
try {
await auth.connectDiscord(window.location.origin + "/map");
} catch (err) {
console.error("Failed to connect Discord:", err);
}
}
async function handleDisconnectDiscord() {
if (!confirm("Disconnect your Discord account?")) return;
try {
await auth.disconnectProvider("discord");
await onRefresh();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
alert(
msg.replace(/^\d+:\s*/, "").trim() || "Failed to disconnect Discord",
);
}
}
async function handleLogout() {
await auth.logout();
}
return (
<div className="account-backdrop" onClick={onClose}>
<div className="account-panel" onClick={(e) => e.stopPropagation()}>
<div className="account-header">
<h2>Account</h2>
<button
className="account-close"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
{/* ── Profile ── */}
<section className="account-section">
<h3>Profile</h3>
<form onSubmit={handleSaveProfile} className="profile-form">
<div className="account-field readonly-field">
<span className="account-label">Username</span>
<span className="account-value">{user.username}</span>
</div>
<div className="profile-name-row">
<label className="account-field-label">
First Name
<input
type="text"
value={firstName}
onChange={(e) => handleFirstNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
<label className="account-field-label">
Last Name
<input
type="text"
value={lastName}
onChange={(e) => handleLastNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
</div>
{user.email && (
<div className="account-field readonly-field">
<span className="account-label">Email</span>
<span className="account-value">{user.email}</span>
</div>
)}
{profileError && <p className="account-error">{profileError}</p>}
{profileSuccess && (
<p className="account-success">Profile saved!</p>
)}
{profileDirty && (
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setFirstName(user.first_name ?? "");
setLastName(user.last_name ?? "");
setProfileDirty(false);
setProfileError(null);
}}
>
Cancel
</button>
<button
type="submit"
className="btn-save"
disabled={profileSaving}
>
{profileSaving ? "Saving…" : "Save"}
</button>
</div>
)}
</form>
</section>
{/* ── Password ── */}
<section className="account-section">
<div className="section-header-row">
<h3>{user.has_password ? "Password" : "Set Password"}</h3>
{!showPasswordSection && (
<button
className="btn-text"
onClick={() => {
setShowPasswordSection(true);
setPwError(null);
setPwSuccess(false);
}}
>
{user.has_password ? "Change" : "Set Password"}
</button>
)}
</div>
{pwSuccess && !showPasswordSection && (
<p className="account-success">Password updated successfully!</p>
)}
{showPasswordSection && (
<form onSubmit={handleChangePassword} className="password-form">
{user.has_password && (
<label className="account-field-label">
Current Password
<input
type="password"
value={pwCurrent}
onChange={(e) => setPwCurrent(e.target.value)}
autoComplete="current-password"
required
/>
</label>
)}
<label className="account-field-label">
New Password
<input
type="password"
value={pwNew}
onChange={(e) => setPwNew(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label className="account-field-label">
Confirm New Password
<input
type="password"
value={pwConfirm}
onChange={(e) => setPwConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
{pwError && <p className="account-error">{pwError}</p>}
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setShowPasswordSection(false);
setPwCurrent("");
setPwNew("");
setPwConfirm("");
setPwError(null);
}}
>
Cancel
</button>
<button type="submit" className="btn-save" disabled={pwSaving}>
{pwSaving
? "Saving…"
: user.has_password
? "Update Password"
: "Set Password"}
</button>
</div>
</form>
)}
</section>
{/* ── Connected services ── */}
<section className="account-section">
<h3>Connected Accounts</h3>
<div className="account-connection">
<svg
className="connection-icon discord-icon"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
<div className="connection-info">
<span className="connection-name">Discord</span>
{discordConnection ? (
<span className="connection-linked">
{discordConnection.provider_username ?? "Connected"}
</span>
) : (
<span className="connection-unlinked">Not connected</span>
)}
</div>
{discordConnection ? (
<button
className="btn-disconnect"
onClick={handleDisconnectDiscord}
disabled={!user.has_password}
title={
!user.has_password
? "Set a password first before disconnecting Discord"
: "Disconnect Discord"
}
>
Disconnect
</button>
) : (
<button
className="btn-connect-discord"
onClick={handleConnectDiscord}
>
Connect
</button>
)}
</div>
{discordConnection && !user.has_password && (
<p className="connection-hint">
Set a password above before disconnecting Discord to avoid being
locked out.
</p>
)}
</section>
{/* ── Footer ── */}
<div className="account-footer">
<button className="btn-logout" onClick={handleLogout}>
Log Out
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,7 +31,9 @@
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: transform 0.1s, border-color 0.1s;
transition:
transform 0.1s,
border-color 0.1s;
overflow: hidden;
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import './ColorPanel.css';
import { useEffect, useRef } from "react";
import "./ColorPanel.css";
interface Props {
colors: string[];
@@ -8,7 +8,12 @@ interface Props {
onColorsChange: (colors: string[]) => void;
}
export default function ColorPanel({ colors, activeColor, onColorChange, onColorsChange }: Props) {
export default function ColorPanel({
colors,
activeColor,
onColorChange,
onColorsChange,
}: Props) {
// One hidden color input ref per slot
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
@@ -16,14 +21,18 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
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;
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);
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [colors, onColorChange]);
function handleDoubleClick(index: number) {
@@ -44,7 +53,7 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
{colors.map((c, i) => (
<div key={i} className="cp-swatch-wrapper">
<button
className={`cp-swatch ${activeColor === c ? 'selected' : ''}`}
className={`cp-swatch ${activeColor === c ? "selected" : ""}`}
style={{ background: c }}
onClick={() => onColorChange(c)}
onDoubleClick={() => handleDoubleClick(i)}
@@ -54,10 +63,12 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
</button>
{/* Hidden color picker for this slot */}
<input
ref={el => { inputRefs.current[i] = el; }}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="color"
value={c}
onChange={e => handleColorEdit(i, e.target.value)}
onChange={(e) => handleColorEdit(i, e.target.value)}
className="cp-color-input"
tabIndex={-1}
/>

View File

@@ -23,7 +23,9 @@
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, border-color 0.12s;
transition:
background 0.12s,
border-color 0.12s;
}
.fp-tool-btn:hover {

View File

@@ -1,47 +1,85 @@
import { useEffect } from 'react';
import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from 'react-icons/md';
import type { Tool } from '../types';
import './ControlPanel.css';
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' },
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.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;
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);
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onToolChange]);
return (
<div className="floating-panel">
{TOOLS.map(t => (
{TOOLS.map((t) => (
<button
key={t.id}
className={`fp-tool-btn ${tool === t.id ? 'active' : ''}`}
className={`fp-tool-btn ${tool === t.id ? "active" : ""}`}
onClick={() => onToolChange(t.id)}
title={`${t.title} (${t.shortcut})`}
>

View File

@@ -0,0 +1,198 @@
.edit-map-modal {
width: 500px;
max-width: 92vw;
max-height: 88vh;
overflow-y: auto;
}
.edit-map-form {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-bottom: 0.5rem;
}
/* ── Section dividers ── */
.edit-section {
border-top: 1px solid #2e3348;
padding-top: 1rem;
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.edit-section h3 {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
}
.edit-loading {
font-size: 0.82rem;
color: #6b7280;
margin: 0;
}
/* ── Permission list ── */
.perm-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.perm-row {
display: flex;
align-items: center;
gap: 0.6rem;
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
padding: 0.45rem 0.75rem;
}
.perm-username {
flex: 1;
font-size: 0.85rem;
color: #e2e8f0;
}
.perm-role-badge {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.5rem;
border-radius: 4px;
}
.role-owner {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
}
.role-editor {
background: rgba(234, 179, 8, 0.15);
color: #fbbf24;
}
.role-viewer {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.perm-remove {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
font-size: 0.8rem;
padding: 0.1rem 0.3rem;
line-height: 1;
transition: color 0.12s;
}
.perm-remove:hover {
color: #ef4444;
}
.perm-empty {
font-size: 0.8rem;
color: #4b5563;
margin: 0;
}
/* ── Add permission form ── */
.add-perm-form {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
}
.add-perm-input {
flex: 1;
min-width: 120px;
background: #111827;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.35rem 0.6rem;
font-size: 0.82rem;
outline: none;
}
.add-perm-input:focus {
border-color: #6366f1;
}
.add-perm-role {
background: #1f2937;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.35rem 0.6rem;
font-size: 0.82rem;
outline: none;
cursor: pointer;
}
/* ── Access request list ── */
.req-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.req-row {
display: flex;
align-items: center;
gap: 0.6rem;
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
padding: 0.45rem 0.75rem;
}
.req-username {
flex: 1;
font-size: 0.85rem;
color: #e2e8f0;
}
.req-actions {
display: flex;
gap: 0.35rem;
}
.btn-approve {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 5px;
color: #34d399;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.55rem;
transition: background 0.12s;
}
.btn-approve:hover {
background: rgba(16, 185, 129, 0.25);
}
.btn-deny {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: 5px;
color: #f87171;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.55rem;
transition: background 0.12s;
}
.btn-deny:hover {
background: rgba(239, 68, 68, 0.2);
}
/* ── Small variant of primary button ── */
.btn-sm {
padding: 0.35rem 0.75rem !important;
font-size: 0.8rem !important;
}

View File

@@ -0,0 +1,326 @@
import { useState, useEffect } from "react";
import { api } from "../api";
import type {
GridMap,
ListedMap,
MapAccessRequest,
MapPermission,
MapRole,
PublicAccess,
} from "../types";
import "./EditMapModal.css";
interface Props {
map: GridMap | ListedMap;
onClose: () => void;
onUpdated: (updated: GridMap) => void;
}
export default function EditMapModal({ map, onClose, onUpdated }: Props) {
const [name, setName] = useState(map.name);
const [publicAccess, setPublicAccess] = useState<PublicAccess>(
map.public_access,
);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Permissions
const [permissions, setPermissions] = useState<MapPermission[]>([]);
const [permsLoading, setPermsLoading] = useState(true);
// Add permission
const [addUsername, setAddUsername] = useState("");
const [addRole, setAddRole] = useState<"editor" | "viewer">("viewer");
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
// Access requests
const [requests, setRequests] = useState<MapAccessRequest[]>([]);
const [reqsLoading, setReqsLoading] = useState(true);
useEffect(() => {
loadPermissions();
loadRequests();
}, [map.id]); // eslint-disable-line react-hooks/exhaustive-deps
async function loadPermissions() {
setPermsLoading(true);
try {
const perms = await api.listPermissions(map.id);
setPermissions(perms);
} catch {
// silent
} finally {
setPermsLoading(false);
}
}
async function loadRequests() {
setReqsLoading(true);
try {
const reqs = await api.listAccessRequests(map.id);
setRequests(reqs);
} catch {
// silent — might just not be owner
} finally {
setReqsLoading(false);
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setSaving(true);
setSaveError(null);
try {
const updated = await api.updateMap(map.id, {
name: trimmed,
public_access: publicAccess,
});
onUpdated(updated);
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setSaveError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to save");
} finally {
setSaving(false);
}
}
async function handleRemovePermission(username: string) {
try {
await api.updatePermission(map.id, username, null);
setPermissions((prev) => prev.filter((p) => p.username !== username));
} catch (err) {
console.error("Failed to remove permission", err);
}
}
async function handleAddPermission(e: React.FormEvent) {
e.preventDefault();
const username = addUsername.trim();
if (!username) return;
setAddLoading(true);
setAddError(null);
try {
await api.updatePermission(map.id, username, addRole as MapRole);
setAddUsername("");
await loadPermissions();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setAddError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to add user");
} finally {
setAddLoading(false);
}
}
async function handleResolveRequest(
requestId: string,
action: "approve" | "deny",
) {
try {
await api.resolveAccessRequest(map.id, requestId, action);
setRequests((prev) => prev.filter((r) => r.id !== requestId));
if (action === "approve") {
await loadPermissions();
}
} catch (err) {
console.error("Failed to resolve request", err);
}
}
const nonOwnerPerms = permissions.filter((p) => p.role !== "owner");
return (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal edit-map-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>Edit Map</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
</div>
{/* ── Map settings ── */}
<form onSubmit={handleSave} className="edit-map-form">
<label className="field-label">
Map Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
required
/>
</label>
<fieldset className="public-access-fieldset">
<legend>Visibility</legend>
<label className="radio-option">
<input
type="radio"
name="edit_public_access"
value="private"
checked={publicAccess === "private"}
onChange={() => setPublicAccess("private")}
/>
<span className="radio-label">
<strong>Private</strong>
<span className="radio-hint">Only you and invited users</span>
</span>
</label>
<label className="radio-option">
<input
type="radio"
name="edit_public_access"
value="public_view"
checked={publicAccess === "public_view"}
onChange={() => setPublicAccess("public_view")}
/>
<span className="radio-label">
<strong>Public View Only</strong>
<span className="radio-hint">
Anyone with the link can view
</span>
</span>
</label>
<label className="radio-option">
<input
type="radio"
name="edit_public_access"
value="public_edit"
checked={publicAccess === "public_edit"}
onChange={() => setPublicAccess("public_edit")}
/>
<span className="radio-label">
<strong>Public View &amp; Edit</strong>
<span className="radio-hint">
Anyone with the link can view and edit
</span>
</span>
</label>
</fieldset>
{saveError && <p className="form-error">{saveError}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={!name.trim() || saving}
>
{saving ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
{/* ── Permissions ── */}
<section className="edit-section">
<h3>Permissions</h3>
{permsLoading ? (
<p className="edit-loading">Loading</p>
) : (
<div className="perm-list">
{permissions.map((p) => (
<div key={p.user_id} className="perm-row">
<span className="perm-username">{p.username}</span>
<span className={`perm-role-badge role-${p.role}`}>
{p.role}
</span>
{p.role !== "owner" && (
<button
className="perm-remove"
onClick={() => handleRemovePermission(p.username)}
title={`Remove ${p.username}`}
>
</button>
)}
</div>
))}
{nonOwnerPerms.length === 0 &&
permissions.filter((p) => p.role === "owner").length > 0 && (
<p className="perm-empty">No editors or viewers yet</p>
)}
</div>
)}
{/* Add user */}
<form className="add-perm-form" onSubmit={handleAddPermission}>
<input
type="text"
placeholder="Username…"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
className="add-perm-input"
/>
<select
value={addRole}
onChange={(e) =>
setAddRole(e.target.value as "editor" | "viewer")
}
className="add-perm-role"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
<button
type="submit"
className="btn-primary btn-sm"
disabled={!addUsername.trim() || addLoading}
>
{addLoading ? "…" : "Add"}
</button>
</form>
{addError && (
<p className="form-error" style={{ marginTop: "0.4rem" }}>
{addError}
</p>
)}
</section>
{/* ── Access Requests ── */}
{!reqsLoading && requests.length > 0 && (
<section className="edit-section">
<h3>Pending Access Requests</h3>
<div className="req-list">
{requests.map((r) => (
<div key={r.id} className="req-row">
<span className="req-username">{r.username}</span>
<span className={`perm-role-badge role-${r.requested_role}`}>
{r.requested_role}
</span>
<div className="req-actions">
<button
className="btn-approve"
onClick={() => handleResolveRequest(r.id, "approve")}
>
Approve
</button>
<button
className="btn-deny"
onClick={() => handleResolveRequest(r.id, "deny")}
>
Deny
</button>
</div>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
.floating-map-controls {
position: absolute;
top: 14px;
left: 14px;
display: flex;
align-items: center;
gap: 0.4rem;
z-index: 20;
}
.fmc-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: rgba(17, 24, 39, 0.88);
border: 1px solid #374151;
border-radius: 6px;
color: #d1d5db;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
padding: 0.35rem 0.7rem;
line-height: 1.4;
white-space: nowrap;
backdrop-filter: blur(6px);
transition:
background 0.12s,
border-color 0.12s,
color 0.12s;
}
.fmc-btn:hover {
background: rgba(55, 65, 81, 0.95);
border-color: #6b7280;
color: #f3f4f6;
}
.fmc-btn-primary {
background: rgba(99, 102, 241, 0.85);
border-color: #6366f1;
color: #fff;
}
.fmc-btn-primary:hover {
background: rgba(79, 70, 229, 0.95);
border-color: #4f46e5;
color: #fff;
}
.fmc-btn-danger:hover {
background: rgba(127, 29, 29, 0.9);
border-color: #ef4444;
color: #fca5a5;
}

View File

@@ -0,0 +1,75 @@
import "./FloatingMapControls.css";
interface Props {
isLoggedIn: boolean;
hasSelectedMap: boolean;
isOwner: boolean;
onNewMap: () => void;
onViewMaps: () => void;
onEditMap: () => void;
onDeleteMap: () => void;
}
export default function FloatingMapControls({
isLoggedIn,
hasSelectedMap,
isOwner,
onNewMap,
onViewMaps,
onEditMap,
onDeleteMap,
}: Props) {
if (!isLoggedIn) return null;
return (
<div className="floating-map-controls">
{/* Always visible for logged-in users */}
<button className="fmc-btn" onClick={onViewMaps} title="View my maps">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
Maps
</button>
<button
className="fmc-btn fmc-btn-primary"
onClick={onNewMap}
title="Create a new map"
>
+ New Map
</button>
{/* Owner-only actions — only when a map is selected */}
{hasSelectedMap && isOwner && (
<>
<button
className="fmc-btn"
onClick={onEditMap}
title="Edit map settings"
>
Edit Map
</button>
<button
className="fmc-btn fmc-btn-danger"
onClick={onDeleteMap}
title="Delete this map"
>
Delete
</button>
</>
)}
</div>
);
}

View File

@@ -1,11 +1,21 @@
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';
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
@@ -16,9 +26,9 @@ 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)';
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;
@@ -56,14 +66,22 @@ function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
function canvasToCell(cx: number, cy: number, cam: Camera): { x: number; y: number } {
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 } {
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,
@@ -84,7 +102,7 @@ function drawToken(
const cy = py + zoom / 2;
const r = zoom * 0.38;
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.shadowBlur = 5;
ctx.beginPath();
@@ -92,7 +110,7 @@ function drawToken(
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.strokeStyle = "rgba(255,255,255,0.55)";
ctx.lineWidth = Math.max(1, zoom * 0.04);
ctx.stroke();
@@ -104,10 +122,10 @@ function drawToken(
words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase()
: label.slice(0, 2).toUpperCase();
ctx.fillStyle = '#ffffff';
ctx.fillStyle = "#ffffff";
ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(initials, cx, cy);
}
}
@@ -133,8 +151,10 @@ function floodFill(
visited.add(cellKey(startX, startY));
const dirs = [
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 },
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
];
while (queue.length > 0) {
@@ -170,9 +190,9 @@ function clampCameraToContent(
) {
if (cells.size === 0 && tokens.size === 0) return;
const viewLeft = cam.offsetX;
const viewRight = cam.offsetX + canvasW / cam.zoom;
const viewTop = cam.offsetY;
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
@@ -180,8 +200,10 @@ function clampCameraToContent(
cellLoop: for (const cell of cells.values()) {
if (
cell.x + 1 > viewLeft && cell.x < viewRight &&
cell.y + 1 > viewTop && cell.y < viewBottom
cell.x + 1 > viewLeft &&
cell.x < viewRight &&
cell.y + 1 > viewTop &&
cell.y < viewBottom
) {
anyVisible = true;
break cellLoop;
@@ -191,8 +213,10 @@ function clampCameraToContent(
if (!anyVisible) {
for (const tok of tokens.values()) {
if (
tok.x + 1 > viewLeft && tok.x < viewRight &&
tok.y + 1 > viewTop && tok.y < viewBottom
tok.x + 1 > viewLeft &&
tok.x < viewRight &&
tok.y + 1 > viewTop &&
tok.y < viewBottom
) {
anyVisible = true;
break;
@@ -203,8 +227,10 @@ function clampCameraToContent(
if (anyVisible) return;
// Find the bounding box of all content
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
let minX = Infinity,
maxX = -Infinity;
let minY = Infinity,
maxY = -Infinity;
for (const c of cells.values()) {
if (c.x < minX) minX = c.x;
@@ -248,43 +274,54 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cameraRef = useRef<Camera>({ offsetX: -2, offsetY: -2, zoom: DEFAULT_ZOOM });
const cameraRef = useRef<Camera>({
offsetX: -2,
offsetY: -2,
zoom: DEFAULT_ZOOM,
});
const cellsRef = useRef<Map<string, GridCell>>(new Map());
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), []);
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);
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 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);
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 });
sendRef.current({ type: "update_colors", colors });
},
}));
@@ -293,11 +330,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// -------------------------------------------------------------------------
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const resize = () => {
canvas.width = container.clientWidth;
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
redraw();
};
@@ -313,77 +350,92 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// -------------------------------------------------------------------------
// Keep a stable ref to the callback so handleMessage doesn't re-create
const onColorsLoadedRef = useRef(onColorsLoaded);
useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; }, [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);
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: c.x, y: c.y, color: c.color,
x: msg.x,
y: msg.y,
color: msg.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;
}
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;
}
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]);
},
[mapId, redraw],
);
const { send } = useWebSocket(mapId, handleMessage);
useEffect(() => { sendRef.current = send; }, [send]);
useEffect(() => {
sendRef.current = send;
}, [send]);
// -------------------------------------------------------------------------
// Canvas draw
@@ -391,11 +443,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
const W = canvas.width;
const H = canvas.height;
const cam = cameraRef.current;
const { offsetX, offsetY, zoom } = cam;
@@ -403,13 +455,19 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
ctx.fillRect(0, 0, W, H);
const startCX = Math.floor(offsetX) - 1;
const endCX = Math.ceil(offsetX + W / zoom) + 1;
const endCX = Math.ceil(offsetX + W / zoom) + 1;
const startCY = Math.floor(offsetY) - 1;
const endCY = Math.ceil(offsetY + H / zoom) + 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;
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);
@@ -437,7 +495,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
}
// Tokens (skip the one being dragged)
tokensRef.current.forEach(token => {
tokensRef.current.forEach((token) => {
if (isDragging.current && dragTokenId.current === token.id) return;
drawToken(ctx, token.x, token.y, token.label, token.color, cam);
});
@@ -447,7 +505,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
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);
drawToken(
ctx,
dragCellPos.current.x,
dragCellPos.current.y,
tok.label,
tok.color,
cam,
);
ctx.globalAlpha = 1;
}
}
@@ -460,21 +525,24 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const canvas = canvasRef.current;
if (canvas) {
clampCameraToContent(
cameraRef.current, cellsRef.current, tokensRef.current,
canvas.width, canvas.height,
cameraRef.current,
cellsRef.current,
tokensRef.current,
canvas.width,
canvas.height,
);
}
redraw();
}
function applyZoom(canvasX: number, canvasY: number, factor: number) {
const cam = cameraRef.current;
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;
cam.zoom = newZoom;
applyClampAndRedraw();
}
@@ -486,14 +554,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
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 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);
canvas.addEventListener("wheel", onWheel, { passive: false });
return () => canvas.removeEventListener("wheel", onWheel);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
@@ -508,34 +576,45 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
return;
}
const dt = lastFrameTime.current !== null
? (timestamp - lastFrameTime.current) / 1000
: 0;
const dt =
lastFrameTime.current !== null
? (timestamp - lastFrameTime.current) / 1000
: 0;
lastFrameTime.current = timestamp;
const cam = cameraRef.current;
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;
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);
clampCameraToContent(
cam,
cellsRef.current,
tokensRef.current,
canvas.width,
canvas.height,
);
}
setTick(n => n + 1);
setTick((n) => n + 1);
rafId.current = requestAnimationFrame(rafTick);
}
function onKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
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)) {
if (["w", "a", "s", "d"].includes(key)) {
e.preventDefault();
keysHeld.current.add(key);
if (rafId.current === null) {
@@ -549,11 +628,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
keysHeld.current.delete(e.key.toLowerCase());
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
if (rafId.current !== null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
@@ -585,22 +664,27 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
const cell = canvasToCell(mx, my, cameraRef.current);
// ---- Pan tool ----
if (tool === 'pan' && e.button === 0) {
if (tool === "pan" && e.button === 0) {
isPanning.current = true;
panStart.current = { mx, my, ox: cameraRef.current.offsetX, oy: cameraRef.current.offsetY };
setCursor('grabbing');
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);
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 (tool === "draw") {
if (e.button === 0) {
if (e.shiftKey) {
// Shift+click → flood fill uncolored region
@@ -609,42 +693,52 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
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 });
sendRef.current({
type: "paint_cell",
x: cell.x,
y: cell.y,
color: paintColor,
});
} else {
// Bounded enclosed region → batch paint
sendRef.current({
type: 'paint_cells',
type: "paint_cells",
cells: region.map(({ x, y }) => ({ x, y, color: paintColor })),
});
}
}
} else {
isDrawing.current = true;
isDrawing.current = true;
lastPainted.current = cellKey(cell.x, cell.y);
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
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);
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 });
sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y });
}
}
return;
}
// ---- Token tool ----
if (tool === 'token') {
if (tool === "token") {
if (e.button === 2) {
const tok = tokenAtCell(cell.x, cell.y);
if (tok) sendRef.current({ type: 'delete_token', id: tok.id });
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;
isDragging.current = true;
dragTokenId.current = tok.id;
dragCellPos.current = { x: cell.x, y: cell.y };
redraw();
@@ -661,8 +755,8 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// 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;
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();
@@ -672,13 +766,18 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// Draw / erase stroke
if (isDrawing.current || isErasing.current) {
const cell = canvasToCell(mx, my, cameraRef.current);
const key = cellKey(cell.x, cell.y);
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 });
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 });
sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y });
}
}
return;
@@ -687,7 +786,10 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// 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) {
if (
dragCellPos.current.x !== cell.x ||
dragCellPos.current.y !== cell.y
) {
dragCellPos.current = { x: cell.x, y: cell.y };
redraw();
}
@@ -697,29 +799,32 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
function handleMouseUp(_e: React.MouseEvent) {
if (isPanning.current) {
isPanning.current = false;
panStart.current = null;
setCursor('grab');
panStart.current = null;
setCursor("grab");
return;
}
if (isDrawing.current || isErasing.current) {
isDrawing.current = false;
isErasing.current = false;
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)) {
if (
tok &&
(tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)
) {
sendRef.current({
type: 'move_token',
type: "move_token",
id: dragTokenId.current,
x: dragCellPos.current.x,
y: dragCellPos.current.y,
});
}
isDragging.current = false;
isDragging.current = false;
dragTokenId.current = null;
dragCellPos.current = null;
redraw();
@@ -727,13 +832,13 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
}
function handleMouseLeave() {
isPanning.current = false;
panStart.current = null;
isDrawing.current = false;
isErasing.current = false;
isPanning.current = false;
panStart.current = null;
isDrawing.current = false;
isErasing.current = false;
lastPainted.current = null;
if (isDragging.current) {
isDragging.current = false;
isDragging.current = false;
dragTokenId.current = null;
dragCellPos.current = null;
redraw();
@@ -743,16 +848,30 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
// 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;
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 });
sendRef.current({
type: "add_token",
x: dialogPos.x,
y: dialogPos.y,
label,
color,
});
setDialogPos(null);
}
@@ -766,7 +885,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onContextMenu={e => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
/>
{dialogPos && (
<TokenDialog

View File

@@ -0,0 +1,48 @@
.app-header {
flex-shrink: 0;
height: 48px;
display: grid;
grid-template-columns: auto 1fr auto;
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;
}
.app-brand span {
color: #818cf8;
}
.app-header-center {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.header-map-name {
font-size: 0.9rem;
font-weight: 600;
color: #e5e7eb;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
}
.app-auth {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}

View File

@@ -0,0 +1,51 @@
import type { UserInfo } from "../types";
import LoginButton from "./LoginButton";
import "./Header.css";
interface Props {
user: UserInfo | null;
authLoading: boolean;
selectedMapName: string | null;
onLoginClick: () => void;
onAccountClick: () => void;
}
export default function Header({
user,
authLoading,
selectedMapName,
onLoginClick,
onAccountClick,
}: Props) {
/** Display name: first name if set, otherwise username */
const displayName = user ? user.first_name?.trim() || user.username : null;
return (
<header className="app-header">
<div className="app-brand">
<span>SIREN</span>
</div>
<div className="app-header-center">
{selectedMapName && (
<span className="header-map-name">{selectedMapName}</span>
)}
</div>
<div className="app-auth">
{!authLoading &&
(user ? (
<button
className="header-btn"
onClick={onAccountClick}
title="Account settings"
>
{displayName}
</button>
) : (
<LoginButton className="header-btn" onClick={onLoginClick} />
))}
</div>
</header>
);
}

View File

@@ -1,21 +1,13 @@
import { auth } from '../api';
interface Props {
className?: string;
onClick: () => void;
}
export default function LoginButton({ className }: Props) {
async function handleLogin() {
try {
await auth.login();
} catch (err) {
console.error('Failed to initiate login:', err);
}
}
/** A simple button that opens the login modal when clicked. */
export default function LoginButton({ className, onClick }: Props) {
return (
<button className={className} onClick={handleLogin}>
Log in with Discord
<button className={className} onClick={onClick}>
Log In / Register
</button>
);
}

View File

@@ -0,0 +1,159 @@
/* ── Backdrop ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* ── Modal card ── */
.modal {
background: #1e2130;
border: 1px solid #2e3348;
border-radius: 10px;
padding: 2rem;
width: 360px;
max-width: 90vw;
position: relative;
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
color: #8892a4;
font-size: 1rem;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.modal-close:hover {
color: #e2e8f0;
}
/* ── Tabs ── */
.modal-tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #2e3348;
margin-bottom: 0.25rem;
}
.modal-tab {
flex: 1;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #8892a4;
cursor: pointer;
font-size: 0.9rem;
padding: 0.5rem;
transition:
color 0.15s,
border-color 0.15s;
}
.modal-tab.active {
color: #e2e8f0;
border-bottom-color: #5865f2;
}
.modal-tab:hover:not(.active) {
color: #cbd5e1;
}
/* ── Form ── */
.modal-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.modal-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: #8892a4;
font-size: 0.8rem;
}
.modal-form input {
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
outline: none;
}
.modal-form input:focus {
border-color: #5865f2;
}
.modal-error {
color: #f87171;
font-size: 0.8rem;
margin: 0;
}
/* ── Buttons ── */
.btn-primary {
background: #5865f2;
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
padding: 0.6rem;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: #4752c4;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Divider ── */
.modal-divider {
display: flex;
align-items: center;
gap: 0.75rem;
color: #4a5568;
font-size: 0.75rem;
}
.modal-divider::before,
.modal-divider::after {
content: "";
flex: 1;
height: 1px;
background: #2e3348;
}
/* ── Discord button ── */
.btn-discord {
align-items: center;
background: #5865f2;
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
display: flex;
font-size: 0.9rem;
font-weight: 600;
gap: 0.5rem;
justify-content: center;
padding: 0.6rem;
transition: background 0.15s;
}
.btn-discord:hover {
background: #4752c4;
}

View File

@@ -0,0 +1,162 @@
import { useState } from "react";
import { auth } from "../api";
import type { UserInfo } from "../types";
import "./LoginModal.css";
interface Props {
onClose: () => void;
onLogin: (user: UserInfo) => void;
}
type Tab = "login" | "register";
export default function LoginModal({ onClose, onLogin }: Props) {
const [tab, setTab] = useState<Tab>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (tab === "register" && password !== confirm) {
setError("Passwords do not match");
return;
}
setLoading(true);
try {
if (tab === "login") {
await auth.loginLocal(username, password);
} else {
await auth.register(username, password);
}
// Cookie is now set server-side; fetch user info to update parent
const user = await auth.me();
if (user) {
onLogin(user);
onClose();
} else {
setError("Login succeeded but could not load user info.");
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
// Extract the human-readable part (strip leading status code)
setError(
msg
.replace(/^\d+:\s*/, "")
.replace(/\{.*\}/s, "")
.trim() || "Authentication failed",
);
} finally {
setLoading(false);
}
}
async function handleDiscordLogin() {
try {
await auth.loginDiscord(window.location.origin + "/map");
} catch (err) {
console.error("Discord login failed:", err);
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
{/* Tab switcher */}
<div className="modal-tabs">
<button
className={`modal-tab ${tab === "login" ? "active" : ""}`}
onClick={() => {
setTab("login");
setError(null);
}}
>
Log In
</button>
<button
className={`modal-tab ${tab === "register" ? "active" : ""}`}
onClick={() => {
setTab("register");
setError(null);
}}
>
Register
</button>
</div>
{/* Username / password form */}
<form className="modal-form" onSubmit={handleSubmit}>
<label>
Username
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
minLength={1}
maxLength={32}
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={
tab === "login" ? "current-password" : "new-password"
}
required
minLength={8}
/>
</label>
{tab === "register" && (
<label>
Confirm Password
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
)}
{error && <p className="modal-error">{error}</p>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading
? "Loading…"
: tab === "login"
? "Log In"
: "Create Account"}
</button>
</form>
<div className="modal-divider">
<span>or</span>
</div>
{/* Discord OAuth */}
<button className="btn-discord" onClick={handleDiscordLogin}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
Log In with Discord
</button>
</div>
</div>
);
}

View File

@@ -1,127 +0,0 @@
.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

@@ -1,70 +0,0 @@
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,183 @@
.map-list-modal {
width: 540px;
max-width: 92vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.map-list-empty {
font-size: 0.85rem;
color: #6b7280;
text-align: center;
padding: 1.5rem 0;
margin: 0;
}
.map-list-scroll {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.5rem;
padding-right: 2px; /* room for scrollbar */
}
.map-list-row {
display: flex;
align-items: center;
gap: 0.75rem;
background: #141622;
border: 1px solid #2e3348;
border-radius: 8px;
padding: 0.6rem 0.85rem;
cursor: pointer;
transition:
background 0.1s,
border-color 0.1s;
outline: none;
}
.map-list-row:hover {
background: #1a1d2e;
border-color: #4b5563;
}
.map-list-row.active {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.08);
}
.map-list-row:focus-visible {
box-shadow: 0 0 0 2px #6366f1;
}
.map-list-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.map-list-name {
font-size: 0.9rem;
font-weight: 600;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-list-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.map-list-owner {
font-size: 0.75rem;
color: #6b7280;
}
.map-access-badge {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
.access-private {
background: rgba(75, 85, 99, 0.3);
color: #9ca3af;
}
.access-public_view {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
}
.access-public_edit {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.map-fav-badge {
font-size: 0.68rem;
color: #f59e0b;
}
/* Role badge reused from EditMapModal */
.perm-role-badge {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.5rem;
border-radius: 4px;
}
.role-owner {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
}
.role-editor {
background: rgba(234, 179, 8, 0.15);
color: #fbbf24;
}
.role-viewer {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
/* ── Row action buttons ── */
.map-list-actions {
display: flex;
align-items: center;
gap: 0.3rem;
flex-shrink: 0;
}
.map-action-btn {
background: none;
border: 1px solid transparent;
border-radius: 5px;
color: #6b7280;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 0.3rem 0.4rem;
transition:
color 0.12s,
border-color 0.12s,
background 0.12s;
display: flex;
align-items: center;
justify-content: center;
}
.map-action-btn:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.05);
border-color: #4b5563;
}
.map-action-btn:disabled {
opacity: 0.4;
cursor: default;
}
.fav-btn.fav-active {
color: #f59e0b;
}
.fav-btn.fav-active:hover {
color: #fbbf24;
}
.copy-btn {
color: #6b7280;
}
.copied-text {
color: #34d399;
font-size: 0.85rem;
}

View File

@@ -0,0 +1,183 @@
import { useState } from "react";
import { api } from "../api";
import type { ListedMap } from "../types";
import "./MapListModal.css";
interface Props {
maps: ListedMap[];
selectedMapId: string | null;
onSelect: (id: string) => void;
onClose: () => void;
onMapsChange: (maps: ListedMap[]) => void;
}
/** Copy text to the clipboard; show a brief "Copied!" toast. */
function copyToClipboard(
text: string,
setCopied: (id: string | null) => void,
id: string,
) {
navigator.clipboard.writeText(text).then(() => {
setCopied(id);
setTimeout(() => setCopied(null), 1500);
});
}
function accessLabel(access: string): string {
switch (access) {
case "public_view":
return "Public (view)";
case "public_edit":
return "Public (edit)";
default:
return "Private";
}
}
export default function MapListModal({
maps,
selectedMapId,
onSelect,
onClose,
onMapsChange,
}: Props) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
async function handleFavoriteToggle(e: React.MouseEvent, map: ListedMap) {
e.stopPropagation();
setTogglingId(map.id);
try {
if (map.is_favorited) {
await api.unfavoriteMap(map.id);
} else {
await api.favoriteMap(map.id);
}
onMapsChange(
maps.map((m) =>
m.id === map.id ? { ...m, is_favorited: !m.is_favorited } : m,
),
);
} catch (err) {
console.error("Failed to toggle favorite", err);
} finally {
setTogglingId(null);
}
}
function handleCopyLink(e: React.MouseEvent, map: ListedMap) {
e.stopPropagation();
const link = `${window.location.origin}/map/${encodeURIComponent(map.id)}`;
copyToClipboard(link, setCopiedId, map.id);
}
function handleSelect(map: ListedMap) {
onSelect(map.id);
onClose();
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal map-list-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>My Maps</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
</div>
{maps.length === 0 ? (
<p className="map-list-empty">
No maps yet. Click "+ New Map" to create one.
</p>
) : (
<div className="map-list-scroll">
{maps.map((map) => (
<div
key={map.id}
className={`map-list-row ${map.id === selectedMapId ? "active" : ""}`}
onClick={() => handleSelect(map)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && handleSelect(map)}
>
<div className="map-list-main">
<span className="map-list-name">{map.name}</span>
<div className="map-list-meta">
<span className="map-list-owner">
by {map.owner_username}
</span>
<span
className={`map-access-badge access-${map.public_access}`}
>
{accessLabel(map.public_access)}
</span>
{map.user_role && (
<span className={`perm-role-badge role-${map.user_role}`}>
{map.user_role}
</span>
)}
{map.is_favorited && !map.user_role && (
<span className="map-fav-badge"> Favorited</span>
)}
</div>
</div>
<div className="map-list-actions">
{/* Favorite toggle */}
<button
className={`map-action-btn fav-btn ${map.is_favorited ? "fav-active" : ""}`}
onClick={(e) => handleFavoriteToggle(e, map)}
disabled={togglingId === map.id}
title={
map.is_favorited
? "Remove from favorites"
: "Add to favorites"
}
>
{map.is_favorited ? "★" : "☆"}
</button>
{/* Copy link */}
<button
className="map-action-btn copy-btn"
onClick={(e) => handleCopyLink(e, map)}
title="Copy link"
>
{copiedId === map.id ? (
<span className="copied-text"></span>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

108
ui/src/components/Modal.css Normal file
View File

@@ -0,0 +1,108 @@
/* ── Shared modal primitives used across all modal components ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
padding: 1rem;
}
.modal {
background: #1e2130;
border: 1px solid #2e3348;
border-radius: 10px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 0.25rem;
}
.modal-header h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
color: #e2e8f0;
}
.modal-close {
background: none;
border: none;
color: #6b7280;
font-size: 1rem;
cursor: pointer;
line-height: 1;
padding: 0.25rem 0.4rem;
border-radius: 4px;
transition:
color 0.12s,
background 0.12s;
}
.modal-close:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.06);
}
/* ── Shared button styles ── */
.btn-primary {
background: #6366f1;
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
padding: 0.4rem 1rem;
transition: background 0.12s;
white-space: nowrap;
}
.btn-primary:hover {
background: #4f46e5;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-secondary {
background: #374151;
border: 1px solid #4b5563;
border-radius: 6px;
color: #d1d5db;
cursor: pointer;
font-size: 0.85rem;
padding: 0.4rem 1rem;
transition: background 0.12s;
white-space: nowrap;
}
.btn-secondary:hover {
background: #4b5563;
}
/* ── Shared header button ── */
.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;
}

View File

@@ -0,0 +1,103 @@
.new-map-modal {
width: 440px;
max-width: 92vw;
}
.new-map-form-modal {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-top: 0.25rem;
}
.field-label {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.82rem;
color: #9ca3af;
font-weight: 500;
}
.field-label input {
background: #111827;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.4rem 0.65rem;
font-size: 0.9rem;
outline: none;
transition: border-color 0.12s;
}
.field-label input:focus {
border-color: #6366f1;
}
/* ── Visibility fieldset ── */
.public-access-fieldset {
border: 1px solid #374151;
border-radius: 8px;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.public-access-fieldset legend {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #6b7280;
padding: 0 0.3rem;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 0.6rem;
cursor: pointer;
}
.radio-option input[type="radio"] {
accent-color: #6366f1;
margin-top: 3px;
flex-shrink: 0;
cursor: pointer;
}
.radio-label {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.radio-label strong {
font-size: 0.85rem;
color: #e2e8f0;
font-weight: 500;
}
.radio-hint {
font-size: 0.75rem;
color: #6b7280;
}
/* ── Shared form error ── */
.form-error {
margin: 0;
font-size: 0.82rem;
color: #f87171;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: 6px;
padding: 0.4rem 0.7rem;
}
/* ── Modal actions row ── */
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
padding-top: 0.25rem;
}

View File

@@ -0,0 +1,130 @@
import { useState, useRef, useEffect } from "react";
import type { PublicAccess } from "../types";
import "./NewMapModal.css";
interface Props {
onClose: () => void;
onCreate: (name: string, publicAccess: PublicAccess) => Promise<void>;
}
export default function NewMapModal({ onClose, onCreate }: Props) {
const [name, setName] = useState("");
const [publicAccess, setPublicAccess] = useState<PublicAccess>("private");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
nameRef.current?.focus();
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setLoading(true);
setError(null);
try {
await onCreate(trimmed, publicAccess);
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to create map");
} finally {
setLoading(false);
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal new-map-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>New Map</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
</div>
<form onSubmit={handleSubmit} className="new-map-form-modal">
<label className="field-label">
Map Name
<input
ref={nameRef}
type="text"
placeholder="My awesome map…"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
required
/>
</label>
<fieldset className="public-access-fieldset">
<legend>Visibility</legend>
<label className="radio-option">
<input
type="radio"
name="public_access"
value="private"
checked={publicAccess === "private"}
onChange={() => setPublicAccess("private")}
/>
<span className="radio-label">
<strong>Private</strong>
<span className="radio-hint">Only you and invited users</span>
</span>
</label>
<label className="radio-option">
<input
type="radio"
name="public_access"
value="public_view"
checked={publicAccess === "public_view"}
onChange={() => setPublicAccess("public_view")}
/>
<span className="radio-label">
<strong>Public View Only</strong>
<span className="radio-hint">
Anyone with the link can view
</span>
</span>
</label>
<label className="radio-option">
<input
type="radio"
name="public_access"
value="public_edit"
checked={publicAccess === "public_edit"}
onChange={() => setPublicAccess("public_edit")}
/>
<span className="radio-label">
<strong>Public View &amp; Edit</strong>
<span className="radio-hint">
Anyone with the link can view and edit
</span>
</span>
</label>
</fieldset>
{error && <p className="form-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={!name.trim() || loading}
>
{loading ? "Creating…" : "Create Map"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -38,7 +38,7 @@
color: #9ca3af;
}
.dialog label input[type='text'] {
.dialog label input[type="text"] {
background: #111827;
border: 1px solid #4b5563;
border-radius: 5px;
@@ -48,11 +48,11 @@
outline: none;
}
.dialog label input[type='text']:focus {
.dialog label input[type="text"]:focus {
border-color: #6366f1;
}
.dialog label input[type='color'] {
.dialog label input[type="color"] {
width: 48px;
height: 32px;
padding: 0;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import './TokenDialog.css';
import { useState, useEffect, useRef } from "react";
import "./TokenDialog.css";
interface Props {
defaultColor: string;
@@ -7,8 +7,12 @@ interface Props {
onCancel: () => void;
}
export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props) {
const [label, setLabel] = useState('');
export default function TokenDialog({
defaultColor,
onConfirm,
onCancel,
}: Props) {
const [label, setLabel] = useState("");
const [color, setColor] = useState(defaultColor);
const inputRef = useRef<HTMLInputElement>(null);
@@ -24,12 +28,16 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') onCancel();
if (e.key === "Escape") onCancel();
}
return (
<div className="dialog-overlay" onClick={onCancel} onKeyDown={handleKeyDown}>
<div className="dialog" onClick={e => e.stopPropagation()}>
<div
className="dialog-overlay"
onClick={onCancel}
onKeyDown={handleKeyDown}
>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Add Token</h3>
<form onSubmit={handleSubmit}>
<label>
@@ -39,7 +47,7 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
type="text"
placeholder="e.g. Strahd von Zarovich"
value={label}
onChange={e => setLabel(e.target.value)}
onChange={(e) => setLabel(e.target.value)}
maxLength={30}
/>
</label>
@@ -48,14 +56,18 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
<input
type="color"
value={color}
onChange={e => setColor(e.target.value)}
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()}>
<button
type="submit"
className="btn-primary"
disabled={!label.trim()}
>
Place Token
</button>
</div>

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useCallback } from 'react';
import type { ServerMessage, ClientMessage } from '../types';
import { getToken } from '../api';
import { useEffect, useRef, useCallback } from "react";
import type { ServerMessage, ClientMessage } from "../types";
export function useWebSocket(
mapId: string,
@@ -12,10 +11,10 @@ export function useWebSocket(
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 proto = window.location.protocol === "https:" ? "wss:" : "ws:";
// The browser automatically sends the siren_session cookie with the
// WebSocket upgrade request — no manual token query param needed.
const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws`;
const ws = new WebSocket(url);
wsRef.current = ws;
@@ -29,12 +28,12 @@ export function useWebSocket(
const msg: ServerMessage = JSON.parse(event.data as string);
onMessageRef.current(msg);
} catch (err) {
console.error('[WS] Failed to parse message:', err);
console.error("[WS] Failed to parse message:", err);
}
};
ws.onerror = (err) => {
console.error('[WS] Error:', err);
console.error("[WS] Error:", err);
};
ws.onclose = () => {

View File

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

View File

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

View File

@@ -1,27 +1,62 @@
export interface User {
id: string; // Discord snowflake (stored as string)
username: string;
avatar?: string;
// ---------------------------------------------------------------------------
// User / Auth
// ---------------------------------------------------------------------------
export interface ConnectionInfo {
provider: string;
provider_username: string | null;
provider_avatar: string | null;
}
export type MapRole = 'owner' | 'editor' | 'viewer';
export interface UserInfo {
id: string; // UUID
username: string;
first_name: string | null;
last_name: string | null;
email: string | null;
/** True when the account has a local password (can log in without OAuth). */
has_password: boolean;
connections: ConnectionInfo[];
}
// ---------------------------------------------------------------------------
// Maps
// ---------------------------------------------------------------------------
export type MapRole = "owner" | "editor" | "viewer";
/** Map visibility / editability level. */
export type PublicAccess = "private" | "public_view" | "public_edit";
export interface MapPermission {
map_id: string;
user_id: number;
user_id: string; // UUID
username: string;
role: MapRole;
}
/** Core map record (returned by create, get, update). */
export interface GridMap {
id: string;
name: string;
is_public: boolean;
owner_id: number;
public_access: PublicAccess;
owner_id: string; // UUID
colors: string[];
created_at: string;
updated_at: string;
}
/**
* Extended map record returned by the list endpoint.
* Includes owner username, the caller's role, and a favorite flag.
*/
export interface ListedMap extends GridMap {
owner_username: string;
/** Null when the map is in the list only via a favorite (no explicit permission). */
user_role: MapRole | null;
is_favorited: boolean;
}
export interface GridCell {
map_id: string;
x: number;
@@ -44,36 +79,52 @@ export interface MapState {
tokens: GridToken[];
}
export type Tool = 'pan' | 'zoom' | 'draw' | 'token';
export interface MapAccessRequest {
id: string; // UUID
map_id: string;
user_id: string; // UUID
username: string;
requested_role: "editor" | "viewer";
status: "pending" | "approved" | "denied";
created_at: string;
updated_at: string;
}
// ---- WebSocket message types ------------------------------------------------
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[] };
| { 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;
}
| { 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 };

1
ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />