Updated Grid
This commit is contained in:
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
75
ui/src/components/TokenStackPicker.css
Normal file
75
ui/src/components/TokenStackPicker.css
Normal 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;
|
||||
}
|
||||
67
ui/src/components/TokenStackPicker.tsx
Normal file
67
ui/src/components/TokenStackPicker.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user