Updated Grid

This commit is contained in:
2026-04-08 09:15:01 -04:00
parent ca95582d92
commit a900e5e96a
45 changed files with 2731 additions and 429 deletions

View File

@@ -8,6 +8,7 @@ import type {
MapPermission,
MapRole,
MapState,
MovementRule,
PublicAccess,
UserInfo,
} from "./types";
@@ -64,10 +65,16 @@ export const api = {
getMap: (id: string): Promise<MapState> =>
request<MapState>(`${GRID_BASE}/maps/${id}`),
/** Update map name and/or public_access (owner only). */
/** Update map settings (owner only). */
updateMap: (
id: string,
payload: { name?: string; public_access?: PublicAccess },
payload: {
name?: string;
public_access?: PublicAccess;
units_per_square?: number;
unit_label?: string;
movement_rule?: MovementRule;
},
): Promise<GridMap> =>
request<GridMap>(`${GRID_BASE}/maps/${id}`, {
method: "PUT",
@@ -110,7 +117,7 @@ export const api = {
/** Request viewer or editor access to a map. */
requestAccess: (mapId: string, role: "editor" | "viewer"): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests`, {
request<void>(`${GRID_BASE}/maps/${mapId}/access`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role }),
@@ -118,7 +125,7 @@ export const api = {
/** List pending access requests for a map (owner only). */
listAccessRequests: (mapId: string): Promise<MapAccessRequest[]> =>
request<MapAccessRequest[]>(`${GRID_BASE}/maps/${mapId}/access-requests`),
request<MapAccessRequest[]>(`${GRID_BASE}/maps/${mapId}/access`),
/** Approve or deny a pending access request (owner only). */
resolveAccessRequest: (
@@ -126,7 +133,7 @@ export const api = {
requestId: string,
action: "approve" | "deny",
): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests/${requestId}`, {
request<void>(`${GRID_BASE}/maps/${mapId}/access/${requestId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
@@ -137,7 +144,7 @@ export const auth = {
/** 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`);
return await request<UserInfo>(`${AUTH_BASE}/user`);
} catch {
return null;
}
@@ -216,8 +223,8 @@ export const auth = {
currentPassword: string | null,
newPassword: string,
): Promise<void> {
await request<void>(`${AUTH_BASE}/change-password`, {
method: "POST",
await request<void>(`${AUTH_BASE}/password`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_password: currentPassword ?? undefined,
@@ -274,11 +281,11 @@ export const audioApi = {
request<AudioStatus>(`${AUDIO_BASE}/${guildId}/status`),
/** Enqueue a track URL for playback (bot joins the caller's voice channel). */
play: (guildId: string, url: string): Promise<void> =>
play: (guildId: string, url: string, loopEnabled = false): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/play`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
body: JSON.stringify({ url, loop_enabled: loopEnabled }),
}),
/** Pause the currently playing track. */
@@ -296,4 +303,12 @@ export const audioApi = {
/** Skip the current track. */
skip: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/skip`, { method: "POST" }),
/** Enable or disable looping on the currently-playing track. */
setLoop: (guildId: string, enabled: boolean): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/loop`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
}),
};

View File

@@ -36,3 +36,19 @@
border-color: #6366f1;
background: rgba(99, 102, 241, 0.25);
}
.fp-tool-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.fp-tool-btn:disabled:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Undo/Redo mini-panel: two buttons side-by-side */
.fp-undo-redo {
flex-direction: row;
padding: 0.35rem;
gap: 0.2rem;
}

View File

@@ -1,11 +1,22 @@
import { useEffect } from "react";
import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from "react-icons/md";
import {
MdPanTool,
MdZoomIn,
MdBrush,
MdPerson,
MdUndo,
MdRedo,
} from "react-icons/md";
import type { Tool } from "../types";
import "./ControlPanel.css";
interface Props {
tool: Tool;
onToolChange: (t: Tool) => void;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
}
const TOOLS: {
@@ -30,18 +41,26 @@ const TOOLS: {
id: "draw",
icon: <MdBrush />,
title:
"Draw left-click to paint, right-click to erase, Shift+click to fill",
"Draw left-click to paint, right-click to erase, Shift+click to fill/replace",
shortcut: "Shift+3",
},
{
id: "token",
icon: <MdPerson />,
title: "Token click to place, drag to move, right-click to delete",
title:
"Token click to place, drag to move, Shift+click to resize, double-click to edit, right-click to delete",
shortcut: "Shift+4",
},
];
export default function ControlPanel({ tool, onToolChange }: Props) {
export default function ControlPanel({
tool,
onToolChange,
canUndo,
canRedo,
onUndo,
onRedo,
}: Props) {
// Keyboard shortcuts: Shift+1/2/3/4 for tools
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -75,17 +94,40 @@ export default function ControlPanel({ tool, onToolChange }: Props) {
}, [onToolChange]);
return (
<div className="floating-panel">
{TOOLS.map((t) => (
<>
{/* Undo / Redo mini-panel — sits above the tool panel */}
<div className="floating-panel fp-undo-redo">
<button
key={t.id}
className={`fp-tool-btn ${tool === t.id ? "active" : ""}`}
onClick={() => onToolChange(t.id)}
title={`${t.title} (${t.shortcut})`}
className="fp-tool-btn"
onClick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
>
{t.icon}
<MdUndo />
</button>
))}
</div>
<button
className="fp-tool-btn"
onClick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Y / Ctrl+Shift+Z)"
>
<MdRedo />
</button>
</div>
{/* Tool selection panel */}
<div className="floating-panel">
{TOOLS.map((t) => (
<button
key={t.id}
className={`fp-tool-btn ${tool === t.id ? "active" : ""}`}
onClick={() => onToolChange(t.id)}
title={`${t.title} (${t.shortcut})`}
>
{t.icon}
</button>
))}
</div>
</>
);
}

View File

@@ -139,6 +139,30 @@
color: #8892a4;
}
/* ── Progress bar ── */
.discord-progress-bar {
height: 4px;
background: #2e3348;
border-radius: 2px;
overflow: hidden;
margin-top: 0.35rem;
}
.discord-progress-fill {
height: 100%;
background: #5865f2;
border-radius: 2px;
transition: width 0.9s linear;
}
.discord-progress-time {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: #6b7280;
margin-top: 0.1rem;
}
/* ── Playback controls ── */
.discord-controls {
display: flex;
@@ -198,6 +222,24 @@
color: #f87171;
}
.discord-btn-loop {
background: transparent;
border: 1px solid #4a5568;
color: #8892a4;
margin-left: auto;
}
.discord-btn-loop:hover:not(:disabled) {
border-color: #5865f2;
color: #a5b4fc;
}
.discord-btn-loop-active {
background: rgba(88, 101, 242, 0.15);
border-color: #5865f2 !important;
color: #a5b4fc !important;
}
/* ── Queue ── */
.discord-queue-count {
font-size: 0.7rem;
@@ -246,6 +288,25 @@
min-width: 0;
}
/* ── Loop checkbox ── */
.discord-loop-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: #8892a4;
cursor: pointer;
user-select: none;
margin-top: 0.3rem;
}
.discord-loop-label input[type="checkbox"] {
accent-color: #5865f2;
width: 13px;
height: 13px;
cursor: pointer;
}
/* ── Add to queue form ── */
.discord-play-form {
display: flex;

View File

@@ -3,6 +3,7 @@ import {
FaDiscord,
FaPause,
FaPlay,
FaRepeat,
FaStop,
FaForwardStep,
} from "react-icons/fa6";
@@ -10,6 +11,14 @@ import { audioApi } from "../api";
import type { AudioStatus, DiscordGuild, UserInfo } from "../types";
import "./DiscordPanel.css";
/** Format a raw seconds value as `m:ss` (e.g. 83 → "1:23"). */
function formatTime(secs: number): string {
const s = Math.floor(secs);
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}:${rem.toString().padStart(2, "0")}`;
}
interface Props {
user: UserInfo;
}
@@ -32,8 +41,17 @@ export default function DiscordPanel({ user }: Props) {
const [statusError, setStatusError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ── Progress tracking ──
// Interpolated playback position, ticked client-side between polls
const [positionSecs, setPositionSecs] = useState(0);
const [durationSecs, setDurationSecs] = useState<number | null>(null);
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Used to detect when the track changes so we can reset position
const currentTrackTitleRef = useRef<string | null>(null);
// ── Play input ──
const [playUrl, setPlayUrl] = useState("");
const [loopOnAdd, setLoopOnAdd] = useState(false);
const [playLoading, setPlayLoading] = useState(false);
const [playError, setPlayError] = useState<string | null>(null);
@@ -85,6 +103,49 @@ export default function DiscordPanel({ user }: Props) {
};
}, [selectedGuildId, fetchStatus]);
// ── Sync polled position/duration into local state ──
// Resets if the track title changes (i.e. a new track started).
useEffect(() => {
if (!status) {
setPositionSecs(0);
setDurationSecs(null);
currentTrackTitleRef.current = null;
return;
}
const newTitle = status.current_track?.title ?? null;
if (newTitle !== currentTrackTitleRef.current) {
// Track changed — snap to the server's reported position immediately
currentTrackTitleRef.current = newTitle;
setPositionSecs(status.position_secs);
} else {
// Same track — only sync if the server position differs by >2 s to
// avoid visible jumps caused by latency jitter between poll cycles.
setPositionSecs((prev) =>
Math.abs(prev - status.position_secs) > 2 ? status.position_secs : prev,
);
}
setDurationSecs(status.current_track?.duration_secs ?? null);
}, [status]);
// ── Client-side tick: advance positionSecs every second while playing ──
useEffect(() => {
if (tickRef.current) clearInterval(tickRef.current);
if (!status?.current_track || status.is_paused) return;
tickRef.current = setInterval(() => {
setPositionSecs((prev) => {
const next = prev + 1;
// Don't tick past the known duration
return durationSecs !== null ? Math.min(next, durationSecs) : next;
});
}, 1000);
return () => {
if (tickRef.current) clearInterval(tickRef.current);
};
// Re-create the interval whenever play/pause state or track changes
}, [status?.current_track?.title, status?.is_paused, durationSecs]);
// ── Helpers ──
function errMsg(err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
@@ -108,7 +169,7 @@ export default function DiscordPanel({ user }: Props) {
setPlayLoading(true);
setPlayError(null);
try {
await audioApi.play(selectedGuildId, playUrl.trim());
await audioApi.play(selectedGuildId, playUrl.trim(), loopOnAdd);
setPlayUrl("");
if (selectedGuildId) await fetchStatus(selectedGuildId);
} catch (err) {
@@ -122,7 +183,6 @@ export default function DiscordPanel({ user }: Props) {
if (!discordConnection) return null;
const selectedGuild = guilds.find((g) => g.id === selectedGuildId);
const isPlaying = !!status?.current_track && !status?.is_paused;
return (
<div className="discord-panel">
@@ -140,9 +200,7 @@ export default function DiscordPanel({ user }: Props) {
) : guildsError ? (
<p className="discord-error">{guildsError}</p>
) : guilds.length === 0 ? (
<p className="discord-muted">
The bot isn't in any servers yet.
</p>
<p className="discord-muted">The bot isn't in any servers yet.</p>
) : guilds.length === 1 ? (
<p className="discord-guild-name">{guilds[0].name}</p>
) : (
@@ -193,6 +251,24 @@ export default function DiscordPanel({ user }: Props) {
<p className="discord-track-status">
{status.is_paused ? "⏸ Paused" : "▶ Playing"}
</p>
{/* Progress bar */}
<div className="discord-progress-bar">
<div
className="discord-progress-fill"
style={{
width:
durationSecs !== null && durationSecs > 0
? `${Math.min((positionSecs / durationSecs) * 100, 100)}%`
: "0%",
}}
/>
</div>
<div className="discord-progress-time">
<span>{formatTime(positionSecs)}</span>
{durationSecs !== null && (
<span>{formatTime(durationSecs)}</span>
)}
</div>
</div>
) : (
<p className="discord-muted">Nothing is playing</p>
@@ -239,6 +315,25 @@ export default function DiscordPanel({ user }: Props) {
>
<FaStop />
</button>
<button
className={`discord-btn discord-btn-loop${status?.current_track?.loop_enabled ? " discord-btn-loop-active" : ""}`}
title={
status?.current_track?.loop_enabled
? "Disable loop"
: "Enable loop"
}
onClick={() =>
runAction(() =>
audioApi.setLoop(
selectedGuildId!,
!status?.current_track?.loop_enabled,
),
)
}
disabled={!status?.current_track}
>
<FaRepeat />
</button>
</div>
{actionError && <p className="discord-error">{actionError}</p>}
@@ -248,7 +343,14 @@ export default function DiscordPanel({ user }: Props) {
{/* Queue */}
{selectedGuild && (
<section className="discord-section">
<h3>Queue {status && status.queue.length > 0 && <span className="discord-queue-count">({status.queue.length})</span>}</h3>
<h3>
Queue{" "}
{status && status.queue.length > 0 && (
<span className="discord-queue-count">
({status.queue.length})
</span>
)}
</h3>
{!status || status.queue.length === 0 ? (
<p className="discord-muted">Queue is empty</p>
) : (
@@ -287,6 +389,14 @@ export default function DiscordPanel({ user }: Props) {
{playLoading ? "Adding…" : "Add"}
</button>
</form>
<label className="discord-loop-label">
<input
type="checkbox"
checked={loopOnAdd}
onChange={(e) => setLoopOnAdd(e.target.checked)}
/>
Loop
</label>
{playError && <p className="discord-error">{playError}</p>}
</section>
)}

View File

@@ -196,3 +196,59 @@
padding: 0.35rem 0.75rem !important;
font-size: 0.8rem !important;
}
/* ── Movement ruler fields ── */
.movement-ruler-row {
padding: 0.2rem 0;
}
.movement-ruler-scale .movement-ruler-scale-inputs {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.25rem;
}
.movement-units-input {
width: 60px;
background: #111827;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
outline: none;
text-align: center;
-moz-appearance: textfield;
}
.movement-units-input::-webkit-outer-spin-button,
.movement-units-input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
.movement-units-input:focus {
border-color: #6366f1;
}
.movement-unit-select {
background: #1f2937;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
outline: none;
cursor: pointer;
}
.movement-unit-select:focus {
border-color: #6366f1;
}
.movement-rule-select {
width: 100%;
margin-top: 0.25rem;
}
.movement-ruler-hint {
font-size: 0.78rem;
color: #6b7280;
}

View File

@@ -6,6 +6,7 @@ import type {
MapAccessRequest,
MapPermission,
MapRole,
MovementRule,
PublicAccess,
} from "../types";
import "./EditMapModal.css";
@@ -21,6 +22,11 @@ export default function EditMapModal({ map, onClose, onUpdated }: Props) {
const [publicAccess, setPublicAccess] = useState<PublicAccess>(
map.public_access,
);
const [unitsPerSquare, setUnitsPerSquare] = useState(map.units_per_square);
const [unitLabel, setUnitLabel] = useState(map.unit_label);
const [movementRule, setMovementRule] = useState<MovementRule>(
map.movement_rule,
);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -77,6 +83,9 @@ export default function EditMapModal({ map, onClose, onUpdated }: Props) {
const updated = await api.updateMap(map.id, {
name: trimmed,
public_access: publicAccess,
units_per_square: unitsPerSquare,
unit_label: unitLabel,
movement_rule: movementRule,
});
onUpdated(updated);
onClose();
@@ -162,6 +171,59 @@ export default function EditMapModal({ map, onClose, onUpdated }: Props) {
/>
</label>
{/* ── Movement ruler ── */}
<fieldset className="public-access-fieldset">
<legend>Movement Ruler</legend>
<div className="movement-ruler-row">
<label className="field-label movement-ruler-scale">
Scale
<div className="movement-ruler-scale-inputs">
<input
type="number"
className="movement-units-input"
value={unitsPerSquare}
min={1}
max={9999}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v > 0) setUnitsPerSquare(v);
}}
/>
<select
className="movement-unit-select"
value={unitLabel}
onChange={(e) => setUnitLabel(e.target.value)}
>
<option value="ft">ft</option>
<option value="m">m</option>
<option value="yd">yd</option>
<option value="mi">mi</option>
</select>
<span className="movement-ruler-hint">per square</span>
</div>
</label>
</div>
<div className="movement-ruler-row">
<label className="field-label">
Diagonal rule
<select
className="movement-unit-select movement-rule-select"
value={movementRule}
onChange={(e) =>
setMovementRule(e.target.value as MovementRule)
}
>
<option value="free">Free Diagonals</option>
<option value="alternating">
Alternating every 2nd diagonal costs double
</option>
</select>
</label>
</div>
</fieldset>
<fieldset className="public-access-fieldset">
<legend>Visibility</legend>

View File

@@ -10,3 +10,47 @@
position: absolute;
inset: 0;
}
.grid-reconnecting-banner {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.75);
color: #facc15;
font-size: 0.8rem;
font-weight: 600;
padding: 4px 14px;
border-radius: 999px;
pointer-events: none;
z-index: 10;
letter-spacing: 0.03em;
animation: ws-pulse 1.2s ease-in-out infinite;
}
@keyframes ws-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
/* Token hover tooltip */
.token-tooltip {
position: absolute;
transform: translate(-50%, calc(-100% - 6px));
background: rgba(0, 0, 0, 0.82);
color: #f9fafb;
font-size: 0.78rem;
font-weight: 500;
padding: 3px 10px;
border-radius: 999px;
white-space: nowrap;
pointer-events: none;
z-index: 20;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,22 +3,32 @@ import "./TokenDialog.css";
interface Props {
defaultColor: string;
/** When provided, the dialog opens in edit mode pre-filled with this token's data. */
initialLabel?: string;
initialColor?: string;
mode?: "add" | "edit";
onConfirm: (label: string, color: string) => void;
onCancel: () => void;
}
export default function TokenDialog({
defaultColor,
initialLabel,
initialColor,
mode = "add",
onConfirm,
onCancel,
}: Props) {
const [label, setLabel] = useState("");
const [color, setColor] = useState(defaultColor);
const [label, setLabel] = useState(initialLabel ?? "");
const [color, setColor] = useState(initialColor ?? defaultColor);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
if (initialLabel) {
inputRef.current?.select();
}
}, [initialLabel]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -31,6 +41,8 @@ export default function TokenDialog({
if (e.key === "Escape") onCancel();
}
const isEdit = mode === "edit";
return (
<div
className="dialog-overlay"
@@ -38,7 +50,7 @@ export default function TokenDialog({
onKeyDown={handleKeyDown}
>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Add Token</h3>
<h3>{isEdit ? "Edit Token" : "Add Token"}</h3>
<form onSubmit={handleSubmit}>
<label>
Name
@@ -68,7 +80,7 @@ export default function TokenDialog({
className="btn-primary"
disabled={!label.trim()}
>
Place Token
{isEdit ? "Save Changes" : "Place Token"}
</button>
</div>
</form>

View File

@@ -0,0 +1,75 @@
.stack-picker {
position: absolute;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.5rem;
min-width: 160px;
max-width: 220px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stack-picker-title {
font-size: 0.7rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.4rem 0.35rem;
border-bottom: 1px solid #374151;
margin-bottom: 0.1rem;
}
.stack-picker-item {
display: flex;
align-items: center;
gap: 0.5rem;
background: transparent;
border: none;
border-radius: 5px;
padding: 0.35rem 0.5rem;
cursor: pointer;
text-align: left;
color: #e5e7eb;
font-size: 0.85rem;
transition: background 0.1s;
width: 100%;
}
.stack-picker-item:hover {
background: #374151;
}
.stack-picker-swatch {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.stack-picker-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stack-picker-divider {
height: 1px;
background: #374151;
margin: 0.15rem 0;
}
.stack-picker-move-all {
color: #a5b4fc;
font-weight: 500;
font-size: 0.82rem;
}
.stack-picker-move-all:hover {
background: #312e81;
}

View File

@@ -0,0 +1,67 @@
import type { GridToken } from "../types";
import "./TokenStackPicker.css";
interface Props {
tokens: GridToken[];
/** Canvas-relative pixel position to anchor the popup */
canvasX: number;
canvasY: number;
onPickToken: (token: GridToken) => void;
onMoveStack: () => void;
onCancel: () => void;
}
export default function TokenStackPicker({
tokens,
canvasX,
canvasY,
onPickToken,
onMoveStack,
onCancel,
}: Props) {
return (
<>
{/* Invisible full-canvas backdrop to close on outside click */}
<div
style={{
position: "absolute",
inset: 0,
zIndex: 49,
}}
onMouseDown={onCancel}
/>
<div
className="stack-picker"
style={{ left: canvasX, top: canvasY }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="stack-picker-title">{tokens.length} tokens stacked</div>
{tokens.map((tok) => (
<button
key={tok.id}
className="stack-picker-item"
onClick={() => onPickToken(tok)}
>
<span
className="stack-picker-swatch"
style={{ background: tok.color }}
/>
<span className="stack-picker-label" title={tok.label}>
{tok.label}
</span>
</button>
))}
<div className="stack-picker-divider" />
<button
className="stack-picker-item stack-picker-move-all"
onClick={onMoveStack}
>
Move entire stack
</button>
</div>
</>
);
}

View File

@@ -1,6 +1,10 @@
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useState } from "react";
import type { ServerMessage, ClientMessage } from "../types";
const INITIAL_RETRY_DELAY_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const MAX_RETRIES = 10;
export function useWebSocket(
mapId: string,
onMessage: (msg: ServerMessage) => void,
@@ -10,39 +14,93 @@ export function useWebSocket(
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const [connected, setConnected] = useState(false);
// Reconnect state — live across re-renders without triggering them
const retryCountRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set to true when the effect tears down so we stop reconnecting
const destroyedRef = useRef(false);
useEffect(() => {
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`;
destroyedRef.current = false;
retryCountRef.current = 0;
const ws = new WebSocket(url);
wsRef.current = ws;
function connect() {
if (destroyedRef.current) return;
ws.onopen = () => {
console.log(`[WS] Connected to map ${mapId}`);
};
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`;
ws.onmessage = (event: MessageEvent) => {
try {
const msg: ServerMessage = JSON.parse(event.data as string);
onMessageRef.current(msg);
} catch (err) {
console.error("[WS] Failed to parse message:", err);
}
};
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onerror = (err) => {
console.error("[WS] Error:", err);
};
ws.onopen = () => {
console.log(`[WS] Connected to map ${mapId}`);
retryCountRef.current = 0;
setConnected(true);
};
ws.onclose = () => {
console.log(`[WS] Disconnected from map ${mapId}`);
};
ws.onmessage = (event: MessageEvent) => {
try {
const msg: ServerMessage = JSON.parse(event.data as string);
onMessageRef.current(msg);
} catch (err) {
console.error("[WS] Failed to parse message:", err);
}
};
ws.onerror = (err) => {
console.error("[WS] Error:", err);
};
ws.onclose = (event) => {
wsRef.current = null;
setConnected(false);
if (destroyedRef.current) {
// Normal teardown — do not reconnect
return;
}
const attempt = retryCountRef.current;
if (attempt >= MAX_RETRIES) {
console.warn(
`[WS] Gave up reconnecting to map ${mapId} after ${MAX_RETRIES} attempts`,
);
return;
}
const delay = Math.min(
INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt),
MAX_RETRY_DELAY_MS,
);
retryCountRef.current = attempt + 1;
console.log(
`[WS] Disconnected from map ${mapId} (code ${event.code}). ` +
`Reconnecting in ${delay} ms (attempt ${retryCountRef.current}/${MAX_RETRIES})…`,
);
retryTimerRef.current = setTimeout(connect, delay);
};
}
connect();
return () => {
ws.close();
wsRef.current = null;
destroyedRef.current = true;
if (retryTimerRef.current !== null) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setConnected(false);
};
}, [mapId]);
@@ -53,5 +111,5 @@ export function useWebSocket(
}
}, []);
return { send };
return { send, connected };
}

View File

@@ -13,7 +13,9 @@ export default function AccountPage() {
const hasDiscord = user.connections.some((c) => c.provider === "discord");
return (
<div className={`page-container ${hasDiscord ? "account-page-layout" : ""}`}>
<div
className={`page-container ${hasDiscord ? "account-page-layout" : ""}`}
>
<AccountPanel
user={user}
onClose={() => navigate("/map")}

View File

@@ -66,6 +66,10 @@ export default function MapPage({ setMapTitle }: Props) {
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
const gridRef = useRef<GridHandle>(null);
// ── Undo / Redo state ──
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
// ── Modal visibility ──
const [showLoginModal, setShowLoginModal] = useState(false);
const [showNewMap, setShowNewMap] = useState(false);
@@ -191,6 +195,9 @@ export default function MapPage({ setMapTitle }: Props) {
...m,
name: updated.name,
public_access: updated.public_access,
units_per_square: updated.units_per_square,
unit_label: updated.unit_label,
movement_rule: updated.movement_rule,
updated_at: updated.updated_at,
}
: m,
@@ -245,9 +252,23 @@ export default function MapPage({ setMapTitle }: Props) {
paintColor={activeColor}
tokenColor={activeColor}
onColorsLoaded={handleColorsLoaded}
unitsPerSquare={selectedMapInfo?.units_per_square ?? 5}
unitLabel={selectedMapInfo?.unit_label ?? "ft"}
movementRule={selectedMapInfo?.movement_rule ?? "free"}
onUndoStateChange={(u, r) => {
setCanUndo(u);
setCanRedo(r);
}}
/>
<div className="floating-panels-container">
<ControlPanel tool={tool} onToolChange={setTool} />
<ControlPanel
tool={tool}
onToolChange={setTool}
canUndo={canUndo}
canRedo={canRedo}
onUndo={() => gridRef.current?.undo()}
onRedo={() => gridRef.current?.redo()}
/>
<ColorPanel
colors={mapColors}
activeColor={activeColor}

View File

@@ -48,6 +48,12 @@ export interface GridMap {
public_access: PublicAccess;
owner_id: string; // UUID
colors: string[];
/** Real-world units per grid square. */
units_per_square: number;
/** Label for the unit, e.g. "ft" or "m". */
unit_label: string;
/** Diagonal movement rule. */
movement_rule: MovementRule;
created_at: Date;
updated_at: Date;
}
@@ -77,6 +83,7 @@ export interface GridToken {
y: number;
label: string;
color: string;
size: number;
}
export interface MapState {
@@ -106,17 +113,30 @@ export interface DiscordGuild {
export interface TrackInfo {
title: string;
url: string;
/** Total duration in seconds, if known. */
duration_secs: number | null;
/** Whether this track is set to loop indefinitely. */
loop_enabled: boolean;
}
export interface AudioStatus {
voice_channel: string | null;
is_paused: boolean;
/** Elapsed playback position of the current track in seconds. */
position_secs: number;
current_track: TrackInfo | null;
queue: TrackInfo[];
}
export type Tool = "pan" | "zoom" | "draw" | "token";
/** Which diagonal movement rule to use when calculating drag distance. */
export type MovementRule =
/** Diagonals cost the same as cardinal moves */
| "free"
/** Every other diagonal costs an extra square */
| "alternating";
export type ClientMessage =
| { type: "paint_cell"; x: number; y: number; color: string }
| {
@@ -127,6 +147,8 @@ export type ClientMessage =
| { 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_token"; id: string; label: string; color: string }
| { type: "resize_token"; id: string; size: number }
| { type: "update_colors"; colors: string[] };
export type ServerMessage =
@@ -144,8 +166,11 @@ export type ServerMessage =
y: number;
label: string;
color: string;
size: number;
}
| { type: "token_moved"; id: string; x: number; y: number }
| { type: "token_deleted"; id: string }
| { type: "token_updated"; id: string; label: string; color: string }
| { type: "token_resized"; id: string; size: number }
| { type: "colors_updated"; colors: string[] }
| { type: "error"; message: string };