From a900e5e96ada43faa61f4502a120f31ecbccdd0a Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 8 Apr 2026 09:15:01 -0400 Subject: [PATCH] Updated Grid --- README.md | 2 +- bruno/audio/Track Status.bru | 11 + bruno/audio/folder.bru | 4 + bruno/auth/discord/Authorize.bru | 15 + bruno/auth/discord/folder.bru | 4 + bruno/auth/folder.bru | 8 + bruno/auth/local/Change Password.bru | 23 + bruno/auth/local/Get Self.bru | 16 + bruno/auth/local/Login.bru | 23 + bruno/auth/local/folder.bru | 8 + bruno/dice/folder.bru | 4 + bruno/environments/Localhost.bru | 7 +- bruno/oauth/Authorize.bru | 11 - crates/siren-api/src/app_state.rs | 8 + crates/siren-api/src/audio/mod.rs | 68 +- crates/siren-api/src/auth/local.rs | 8 +- crates/siren-api/src/grid/mod.rs | 253 +++- crates/siren-api/src/grid/model.rs | 37 + crates/siren-bot/src/commands/audio/play.rs | 88 +- crates/siren-bot/src/commands/audio/queue.rs | 70 +- crates/siren-bot/src/commands/audio/skip.rs | 6 +- crates/siren-bot/src/handler.rs | 9 +- crates/siren-bot/src/ytdlp/mod.rs | 32 +- crates/siren-bot/src/ytdlp/model.rs | 7 + crates/siren/src/main.rs | 2 + migrations/000_initial.sql | 135 -- migrations/001_map.sql | 67 + migrations/002_dnd.sql | 43 + migrations/003_misc.sql | 18 + ui/src/api.ts | 35 +- ui/src/components/ControlPanel.css | 16 + ui/src/components/ControlPanel.tsx | 68 +- ui/src/components/DiscordPanel.css | 61 + ui/src/components/DiscordPanel.tsx | 122 +- ui/src/components/EditMapModal.css | 56 + ui/src/components/EditMapModal.tsx | 62 + ui/src/components/Grid.css | 44 + ui/src/components/Grid.tsx | 1381 ++++++++++++++++-- ui/src/components/TokenDialog.tsx | 22 +- ui/src/components/TokenStackPicker.css | 75 + ui/src/components/TokenStackPicker.tsx | 67 + ui/src/hooks/useWebSocket.ts | 112 +- ui/src/pages/AccountPage.tsx | 4 +- ui/src/pages/MapPage.tsx | 23 +- ui/src/types.ts | 25 + 45 files changed, 2731 insertions(+), 429 deletions(-) create mode 100644 bruno/audio/Track Status.bru create mode 100644 bruno/audio/folder.bru create mode 100644 bruno/auth/discord/Authorize.bru create mode 100644 bruno/auth/discord/folder.bru create mode 100644 bruno/auth/folder.bru create mode 100644 bruno/auth/local/Change Password.bru create mode 100644 bruno/auth/local/Get Self.bru create mode 100644 bruno/auth/local/Login.bru create mode 100644 bruno/auth/local/folder.bru create mode 100644 bruno/dice/folder.bru delete mode 100644 bruno/oauth/Authorize.bru create mode 100644 migrations/001_map.sql create mode 100644 migrations/002_dnd.sql create mode 100644 migrations/003_misc.sql create mode 100644 ui/src/components/TokenStackPicker.css create mode 100644 ui/src/components/TokenStackPicker.tsx diff --git a/README.md b/README.md index 78b97de..d0dac68 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Siren uses Discord slash commands. | `/stop` | Stop playback and clear the queue | Done | | `/mute` | Mute/unmute the bot | Done | | `/volume <0–100>` | Set the playback volume | Done | -| `/queue` | Display the current queue | Planned | +| `/queue` | Display the current queue | Done | | `/nowplaying` | Display the currently playing track | Planned | | `/shuffle` | Shuffle the queue | Planned | | `/loop` | Toggle looping the current track | Planned | diff --git a/bruno/audio/Track Status.bru b/bruno/audio/Track Status.bru new file mode 100644 index 0000000..47513bc --- /dev/null +++ b/bruno/audio/Track Status.bru @@ -0,0 +1,11 @@ +meta { + name: Track Status + type: http + seq: 4 +} + +get { + url: {{BASE_URL}}/audio/{{SERVER}}/status + body: none + auth: inherit +} diff --git a/bruno/audio/folder.bru b/bruno/audio/folder.bru new file mode 100644 index 0000000..073dc69 --- /dev/null +++ b/bruno/audio/folder.bru @@ -0,0 +1,4 @@ +meta { + name: audio + seq: 2 +} diff --git a/bruno/auth/discord/Authorize.bru b/bruno/auth/discord/Authorize.bru new file mode 100644 index 0000000..797ad02 --- /dev/null +++ b/bruno/auth/discord/Authorize.bru @@ -0,0 +1,15 @@ +meta { + name: Authorize + type: http + seq: 1 +} + +get { + url: {{BASE_URL}}/auth/discord/authorize?redirect_uri=bruno://oauth/callback + body: none + auth: oauth2 +} + +params:query { + redirect_uri: bruno://oauth/callback +} diff --git a/bruno/auth/discord/folder.bru b/bruno/auth/discord/folder.bru new file mode 100644 index 0000000..26c7e80 --- /dev/null +++ b/bruno/auth/discord/folder.bru @@ -0,0 +1,4 @@ +meta { + name: discord + seq: 1 +} diff --git a/bruno/auth/folder.bru b/bruno/auth/folder.bru new file mode 100644 index 0000000..3cc6ad0 --- /dev/null +++ b/bruno/auth/folder.bru @@ -0,0 +1,8 @@ +meta { + name: auth + seq: 4 +} + +auth { + mode: inherit +} diff --git a/bruno/auth/local/Change Password.bru b/bruno/auth/local/Change Password.bru new file mode 100644 index 0000000..e099601 --- /dev/null +++ b/bruno/auth/local/Change Password.bru @@ -0,0 +1,23 @@ +meta { + name: Change Password + type: http + seq: 2 +} + +put { + url: {{BASE_URL}}/auth/password + body: json + auth: inherit +} + +body:json { + { + "current_password": "test", + "new_password": "test" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/auth/local/Get Self.bru b/bruno/auth/local/Get Self.bru new file mode 100644 index 0000000..6ad2d0f --- /dev/null +++ b/bruno/auth/local/Get Self.bru @@ -0,0 +1,16 @@ +meta { + name: Get Self + type: http + seq: 3 +} + +get { + url: {{BASE_URL}}/auth/user + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/auth/local/Login.bru b/bruno/auth/local/Login.bru new file mode 100644 index 0000000..17c404b --- /dev/null +++ b/bruno/auth/local/Login.bru @@ -0,0 +1,23 @@ +meta { + name: Login + type: http + seq: 1 +} + +post { + url: {{BASE_URL}}/auth/login + body: json + auth: inherit +} + +body:json { + { + "username": "admin", + "password": "test" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/auth/local/folder.bru b/bruno/auth/local/folder.bru new file mode 100644 index 0000000..07f9d49 --- /dev/null +++ b/bruno/auth/local/folder.bru @@ -0,0 +1,8 @@ +meta { + name: local + seq: 2 +} + +auth { + mode: inherit +} diff --git a/bruno/dice/folder.bru b/bruno/dice/folder.bru new file mode 100644 index 0000000..41334d0 --- /dev/null +++ b/bruno/dice/folder.bru @@ -0,0 +1,4 @@ +meta { + name: dice + seq: 3 +} diff --git a/bruno/environments/Localhost.bru b/bruno/environments/Localhost.bru index 3be8e02..01479d2 100644 --- a/bruno/environments/Localhost.bru +++ b/bruno/environments/Localhost.bru @@ -1,7 +1,8 @@ vars { - baseUrl: http://localhost:3000/api + BASE_URL: http://localhost:3000/api } vars:secret [ - server, - apiKey + TEST_SERVER, + apiKey, + SERVER ] diff --git a/bruno/oauth/Authorize.bru b/bruno/oauth/Authorize.bru deleted file mode 100644 index 433ba3a..0000000 --- a/bruno/oauth/Authorize.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Authorize - type: http - seq: 1 -} - -get { - url: {{baseUrl}}/oauth/authorize - body: none - auth: inherit -} diff --git a/crates/siren-api/src/app_state.rs b/crates/siren-api/src/app_state.rs index b3d4fac..adaab5f 100644 --- a/crates/siren-api/src/app_state.rs +++ b/crates/siren-api/src/app_state.rs @@ -1,3 +1,4 @@ +use crate::grid::model::CellPatch; use dashmap::DashMap; use serenity::{ all::{Cache, Http}, @@ -30,4 +31,11 @@ pub struct AppState { /// Per-map WebSocket broadcast channels for real-time collaboration. /// Key is the CSPRNG map ID (TEXT). pub map_rooms: Arc>>, + /// Per-map buffered single-cell paints awaiting the next 100 ms DB flush. + /// Drained by a per-map background task; broadcast to clients happens + /// immediately — only the DB write is deferred. + pub map_paint_buffer: Arc>>>>, + /// Sentinel set — tracks which map IDs already have a DB flush task running + /// so we don't spawn duplicate tasks when multiple clients connect. + pub map_flush_tasks: Arc>, } diff --git a/crates/siren-api/src/audio/mod.rs b/crates/siren-api/src/audio/mod.rs index 954bf49..4bc4424 100644 --- a/crates/siren-api/src/audio/mod.rs +++ b/crates/siren-api/src/audio/mod.rs @@ -16,7 +16,7 @@ use siren_bot::{ join_voice_channel, pause::pause_track, play::enqueue_track, - queue::{TrackInfo, get_is_paused, get_queue}, + queue::{TrackInfo, get_current_position, get_is_paused, get_queue, set_loop_current}, resume::resume_track, skip::skip_track, stop::stop_track, @@ -39,6 +39,7 @@ pub fn get_guild_routes() -> Router> { .route("/resume", post(resume_audio)) .route("/stop", post(stop_audio)) .route("/skip", post(skip_audio)) + .route("/loop", post(set_loop_audio)) .route("/status", get(audio_status)) } @@ -47,6 +48,13 @@ pub fn get_guild_routes() -> Router> { #[derive(Deserialize)] struct PlayTrackRequest { url: String, + #[serde(default)] + loop_enabled: bool, +} + +#[derive(Deserialize)] +struct SetLoopRequest { + enabled: bool, } /// Resolve the Discord snowflake for a local user from `user_connections`. @@ -130,7 +138,13 @@ async fn play_audio( // Play the track let manager = get_songbird(); let _channel_id = join_voice_channel(&state.cache, manager, &guild_id, &user_id).await?; - enqueue_track(manager, guild_id.to_owned(), &payload.url).await?; + enqueue_track( + manager, + guild_id.to_owned(), + &payload.url, + payload.loop_enabled, + ) + .await?; Ok(()) } @@ -216,10 +230,37 @@ async fn skip_audio( Ok(()) } +// ── POST /api/audio/{guild_id}/loop ────────────────────────────────────────── + +async fn set_loop_audio( + SessionAuthorization(session): SessionAuthorization, + State(state): State>, + Path(guild_id): Path, + Json(payload): Json, +) -> Result<()> { + session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + log::debug!("<{}> Setting loop={}", guild_id, payload.enabled); + + let guild_id = match state.cache.guild(guild_id) { + Some(guild) => guild.id, + None => return Err(Error::not_found("Guild not found".to_string())), + }; + + let found = set_loop_current(guild_id.get(), payload.enabled).await; + if !found { + return Err(Error::not_found( + "No track is currently playing".to_string(), + )); + } + Ok(()) +} + #[derive(Serialize)] struct AudioStatus { voice_channel: Option, is_paused: bool, + /// Elapsed playback position of the current track in seconds. + position_secs: f64, current_track: Option, queue: Vec, } @@ -238,19 +279,17 @@ async fn audio_status( // ── Voice channel: look up the bot's own voice state + channel name from cache ── let bot_user_id = state.cache.current_user().id; - let voice_channel = state - .cache - .guild(guild_id_snowflake) - .and_then(|guild| { - let ch_id = guild - .voice_states - .get(&bot_user_id) - .and_then(|vs| vs.channel_id)?; - guild.channels.get(&ch_id).map(|ch| ch.name.clone()) - }); + let voice_channel = state.cache.guild(guild_id_snowflake).and_then(|guild| { + let ch_id = guild + .voice_states + .get(&bot_user_id) + .and_then(|vs| vs.channel_id)?; + guild.channels.get(&ch_id).map(|ch| ch.name.clone()) + }); - // ── Playback paused state (delegated to siren-bot to keep songbird internal) ── - let is_paused = get_is_paused(guild_id).await; + // ── Playback paused state + position (delegated to siren-bot to keep songbird internal) ── + let (is_paused, position_secs) = + tokio::join!(get_is_paused(guild_id), get_current_position(guild_id)); // ── Queue metadata from our store (index 0 = currently playing) ── let mut full_queue = get_queue(guild_id); @@ -263,6 +302,7 @@ async fn audio_status( Ok(Json(AudioStatus { voice_channel, is_paused, + position_secs, current_track, queue: full_queue, })) diff --git a/crates/siren-api/src/auth/local.rs b/crates/siren-api/src/auth/local.rs index 04f12f4..44651e2 100644 --- a/crates/siren-api/src/auth/local.rs +++ b/crates/siren-api/src/auth/local.rs @@ -35,9 +35,9 @@ pub fn get_routes() -> Router> { .route("/register", post(register)) .route("/login", post(login)) .route("/logout", post(logout)) - .route("/me", get(me)) + .route("/user", get(get_self)) .route("/profile", put(update_profile)) - .route("/change-password", post(change_password)) + .route("/password", put(update_password)) .route("/connections/{provider}", delete(disconnect_provider)) } @@ -323,7 +323,7 @@ async fn logout( (jar.add(removal), StatusCode::NO_CONTENT) } -async fn me(SessionAuthorization(session): SessionAuthorization) -> Result> { +async fn get_self(SessionAuthorization(session): SessionAuthorization) -> Result> { let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; Ok(Json(load_user_info(session.user_id).await?)) } @@ -375,7 +375,7 @@ async fn update_profile( Ok(Json(load_user_info(session.user_id).await?)) } -async fn change_password( +async fn update_password( SessionAuthorization(session): SessionAuthorization, Json(payload): Json, ) -> Result { diff --git a/crates/siren-api/src/grid/mod.rs b/crates/siren-api/src/grid/mod.rs index 676a658..6c6e597 100644 --- a/crates/siren-api/src/grid/mod.rs +++ b/crates/siren-api/src/grid/mod.rs @@ -21,6 +21,7 @@ use axum::{ use futures_util::{SinkExt, StreamExt}; use model::{ AccessRequestWithUser, + CellPatch, ClientMessage, CreateAccessRequestPayload, CreateMapPayload, @@ -37,8 +38,11 @@ use model::{ UpdatePermissionPayload, }; use siren_core::utils::csprng; -use std::sync::Arc; -use tokio::sync::broadcast; +use std::{collections::HashMap, sync::Arc}; +use tokio::{ + sync::{broadcast, broadcast::error::RecvError}, + time::Duration, +}; use uuid::Uuid; pub fn get_routes() -> Router> { @@ -52,10 +56,10 @@ pub fn get_routes() -> Router> { .route("/maps/{id}/permissions", put(update_permission)) .route("/maps/{id}/favorite", post(favorite_map)) .route("/maps/{id}/favorite", delete(unfavorite_map)) - .route("/maps/{id}/access-requests", post(create_access_request)) - .route("/maps/{id}/access-requests", get(list_access_requests)) + .route("/maps/{id}/access", post(create_access_request)) + .route("/maps/{id}/access", get(list_access_requests)) .route( - "/maps/{id}/access-requests/{request_id}", + "/maps/{id}/access/{request_id}", put(resolve_access_request), ) .route("/maps/{id}/ws", get(ws_handler)) @@ -126,7 +130,9 @@ pub async fn list_maps( "SELECT gm.id, gm.name, gm.public_access, gm.owner_id, u.username AS owner_username, - gm.colors, gm.created_at, gm.updated_at, + gm.colors, + gm.units_per_square, gm.unit_label, gm.movement_rule, + gm.created_at, gm.updated_at, mp.role AS user_role, (mf.user_id IS NOT NULL) AS is_favorited FROM grid_maps gm @@ -235,18 +241,47 @@ pub async fn update_map( } } + if let Some(ref mr) = payload.movement_rule { + if !matches!(mr.as_str(), "free" | "alternating") { + return Err(Error::new(422, "Invalid movement_rule value".into())); + } + } + + if let Some(ups) = payload.units_per_square { + if ups < 1 { + return Err(Error::new(422, "units_per_square must be >= 1".into())); + } + } + let new_name = payload.name.as_deref().unwrap_or(&map.name); let new_pa = payload .public_access .as_deref() .unwrap_or(&map.public_access); + let new_ups = payload.units_per_square.unwrap_or(map.units_per_square); + let new_ul = payload + .unit_label + .as_deref() + .unwrap_or(&map.unit_label) + .to_string(); + let new_mr = payload + .movement_rule + .as_deref() + .unwrap_or(&map.movement_rule) + .to_string(); let updated: GridMap = sqlx::query_as( - "UPDATE grid_maps SET name = $1, public_access = $2, updated_at = NOW() - WHERE id = $3 RETURNING *", + "UPDATE grid_maps + SET name = $1, public_access = $2, + units_per_square = $3, unit_label = $4, movement_rule = $5, + updated_at = NOW() + WHERE id = $6 RETURNING *", ) .bind(new_name) .bind(new_pa) + .bind(new_ups) + .bind(&new_ul) + .bind(&new_mr) .bind(&id) .fetch_one(pool) .await?; @@ -595,16 +630,51 @@ async fn handle_socket( let editor = can_edit(&map_state.map, &session).await; + // ── Broadcast channel (1024 slots — reduces RecvError::Lagged risk) ────── let tx = state .map_rooms .entry(map_id.clone()) .or_insert_with(|| { - let (tx, _) = broadcast::channel(256); + let (tx, _) = broadcast::channel(1024); tx }) .clone(); let mut rx = tx.subscribe(); + // ── Per-map paint buffer (deferred DB writes) ───────────────────────────── + let paint_buffer = state + .map_paint_buffer + .entry(map_id.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(Vec::new()))) + .clone(); + + // Start the 100 ms DB-flush task exactly once per map_id. + state + .map_flush_tasks + .entry(map_id.clone()) + .or_insert_with(|| { + let buf = paint_buffer.clone(); + let mid = map_id.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_millis(100)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // Consume the initial immediate tick so the first flush waits 100 ms. + ticker.tick().await; + loop { + ticker.tick().await; + let cells: Vec = { + let mut guard = buf.lock().await; + if guard.is_empty() { + continue; + } + std::mem::take(&mut *guard) + }; + flush_paint_buffer(&mid, cells).await; + } + }); + true + }); + let (mut ws_tx, mut ws_rx) = socket.split(); let init_msg = ServerMessage::State { @@ -616,20 +686,52 @@ async fn handle_socket( let _ = ws_tx.send(Message::Text(json.into())).await; } + // ── Send task: forwards broadcast messages AND sends periodic pings ─────── + let map_id_for_log = map_id.clone(); let mut send_task = tokio::spawn(async move { - while let Ok(json) = rx.recv().await { - if ws_tx.send(Message::Text(json.into())).await.is_err() { - break; + let mut ping_interval = tokio::time::interval(Duration::from_secs(30)); + ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // Skip the first immediate tick so the first ping fires after 30 s. + ping_interval.tick().await; + + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(json) => { + if ws_tx.send(Message::Text(json.into())).await.is_err() { + break; + } + } + Err(RecvError::Lagged(n)) => { + // The receiver fell too far behind. Close the socket so the + // client reconnects and receives a fresh full-state message. + log::warn!( + "[WS] map {map_id_for_log}: receiver lagged by {n} messages — closing for reconnect" + ); + break; + } + Err(_) => break, // channel closed + } + } + _ = ping_interval.tick() => { + // Send a WebSocket ping to keep the connection alive through + // proxies and NAT that drop idle connections. + if ws_tx.send(Message::Ping(vec![].into())).await.is_err() { + break; + } + } } } }); + // ── Receive task: handles incoming client messages ──────────────────────── let tx_clone = tx.clone(); let mut recv_task = tokio::spawn(async move { while let Some(Ok(msg)) = ws_rx.next().await { match msg { Message::Text(text) => { - handle_client_message(&text, &map_id, editor, &tx_clone).await; + handle_client_message(&text, &map_id, editor, &tx_clone, paint_buffer.clone()).await; } Message::Close(_) => break, _ => {} @@ -664,11 +766,18 @@ async fn fetch_map_state(map_id: &str) -> crate::error::Result { Ok(MapState { map, cells, tokens }) } +/// Handles a single message received from a WebSocket client. +/// +/// `paint_buffer` — shared per-map buffer for deferred single-cell DB writes. +/// Single-cell paints are queued here (not written to the DB immediately) and +/// flushed every 100 ms by a background task, while still being broadcast +/// instantly to all connected clients. async fn handle_client_message( raw: &str, map_id: &str, can_edit: bool, tx: &broadcast::Sender, + paint_buffer: Arc>>, ) { let client_msg: ClientMessage = match serde_json::from_str(raw) { Ok(m) => m, @@ -691,26 +800,18 @@ async fn handle_client_message( let pool = siren_core::data::pool(); let server_msg: Option = match client_msg { + // Single-cell paints are queued for deferred DB persistence and + // broadcast immediately so all clients see the change in real time. ClientMessage::PaintCell { x, y, color } => { - let result = sqlx::query( - "INSERT INTO grid_cells (map_id, x, y, color) - VALUES ($1, $2, $3, $4) - ON CONFLICT (map_id, x, y) DO UPDATE SET color = EXCLUDED.color", - ) - .bind(map_id) - .bind(x) - .bind(y) - .bind(&color) - .execute(pool) - .await; - - match result { - Ok(_) => Some(ServerMessage::CellPainted { x, y, color }), - Err(e) => { - log::error!("DB error painting cell: {e}"); - None - } + { + let mut buf = paint_buffer.lock().await; + buf.push(CellPatch { + x, + y, + color: color.clone(), + }); } + Some(ServerMessage::CellPainted { x, y, color }) } ClientMessage::PaintCells { cells } => { @@ -795,6 +896,7 @@ async fn handle_client_message( y: token.y, label: token.label, color: token.color, + size: token.size, }), Err(e) => { log::error!("DB error adding token: {e}"); @@ -823,6 +925,45 @@ async fn handle_client_message( } } + ClientMessage::UpdateToken { id, label, color } => { + let result = + sqlx::query("UPDATE grid_tokens SET label = $1, color = $2 WHERE id = $3 AND map_id = $4") + .bind(&label) + .bind(&color) + .bind(&id) + .bind(map_id) + .execute(pool) + .await; + + match result { + Ok(r) if r.rows_affected() > 0 => Some(ServerMessage::TokenUpdated { id, label, color }), + Ok(_) => None, + Err(e) => { + log::error!("DB error updating token: {e}"); + None + } + } + } + + ClientMessage::ResizeToken { id, size } => { + let size = size.max(1).min(9); + let result = sqlx::query("UPDATE grid_tokens SET size = $1 WHERE id = $2 AND map_id = $3") + .bind(size) + .bind(&id) + .bind(map_id) + .execute(pool) + .await; + + match result { + Ok(r) if r.rows_affected() > 0 => Some(ServerMessage::TokenResized { id, size }), + Ok(_) => None, + Err(e) => { + log::error!("DB error resizing token: {e}"); + None + } + } + } + ClientMessage::DeleteToken { id } => { let result = sqlx::query("DELETE FROM grid_tokens WHERE id = $1 AND map_id = $2") .bind(&id) @@ -864,3 +1005,53 @@ async fn handle_client_message( } } } + +/// Persists a batch of buffered single-cell paints to the database. +/// +/// Deduplicates cells by coordinate (last write wins) before issuing the +/// upserts, so rapid repaints of the same cell only generate one DB write +/// per flush interval. +async fn flush_paint_buffer(map_id: &str, cells: Vec) { + if cells.is_empty() { + return; + } + + // Deduplicate: for the same (x, y) keep only the last color written. + let mut deduped: HashMap<(i32, i32), String> = HashMap::with_capacity(cells.len()); + for cell in cells { + deduped.insert((cell.x, cell.y), cell.color); + } + + let pool = siren_core::data::pool(); + let mut tx_db = match pool.begin().await { + Ok(t) => t, + Err(e) => { + log::error!("[flush] DB transaction error for map {map_id}: {e}"); + return; + } + }; + + for ((x, y), color) in &deduped { + let res = sqlx::query( + "INSERT INTO grid_cells (map_id, x, y, color) + VALUES ($1, $2, $3, $4) + ON CONFLICT (map_id, x, y) DO UPDATE SET color = EXCLUDED.color", + ) + .bind(map_id) + .bind(x) + .bind(y) + .bind(color) + .execute(&mut *tx_db) + .await; + + if let Err(e) = res { + log::error!("[flush] DB error for map {map_id} cell ({x},{y}): {e}"); + let _ = tx_db.rollback().await; + return; + } + } + + if let Err(e) = tx_db.commit().await { + log::error!("[flush] DB commit error for map {map_id}: {e}"); + } +} diff --git a/crates/siren-api/src/grid/model.rs b/crates/siren-api/src/grid/model.rs index a96e90f..2f6cbcb 100644 --- a/crates/siren-api/src/grid/model.rs +++ b/crates/siren-api/src/grid/model.rs @@ -41,6 +41,12 @@ pub struct GridMap { pub public_access: String, pub owner_id: Uuid, pub colors: Vec, + /// Real-world units represented by one grid square + pub units_per_square: i32, + /// Label for the unit, e.g. "ft" or "m". + pub unit_label: String, + /// Diagonal movement rule: "free" or "alternating" + pub movement_rule: String, pub created_at: DateTime, pub updated_at: DateTime, } @@ -56,6 +62,9 @@ pub struct ListedMap { pub owner_id: Uuid, pub owner_username: String, pub colors: Vec, + pub units_per_square: i32, + pub unit_label: String, + pub movement_rule: String, pub created_at: DateTime, pub updated_at: DateTime, /// The authenticated caller's role on this map, or NULL if they only have it @@ -80,6 +89,9 @@ fn default_private() -> String { pub struct UpdateMapPayload { pub name: Option, pub public_access: Option, + pub units_per_square: Option, + pub unit_label: Option, + pub movement_rule: Option, } #[derive(Deserialize, Clone, Debug)] @@ -138,6 +150,12 @@ pub struct GridToken { pub y: i32, pub label: String, pub color: String, + #[serde(default = "default_token_size")] + pub size: i32, +} + +fn default_token_size() -> i32 { + 1 } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -176,6 +194,15 @@ pub enum ClientMessage { DeleteToken { id: String, }, + UpdateToken { + id: String, + label: String, + color: String, + }, + ResizeToken { + id: String, + size: i32, + }, UpdateColors { colors: Vec, }, @@ -207,6 +234,7 @@ pub enum ServerMessage { y: i32, label: String, color: String, + size: i32, }, TokenMoved { id: String, @@ -216,6 +244,15 @@ pub enum ServerMessage { TokenDeleted { id: String, }, + TokenUpdated { + id: String, + label: String, + color: String, + }, + TokenResized { + id: String, + size: i32, + }, ColorsUpdated { colors: Vec, }, diff --git a/crates/siren-bot/src/commands/audio/play.rs b/crates/siren-bot/src/commands/audio/play.rs index c988655..471c4e3 100644 --- a/crates/siren-bot/src/commands/audio/play.rs +++ b/crates/siren-bot/src/commands/audio/play.rs @@ -69,7 +69,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { "<{guild_id}> Play command executed on channel {channel_id} with track: {track_url:?}" ); // Handle the track url - match enqueue_track(manager, guild_id.to_owned(), track_url).await { + match enqueue_track(manager, guild_id.to_owned(), track_url, false).await { Ok(items) => { let mut message = format!("Added {} tracks", items.len()); if items.is_empty() { @@ -103,45 +103,59 @@ pub async fn enqueue_track( manager: &Arc, guild_id: GuildId, track_url: &str, + loop_enabled: bool, ) -> Result> { - let mut playlist_items: Vec = Vec::new(); - if let Some(handler_lock) = manager.get(guild_id) { - let mut handler = handler_lock.lock().await; - let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap(); - let valid = is_valid_url(track_url); + // Validate URL before doing any I/O + if !is_valid_url(track_url) { + log::warn!("<{guild_id}> Invalid track url: {}", track_url); + return Err(Error::new(422, format!("Invalid track url: {}", track_url))); + } - // Check if the URL is valid - if !valid { - log::warn!("<{guild_id}> Invalid track url: {}", track_url); - return Err(Error::new(422, format!("Invalid track url: {}", track_url))); - } + // Verify there is an active voice session + if manager.get(guild_id).is_none() { + return Ok(Vec::new()); + } - playlist_items = get_ytdlp_items(track_url)?; + // Fetch yt-dlp metadata + let playlist_items = get_ytdlp_items(track_url).await?; + if playlist_items.is_empty() { + return Ok(playlist_items); + } - // Collect TrackInfo for the queue store before borrowing `item` in the loop - let track_infos: Vec = playlist_items - .iter() - .map(|item| TrackInfo { - title: item.get_title().to_owned(), - url: item.get_url().to_owned(), - }) - .collect(); + // Fetch guild config + let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap(); + let volume = guild.volume as f32 / 100.0; - // Add each track to the queue - for item in &playlist_items { - let volume = guild.volume as f32 / 100.0; - let http_client = get_client(); + // Store track metadata + let track_infos: Vec = playlist_items + .iter() + .map(|item| TrackInfo { + title: item.get_title().to_owned(), + url: item.get_url().to_owned(), + duration_secs: item.get_duration(), + loop_enabled, + }) + .collect(); + enqueue_tracks(guild_id.get(), track_infos); + // Enqueue the tracks + let http_client = get_client(); + for item in &playlist_items { + if let Some(handler_lock) = manager.get(guild_id) { let source = YoutubeDl::new(http_client.to_owned(), item.get_url().to_owned()); let input: Input = source.into(); let track_title = item.get_title().to_owned(); + let mut handler = handler_lock.lock().await; let track_handle: TrackHandle = handler.enqueue_input(input).await; - - // Set the volume - let _ = track_handle.set_volume(volume); - - log::debug!("<{guild_id}> Added track: {}", track_title); + if let Err(err) = track_handle.set_volume(volume) { + log::warn!("Failed to set volume for track {}: {}", track_title, err); + }; + if loop_enabled { + if let Err(err) = track_handle.enable_loop() { + log::warn!("Failed to enable loop for track {}: {}", track_title, err); + }; + } handler.remove_all_global_events(); handler.add_global_event( Event::Track(TrackEvent::End), @@ -150,25 +164,23 @@ pub async fn enqueue_track( call: manager.clone(), }, ); - } - - // Store track metadata so the REST API can expose queue info - enqueue_tracks(guild_id.get(), track_infos); - - if handler.queue().is_empty() { - let _ = handler.queue().resume(); + // Release the lock + drop(handler); + log::debug!("<{guild_id}> Added track: {}", track_title); } } + Ok(playlist_items) } -pub fn get_ytdlp_items(url: &str) -> Result> { +pub async fn get_ytdlp_items(url: &str) -> Result> { let output = YtDlp::new() .arg("--flat-playlist") .arg("--dump-json") .arg("--no-check-formats") .arg(url) - .execute()?; + .execute() + .await?; // Check if yt-dlp exited successfully; log stderr if not if !output.status.success() { diff --git a/crates/siren-bot/src/commands/audio/queue.rs b/crates/siren-bot/src/commands/audio/queue.rs index 80e0fdc..b21b81a 100644 --- a/crates/siren-bot/src/commands/audio/queue.rs +++ b/crates/siren-bot/src/commands/audio/queue.rs @@ -13,6 +13,10 @@ use std::{ pub struct TrackInfo { pub title: String, pub url: String, + /// Total duration in seconds, if known (from yt-dlp metadata). + pub duration_secs: Option, + /// Whether this track should loop indefinitely. + pub loop_enabled: bool, } /// Global map of guild_id → ordered queue of TrackInfo. @@ -21,9 +25,7 @@ static TRACK_QUEUES: OnceLock>>> = OnceLock /// Call once from the `ready` event handler to initialise the store. pub fn init_track_queues() { - TRACK_QUEUES - .set(Arc::new(DashMap::new())) - .ok(); + TRACK_QUEUES.set(Arc::new(DashMap::new())).ok(); } /// Returns a reference to the global TRACK_QUEUES map. @@ -61,9 +63,7 @@ pub fn clear_queue(guild_id: u64) { pub fn get_queue(guild_id: u64) -> Vec { queues() .get(&guild_id) - .map(|q: dashmap::mapref::one::Ref>| { - q.iter().cloned().collect() - }) + .map(|q: dashmap::mapref::one::Ref>| q.iter().cloned().collect()) .unwrap_or_default() } @@ -86,3 +86,61 @@ pub async fn get_is_paused(guild_id: u64) -> bool { } false } + +/// Toggle or set loop on the currently playing track +pub async fn set_loop_current(guild_id: u64, enabled: bool) -> bool { + // Update our metadata store first + let updated = { + if let Some(mut q) = queues().get_mut(&guild_id) { + if let Some(front) = q.front_mut() { + front.loop_enabled = enabled; + true + } else { + false + } + } else { + false + } + }; + if !updated { + return false; + } + + // Tell songbird to loop / unloop the live track handle + let manager = get_songbird(); + let serenity_guild_id = GuildId::from(guild_id); + if let Some(handler_lock) = manager.get(serenity_guild_id) { + let handler = handler_lock.lock().await; + let current = handler.queue().current(); + drop(handler); + if let Some(track) = current { + if enabled { + let _ = track.enable_loop(); + } else { + let _ = track.disable_loop(); + } + return true; + } + } + false +} + +/// Returns the current playback position (in seconds) for the active track in the +/// given guild, or `0.0` if nothing is playing or the info is unavailable. +pub async fn get_current_position(guild_id: u64) -> f64 { + let manager = get_songbird(); + let serenity_guild_id = GuildId::from(guild_id); + if let Some(handler_lock) = manager.get(serenity_guild_id) { + let handler = handler_lock.lock().await; + let current = handler.queue().current(); + drop(handler); + if let Some(track) = current { + return track + .get_info() + .await + .map(|info| info.position.as_secs_f64()) + .unwrap_or(0.0); + } + } + 0.0 +} diff --git a/crates/siren-bot/src/commands/audio/skip.rs b/crates/siren-bot/src/commands/audio/skip.rs index 9611c18..49ef2a7 100644 --- a/crates/siren-bot/src/commands/audio/skip.rs +++ b/crates/siren-bot/src/commands/audio/skip.rs @@ -44,12 +44,10 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn skip_track(manager: &Arc, guild_id: &GuildId) -> Result<(), String> { if let Some(handler_lock) = manager.get(guild_id.to_owned()) { let handler = handler_lock.lock().await; - handler - .queue() - .skip() - .map_err(|e| e.to_string())?; + handler.queue().skip().map_err(|e| e.to_string())?; // Pop the current track from our metadata store; the next track (if any) moves to front pop_front(guild_id.get()); + drop(handler); Ok(()) } else { Err("No active audio session in this guild".to_string()) diff --git a/crates/siren-bot/src/handler.rs b/crates/siren-bot/src/handler.rs index f72d116..8761ea7 100644 --- a/crates/siren-bot/src/handler.rs +++ b/crates/siren-bot/src/handler.rs @@ -1,7 +1,10 @@ use super::{chat::create_modal_response, commands}; use crate::{ HttpKey, - commands::{audio::queue::init_track_queues, fun::roll::{format_roll, roll_dice, send_roll_message}}, + commands::{ + audio::queue::init_track_queues, + fun::roll::{format_roll, roll_dice, send_roll_message}, + }, }; use serenity::{ all::{ @@ -97,9 +100,7 @@ impl EventHandler for BotHandler { } } - async fn resume(&self, _: Context, _: ResumedEvent) { - log::trace!("Resumed"); - } + async fn resume(&self, _: Context, _: ResumedEvent) {} async fn interaction_create(&self, ctx: Context, interaction: Interaction) { if let Interaction::Command(command) = interaction { diff --git a/crates/siren-bot/src/ytdlp/mod.rs b/crates/siren-bot/src/ytdlp/mod.rs index a872c25..749eece 100644 --- a/crates/siren-bot/src/ytdlp/mod.rs +++ b/crates/siren-bot/src/ytdlp/mod.rs @@ -1,12 +1,11 @@ mod model; pub use model::*; -use std::process::{Child, Command, Output, Stdio}; +use std::process::{Output, Stdio}; const YOUTUBE_DL_COMMAND: &str = "yt-dlp"; pub struct YtDlp { - command: Command, args: Vec, } @@ -17,29 +16,26 @@ impl Default for YtDlp { } impl YtDlp { + /// Create a new yt-dlp command builder pub fn new() -> Self { - let mut cmd = Command::new(YOUTUBE_DL_COMMAND); - cmd - .env("LC_ALL", "en_US.UTF-8") - .stdout(Stdio::piped()) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()); - Self { - command: cmd, - args: Vec::new(), - } + Self { args: Vec::new() } } + /// Add an argument to the yt-dlp command pub fn arg(&mut self, arg: &str) -> &mut Self { self.args.push(arg.to_owned()); self } - pub fn execute(&mut self) -> std::io::Result { - self - .command - .args(self.args.clone()) - .spawn() - .and_then(Child::wait_with_output) + /// Execute the yt-dlp command asynchronously + pub async fn execute(&mut self) -> std::io::Result { + tokio::process::Command::new(YOUTUBE_DL_COMMAND) + .env("LC_ALL", "en_US.UTF-8") + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .args(&self.args) + .output() + .await } } diff --git a/crates/siren-bot/src/ytdlp/model.rs b/crates/siren-bot/src/ytdlp/model.rs index 88babce..e618ad5 100644 --- a/crates/siren-bot/src/ytdlp/model.rs +++ b/crates/siren-bot/src/ytdlp/model.rs @@ -32,4 +32,11 @@ impl YtDlpItem { YtDlpItem::VideoItem { webpage_url, .. } => webpage_url, } } + + pub fn get_duration(&self) -> Option { + match self { + YtDlpItem::PlaylistItem { duration, .. } => *duration, + YtDlpItem::VideoItem { duration, .. } => *duration, + } + } } diff --git a/crates/siren/src/main.rs b/crates/siren/src/main.rs index d7b81a4..2e7b38e 100644 --- a/crates/siren/src/main.rs +++ b/crates/siren/src/main.rs @@ -43,6 +43,8 @@ async fn main() -> std::result::Result<(), Box> { http: Arc::clone(&client.http), cache: Arc::clone(&client.cache), map_rooms: Arc::new(DashMap::new()), + map_paint_buffer: Arc::new(DashMap::new()), + map_flush_tasks: Arc::new(DashMap::new()), }; log::debug!( diff --git a/migrations/000_initial.sql b/migrations/000_initial.sql index 5ca3d4c..f2da865 100644 --- a/migrations/000_initial.sql +++ b/migrations/000_initial.sql @@ -4,73 +4,7 @@ CREATE TABLE IF NOT EXISTS guilds ( owner_id BIGINT, volume INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS dice_track ( - id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), - guild_id BIGINT NOT NULL, - owner_id BIGINT NOT NULL, - dice TEXT NOT NULL, - user_id BIGINT, - value INT, - operator TEXT -); -CREATE TABLE IF NOT EXISTS events ( - id UUID PRIMARY KEY NOT NULL, - guild_id BIGINT NOT NULL, - author_id BIGINT NOT NULL, - title TEXT NOT NULL, - date_time TIMESTAMPTZ NOT NULL, - description TEXT, - rsvp BIGINT[] NOT NULL -); -CREATE TABLE IF NOT EXISTS races ( - id INTEGER GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL, - size TEXT NOT NULL, - source TEXT NOT NULL, - data JSON NOT NULL -); -CREATE TABLE IF NOT EXISTS classes ( - id INTEGER GENERATED ALWAYS AS IDENTITY -); -CREATE TABLE IF NOT EXISTS feats ( - id INTEGER GENERATED ALWAYS AS IDENTITY -); -CREATE TABLE IF NOT EXISTS options_features ( - id INTEGER GENERATED ALWAYS AS IDENTITY -); -CREATE TABLE IF NOT EXISTS backgrounds ( - id INTEGER GENERATED ALWAYS AS IDENTITY -); -CREATE TABLE IF NOT EXISTS items ( - id INTEGER GENERATED ALWAYS AS IDENTITY -); -CREATE TABLE IF NOT EXISTS spells ( - id INTEGER GENERATED ALWAYS AS IDENTITY, - name TEXT NOT NULL, - school TEXT NOT NULL, - level INTEGER NOT NULL, - ritual BOOLEAN DEFAULT FALSE, - concentration BOOLEAN DEFAULT FALSE, - classes TEXT[] NOT NULL, - damage_inflict TEXT[] NOT NULL, - damage_resist TEXT[] NOT NULL, - conditions TEXT[] NOT NULL, - saving_throw TEXT[] NOT NULL, - attack_type TEXT, - data JSONB NOT NULL -); -CREATE TABLE IF NOT EXISTS conditions ( - id INTEGER GENERATED ALWAYS AS IDENTITY -); -CREATE TABLE IF NOT EXISTS bestiary ( - id INTEGER GENERATED ALWAYS AS IDENTITY -); --- ============================================================ --- Auth / Users --- ============================================================ - --- Core local user accounts. password_hash is NULL for OAuth-only users. CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), username TEXT UNIQUE NOT NULL, @@ -84,7 +18,6 @@ CREATE TABLE IF NOT EXISTS users ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- External OAuth provider connections (Discord, etc.) CREATE TABLE IF NOT EXISTS user_connections ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, @@ -94,71 +27,3 @@ CREATE TABLE IF NOT EXISTS user_connections ( PRIMARY KEY (user_id, provider), UNIQUE (provider, provider_user_id) ); - -CREATE TABLE IF NOT EXISTS grid_maps ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - public_access TEXT NOT NULL DEFAULT 'private' - CHECK (public_access IN ('private', 'public_view', 'public_edit')), - owner_id UUID NOT NULL REFERENCES users(id), - colors TEXT[] NOT NULL DEFAULT ARRAY[ - '#6b7280', - '#92400e', - '#15803d', - '#1d4ed8', - '#7c3aed', - '#dc2626', - '#ca8a04', - '#0f172a', - '#f9fafb' - ], - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Per-map role assignments; owner is auto-inserted on map creation -CREATE TABLE IF NOT EXISTS map_permissions ( - map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')), - PRIMARY KEY (map_id, user_id) -); - --- Maps a user has favorited; makes them appear in the user's map list modal --- even if they have no explicit map_permissions entry (e.g. public maps) -CREATE TABLE IF NOT EXISTS map_favorites ( - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, map_id) -); - --- Pending/resolved requests from users wanting viewer or editor access -CREATE TABLE IF NOT EXISTS map_access_requests ( - id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), - map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')), - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (map_id, user_id) -); - --- Composite primary key replaces the old UUID id column -CREATE TABLE IF NOT EXISTS grid_cells ( - map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, - x INTEGER NOT NULL, - y INTEGER NOT NULL, - color TEXT NOT NULL DEFAULT '#808080', - PRIMARY KEY (map_id, x, y) -); - -CREATE TABLE IF NOT EXISTS grid_tokens ( - id TEXT PRIMARY KEY NOT NULL, - map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, - x INTEGER NOT NULL, - y INTEGER NOT NULL, - label TEXT NOT NULL, - color TEXT NOT NULL DEFAULT '#4444FF' -); diff --git a/migrations/001_map.sql b/migrations/001_map.sql new file mode 100644 index 0000000..21dd624 --- /dev/null +++ b/migrations/001_map.sql @@ -0,0 +1,67 @@ +CREATE TABLE IF NOT EXISTS grid_maps ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + public_access TEXT NOT NULL DEFAULT 'private' + CHECK (public_access IN ('private', 'public_view', 'public_edit')), + owner_id UUID NOT NULL REFERENCES users(id), + colors TEXT[] NOT NULL DEFAULT ARRAY[ + '#6b7280', + '#92400e', + '#15803d', + '#1d4ed8', + '#7c3aed', + '#dc2626', + '#ca8a04', + '#0f172a', + '#f9fafb' + ], + units_per_square INTEGER NOT NULL DEFAULT 5, + unit_label TEXT NOT NULL DEFAULT 'ft', + movement_rule TEXT NOT NULL DEFAULT 'free', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS grid_cells ( + map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + color TEXT NOT NULL DEFAULT '#808080', + PRIMARY KEY (map_id, x, y) +); + +CREATE TABLE IF NOT EXISTS grid_tokens ( + id TEXT PRIMARY KEY NOT NULL, + map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, + size INTEGER NOT NULL DEFAULT 1, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + label TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#4444FF' +); + +CREATE TABLE IF NOT EXISTS map_permissions ( + map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')), + PRIMARY KEY (map_id, user_id) +); + +CREATE TABLE IF NOT EXISTS map_favorites ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, map_id) +); + +-- Pending/resolved requests from users wanting viewer or editor access +CREATE TABLE IF NOT EXISTS map_access_requests ( + id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (map_id, user_id) +); diff --git a/migrations/002_dnd.sql b/migrations/002_dnd.sql new file mode 100644 index 0000000..dec2030 --- /dev/null +++ b/migrations/002_dnd.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS races ( + id INTEGER GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL, + size TEXT NOT NULL, + source TEXT NOT NULL, + data JSON NOT NULL +); +CREATE TABLE IF NOT EXISTS classes ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); +CREATE TABLE IF NOT EXISTS feats ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); +CREATE TABLE IF NOT EXISTS options_features ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); +CREATE TABLE IF NOT EXISTS backgrounds ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); +CREATE TABLE IF NOT EXISTS items ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); +CREATE TABLE IF NOT EXISTS spells ( + id INTEGER GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL, + school TEXT NOT NULL, + level INTEGER NOT NULL, + ritual BOOLEAN DEFAULT FALSE, + concentration BOOLEAN DEFAULT FALSE, + classes TEXT[] NOT NULL, + damage_inflict TEXT[] NOT NULL, + damage_resist TEXT[] NOT NULL, + conditions TEXT[] NOT NULL, + saving_throw TEXT[] NOT NULL, + attack_type TEXT, + data JSONB NOT NULL +); +CREATE TABLE IF NOT EXISTS conditions ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); +CREATE TABLE IF NOT EXISTS bestiary ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/migrations/003_misc.sql b/migrations/003_misc.sql new file mode 100644 index 0000000..e065462 --- /dev/null +++ b/migrations/003_misc.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS dice_track ( + id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + guild_id BIGINT NOT NULL, + owner_id BIGINT NOT NULL, + dice TEXT NOT NULL, + user_id BIGINT, + value INT, + operator TEXT +); +CREATE TABLE IF NOT EXISTS events ( + id UUID PRIMARY KEY NOT NULL, + guild_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + title TEXT NOT NULL, + date_time TIMESTAMPTZ NOT NULL, + description TEXT, + rsvp BIGINT[] NOT NULL +); \ No newline at end of file diff --git a/ui/src/api.ts b/ui/src/api.ts index 83d9c37..1832067 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -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 => request(`${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 => request(`${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 => - request(`${GRID_BASE}/maps/${mapId}/access-requests`, { + request(`${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 => - request(`${GRID_BASE}/maps/${mapId}/access-requests`), + request(`${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 => - request(`${GRID_BASE}/maps/${mapId}/access-requests/${requestId}`, { + request(`${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 { try { - return await request(`${AUTH_BASE}/me`); + return await request(`${AUTH_BASE}/user`); } catch { return null; } @@ -216,8 +223,8 @@ export const auth = { currentPassword: string | null, newPassword: string, ): Promise { - await request(`${AUTH_BASE}/change-password`, { - method: "POST", + await request(`${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(`${AUDIO_BASE}/${guildId}/status`), /** Enqueue a track URL for playback (bot joins the caller's voice channel). */ - play: (guildId: string, url: string): Promise => + play: (guildId: string, url: string, loopEnabled = false): Promise => request(`${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 => request(`${AUDIO_BASE}/${guildId}/skip`, { method: "POST" }), + + /** Enable or disable looping on the currently-playing track. */ + setLoop: (guildId: string, enabled: boolean): Promise => + request(`${AUDIO_BASE}/${guildId}/loop`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }), }; diff --git a/ui/src/components/ControlPanel.css b/ui/src/components/ControlPanel.css index c9eae59..86adfb9 100644 --- a/ui/src/components/ControlPanel.css +++ b/ui/src/components/ControlPanel.css @@ -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; +} diff --git a/ui/src/components/ControlPanel.tsx b/ui/src/components/ControlPanel.tsx index 7481570..31838cc 100644 --- a/ui/src/components/ControlPanel.tsx +++ b/ui/src/components/ControlPanel.tsx @@ -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: , 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: , - 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 ( -
- {TOOLS.map((t) => ( + <> + {/* Undo / Redo mini-panel — sits above the tool panel */} +
- ))} -
+ +
+ + {/* Tool selection panel */} +
+ {TOOLS.map((t) => ( + + ))} +
+ ); } diff --git a/ui/src/components/DiscordPanel.css b/ui/src/components/DiscordPanel.css index 866facc..8f22ece 100644 --- a/ui/src/components/DiscordPanel.css +++ b/ui/src/components/DiscordPanel.css @@ -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; diff --git a/ui/src/components/DiscordPanel.tsx b/ui/src/components/DiscordPanel.tsx index 824d49e..f8ced6c 100644 --- a/ui/src/components/DiscordPanel.tsx +++ b/ui/src/components/DiscordPanel.tsx @@ -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(null); const pollRef = useRef | null>(null); + // ── Progress tracking ── + // Interpolated playback position, ticked client-side between polls + const [positionSecs, setPositionSecs] = useState(0); + const [durationSecs, setDurationSecs] = useState(null); + const tickRef = useRef | null>(null); + // Used to detect when the track changes so we can reset position + const currentTrackTitleRef = useRef(null); + // ── Play input ── const [playUrl, setPlayUrl] = useState(""); + const [loopOnAdd, setLoopOnAdd] = useState(false); const [playLoading, setPlayLoading] = useState(false); const [playError, setPlayError] = useState(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 (
@@ -140,9 +200,7 @@ export default function DiscordPanel({ user }: Props) { ) : guildsError ? (

{guildsError}

) : guilds.length === 0 ? ( -

- The bot isn't in any servers yet. -

+

The bot isn't in any servers yet.

) : guilds.length === 1 ? (

{guilds[0].name}

) : ( @@ -193,6 +251,24 @@ export default function DiscordPanel({ user }: Props) {

{status.is_paused ? "⏸ Paused" : "▶ Playing"}

+ {/* Progress bar */} +
+
0 + ? `${Math.min((positionSecs / durationSecs) * 100, 100)}%` + : "0%", + }} + /> +
+
+ {formatTime(positionSecs)} + {durationSecs !== null && ( + {formatTime(durationSecs)} + )} +
) : (

Nothing is playing

@@ -239,6 +315,25 @@ export default function DiscordPanel({ user }: Props) { > +
{actionError &&

{actionError}

} @@ -248,7 +343,14 @@ export default function DiscordPanel({ user }: Props) { {/* Queue */} {selectedGuild && (
-

Queue {status && status.queue.length > 0 && ({status.queue.length})}

+

+ Queue{" "} + {status && status.queue.length > 0 && ( + + ({status.queue.length}) + + )} +

{!status || status.queue.length === 0 ? (

Queue is empty

) : ( @@ -287,6 +389,14 @@ export default function DiscordPanel({ user }: Props) { {playLoading ? "Adding…" : "Add"} + {playError &&

{playError}

}
)} diff --git a/ui/src/components/EditMapModal.css b/ui/src/components/EditMapModal.css index 12f0377..2086226 100644 --- a/ui/src/components/EditMapModal.css +++ b/ui/src/components/EditMapModal.css @@ -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; +} diff --git a/ui/src/components/EditMapModal.tsx b/ui/src/components/EditMapModal.tsx index 6c42f05..2fad0b9 100644 --- a/ui/src/components/EditMapModal.tsx +++ b/ui/src/components/EditMapModal.tsx @@ -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( map.public_access, ); + const [unitsPerSquare, setUnitsPerSquare] = useState(map.units_per_square); + const [unitLabel, setUnitLabel] = useState(map.unit_label); + const [movementRule, setMovementRule] = useState( + map.movement_rule, + ); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(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) { /> + {/* ── Movement ruler ── */} +
+ Movement Ruler + +
+ +
+ +
+ +
+
+
Visibility diff --git a/ui/src/components/Grid.css b/ui/src/components/Grid.css index e5eebdf..2209e91 100644 --- a/ui/src/components/Grid.css +++ b/ui/src/components/Grid.css @@ -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); +} diff --git a/ui/src/components/Grid.tsx b/ui/src/components/Grid.tsx index 6b9b2fc..d4ac32b 100644 --- a/ui/src/components/Grid.tsx +++ b/ui/src/components/Grid.tsx @@ -10,11 +10,13 @@ import type { GridCell, GridToken, Tool, + MovementRule, ServerMessage, ClientMessage, } from "../types"; import { useWebSocket } from "../hooks/useWebSocket"; import TokenDialog from "./TokenDialog"; +import TokenStackPicker from "./TokenStackPicker"; import "./Grid.css"; const DEFAULT_ZOOM = 40; @@ -32,28 +34,51 @@ const MAX_FLOOD_CELLS = 2500; /** World units per second for WASD keyboard panning. */ const WASD_PAN_SPEED = 12; +/** Maximum token size (NxN). */ +const MAX_TOKEN_SIZE = 9; + interface Camera { offsetX: number; offsetY: number; zoom: number; } +interface UndoAction { + revert: () => void; + apply: () => void; +} + interface Props { mapId: string; tool: Tool; paintColor: string; tokenColor: string; onColorsLoaded: (colors: string[]) => void; + /** How many real-world units one grid square represents (default 5). */ + unitsPerSquare: number; + /** Label for the unit, e.g. "ft" or "m". */ + unitLabel: string; + /** Which diagonal movement rule to use. */ + movementRule: MovementRule; + /** Called whenever the undo/redo stack availability changes. */ + onUndoStateChange?: (canUndo: boolean, canRedo: boolean) => void; } export interface GridHandle { sendColorUpdate: (colors: string[]) => void; + undo: () => void; + redo: () => void; } function cellKey(x: number, y: number): string { return `${x},${y}`; } +function parseCellKey(key: string): { x: number; y: number } { + const [xs, ys] = key.split(","); + return { x: Number(xs), y: Number(ys) }; +} + function canvasToCell( cx: number, cy: number, @@ -65,6 +90,25 @@ function canvasToCell( }; } +/** + * Convert a canvas point to the token anchor whose CENTER is closest to the cursor. + * For a size-N token: anchor = round(cursorWorld - N/2). + * For size=1 this is equivalent to canvasToCell (Math.floor). + */ +function canvasToCenterAnchor( + cx: number, + cy: number, + size: number, + cam: Camera, +): { x: number; y: number } { + const worldX = cx / cam.zoom + cam.offsetX; + const worldY = cy / cam.zoom + cam.offsetY; + return { + x: Math.round(worldX - size / 2), + y: Math.round(worldY - size / 2), + }; +} + function cellToCanvas( cellX: number, cellY: number, @@ -83,12 +127,14 @@ function drawToken( label: string, color: string, cam: Camera, + size: number = 1, ) { const zoom = cam.zoom; const { x: px, y: py } = cellToCanvas(cellX, cellY, cam); - const cx = px + zoom / 2; - const cy = py + zoom / 2; - const r = zoom * 0.38; + // Center of the NxN area + const cx = px + (zoom * size) / 2; + const cy = py + (zoom * size) / 2; + const r = zoom * size * 0.38; ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 5; @@ -111,21 +157,67 @@ function drawToken( ? (words[0][0] + words[1][0]).toUpperCase() : label.slice(0, 2).toUpperCase(); ctx.fillStyle = "#ffffff"; - ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`; + ctx.font = `bold ${Math.round(zoom * size * 0.3)}px system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(initials, cx, cy); } } +/** Draw a small count badge at the top-right of the token circle. */ +function drawStackBadge( + ctx: CanvasRenderingContext2D, + cellX: number, + cellY: number, + count: number, + cam: Camera, + size: number = 1, +) { + const zoom = cam.zoom; + const { x: px, y: py } = cellToCanvas(cellX, cellY, cam); + const tokenR = zoom * size * 0.38; + // Badge centre: top-right of the token circle + const bx = px + (zoom * size) / 2 + tokenR * 0.68; + const by = py + (zoom * size) / 2 - tokenR * 0.68; + const br = Math.max(5, zoom * 0.18); + + ctx.beginPath(); + ctx.arc(bx, by, br, 0, Math.PI * 2); + ctx.fillStyle = "#ef4444"; + ctx.fill(); + ctx.strokeStyle = "#1f2937"; + ctx.lineWidth = Math.max(1, zoom * 0.025); + ctx.stroke(); + + if (zoom >= 14) { + ctx.fillStyle = "#ffffff"; + ctx.font = `bold ${Math.round(br * 1.2)}px system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(String(count), bx, by); + } +} + +/** + * Calculate movement distance in grid squares using D&D rules. + */ +function calcDndSquares(dx: number, dy: number, rule: MovementRule): number { + const adx = Math.abs(dx); + const ady = Math.abs(dy); + const straight = Math.abs(adx - ady); + const diagonal = Math.min(adx, ady); + if (rule === "free") { + return straight + diagonal; + } + return straight + diagonal + Math.floor(diagonal / 2); +} + function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } /** - * BFS flood fill from (startX, startY) through uncolored cells. - * Returns the list of cells to fill, or null if the region is unbounded - * (search exceeded MAX_FLOOD_CELLS). + * BFS flood fill from (startX, startY) through uncolored (empty) cells. */ function floodFill( startX: number, @@ -149,7 +241,7 @@ function floodFill( const { x, y } = queue.shift()!; found.push({ x, y }); - if (found.length > MAX_FLOOD_CELLS) return null; // unbounded + if (found.length > MAX_FLOOD_CELLS) return null; for (const { dx, dy } of dirs) { const nx = x + dx; @@ -166,8 +258,53 @@ function floodFill( } /** - * Clamp the camera so at least one colored cell or token remains visible in - * the current viewport. No-op when the map has no content yet. + * BFS flood fill from (startX, startY) through cells matching targetColor. + * Used for shift+click color replacement. + */ +function floodFillColored( + startX: number, + startY: number, + targetColor: string, + cells: Map, +): Array<{ x: number; y: number }> | null { + const visited = new Set(); + const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }]; + const found: Array<{ x: number; y: number }> = []; + + visited.add(cellKey(startX, startY)); + + const dirs = [ + { dx: 1, dy: 0 }, + { dx: -1, dy: 0 }, + { dx: 0, dy: 1 }, + { dx: 0, dy: -1 }, + ]; + + while (queue.length > 0) { + const { x, y } = queue.shift()!; + found.push({ x, y }); + + if (found.length > MAX_FLOOD_CELLS) return null; + + for (const { dx, dy } of dirs) { + const nx = x + dx; + const ny = y + dy; + const key = cellKey(nx, ny); + if (!visited.has(key)) { + const neighbor = cells.get(key); + if (neighbor && neighbor.color === targetColor) { + visited.add(key); + queue.push({ x: nx, y: ny }); + } + } + } + } + + return found; +} + +/** + * Clamp the camera so at least one colored cell or token remains visible. */ function clampCameraToContent( cam: Camera, @@ -183,7 +320,6 @@ function clampCameraToContent( const viewTop = cam.offsetY; const viewBottom = cam.offsetY + canvasH / cam.zoom; - // Quick visibility check let anyVisible = false; cellLoop: for (const cell of cells.values()) { @@ -214,7 +350,6 @@ function clampCameraToContent( if (anyVisible) return; - // Find the bounding box of all content let minX = Infinity, maxX = -Infinity; let minY = Infinity, @@ -236,16 +371,12 @@ function clampCameraToContent( const viewW = canvasW / cam.zoom; const viewH = canvasH / cam.zoom; - // X: bring the nearest content edge to the nearest viewport edge if (maxX + 1 <= viewLeft) { - // All content is to the left — show rightmost cell at the left edge cam.offsetX = maxX; } else if (minX >= viewRight) { - // All content is to the right — show leftmost cell at the right edge cam.offsetX = minX - viewW + 1; } - // Y: same logic if (maxY + 1 <= viewTop) { cam.offsetY = maxY; } else if (minY >= viewBottom) { @@ -254,7 +385,17 @@ function clampCameraToContent( } const Grid = forwardRef(function Grid( - { mapId, tool, paintColor, tokenColor, onColorsLoaded }, + { + mapId, + tool, + paintColor, + tokenColor, + onColorsLoaded, + unitsPerSquare, + unitLabel, + movementRule, + onUndoStateChange, + }, ref, ) { const containerRef = useRef(null); @@ -272,8 +413,9 @@ const Grid = forwardRef(function Grid( const [tick, setTick] = useState(0); const redraw = useCallback(() => setTick((n) => n + 1), []); - // ---- Mouse interaction state (refs to avoid stale closures) ---- + // ── Mouse interaction state ── const isPanning = useRef(false); + const middleMousePanning = useRef(false); const panStart = useRef<{ mx: number; my: number; @@ -286,24 +428,119 @@ const Grid = forwardRef(function Grid( const isDragging = useRef(false); const dragTokenId = useRef(null); const dragCellPos = useRef<{ x: number; y: number } | null>(null); + const dragStartCell = useRef<{ x: number; y: number } | null>(null); + const dragTokenPrevPos = useRef<{ x: number; y: number } | null>(null); - // ---- WASD state ---- + // ── Multi-token drag (move stack) ── + const dragTokenIds = useRef([]); + const dragStackPrevPositions = useRef< + Array<{ id: string; x: number; y: number }> + >([]); + + /** Size of the token currently being dragged — used for center-anchor snapping. */ + const dragTokenSize = useRef(1); + + // ── Token resize ── + const isResizing = useRef(false); + const resizeTokenId = useRef(null); + const resizeTokenOriginalSize = useRef(1); + /** World-coordinate center of the token at the moment resize started. */ + const resizeTokenCenter = useRef<{ x: number; y: number } | null>(null); + + // ── Stroke tracking for undo ── + /** Maps cell key → state of the cell BEFORE the stroke touched it. undefined = was empty. */ + const strokeBeforeState = useRef>( + new Map(), + ); + const strokeIsErase = useRef(false); + const strokeColor = useRef(""); + + // ── Undo / Redo infrastructure ── + const undoStack = useRef([]); + const redoStack = useRef([]); + /** + * Callbacks waiting for the next token_added server message (used when re-adding + * a token via undo/redo so the new server-assigned ID can be captured). + */ + const pendingTokenAdded = useRef void>>([]); + /** + * When handleAddToken sends add_token, we store the token's data here. + * On the next token_added echo, we use this to push the undo entry with the + * server-assigned ID. + */ + const pendingNewTokenUndo = useRef<{ + x: number; + y: number; + label: string; + color: string; + } | null>(null); + + const onUndoStateChangeRef = useRef(onUndoStateChange); + useEffect(() => { + onUndoStateChangeRef.current = onUndoStateChange; + }, [onUndoStateChange]); + + function notifyUndoState() { + onUndoStateChangeRef.current?.( + undoStack.current.length > 0, + redoStack.current.length > 0, + ); + } + + function pushUndoAction(action: UndoAction) { + undoStack.current.push(action); + redoStack.current = []; + notifyUndoState(); + } + + const performUndo = useCallback(() => { + const action = undoStack.current.pop(); + if (!action) return; + action.revert(); + redoStack.current.push(action); + notifyUndoState(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const performRedo = useCallback(() => { + const action = redoStack.current.pop(); + if (!action) return; + action.apply(); + undoStack.current.push(action); + notifyUndoState(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // ── WASD state ── const keysHeld = useRef>(new Set()); const rafId = useRef(null); const lastFrameTime = useRef(null); - // ---- Stable send ref so handlers never go stale ---- + // ── Stable send ref so handlers never go stale ── const sendRef = useRef<(msg: ClientMessage) => void>(() => {}); const [cursor, setCursor] = useState("default"); const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>( null, ); + const [editToken, setEditToken] = useState(null); + const [hoveredToken, setHoveredToken] = useState<{ + token: GridToken; + canvasX: number; + canvasY: number; + } | null>(null); + const [stackPickerState, setStackPickerState] = useState<{ + tokens: GridToken[]; + cellX: number; + cellY: number; + canvasX: number; + canvasY: number; + } | null>(null); useImperativeHandle(ref, () => ({ sendColorUpdate(colors: string[]) { sendRef.current({ type: "update_colors", colors }); }, + undo: performUndo, + redo: performRedo, })); useEffect(() => { @@ -323,7 +560,6 @@ const Grid = forwardRef(function Grid( return () => observer.disconnect(); }, [redraw]); - // Keep a stable ref to the callback so handleMessage doesn't re-create const onColorsLoadedRef = useRef(onColorsLoaded); useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; @@ -378,7 +614,46 @@ const Grid = forwardRef(function Grid( y: msg.y, label: msg.label, color: msg.color, + size: msg.size ?? 1, }); + // Fire any redo/undo re-add callbacks waiting for the new ID + if (pendingTokenAdded.current.length > 0) { + const cb = pendingTokenAdded.current.shift()!; + cb(msg.id); + } + // Register undo entry for a token just placed via the dialog + if (pendingNewTokenUndo.current) { + const { x, y, label, color } = pendingNewTokenUndo.current; + pendingNewTokenUndo.current = null; + let activeId = msg.id; + + pushUndoAction({ + revert: () => { + sendRef.current({ type: "delete_token", id: activeId }); + tokensRef.current.delete(activeId); + redraw(); + }, + apply: () => { + const tempId = `__ra_${Date.now()}`; + tokensRef.current.set(tempId, { + id: tempId, + map_id: mapId, + x, + y, + label, + color, + size: 1, + }); + redraw(); + sendRef.current({ type: "add_token", x, y, label, color }); + pendingTokenAdded.current.push((newId: string) => { + tokensRef.current.delete(tempId); + activeId = newId; + redraw(); + }); + }, + }); + } redraw(); break; } @@ -395,6 +670,26 @@ const Grid = forwardRef(function Grid( redraw(); break; } + case "token_updated": { + const tok = tokensRef.current.get(msg.id); + if (tok) { + tokensRef.current.set(msg.id, { + ...tok, + label: msg.label, + color: msg.color, + }); + redraw(); + } + break; + } + case "token_resized": { + const tok = tokensRef.current.get(msg.id); + if (tok) { + tokensRef.current.set(msg.id, { ...tok, size: msg.size }); + redraw(); + } + break; + } case "colors_updated": { onColorsLoadedRef.current(msg.colors); break; @@ -404,14 +699,15 @@ const Grid = forwardRef(function Grid( break; } }, - [mapId, redraw], + [mapId, redraw], // eslint-disable-line react-hooks/exhaustive-deps ); - const { send } = useWebSocket(mapId, handleMessage); + const { send, connected } = useWebSocket(mapId, handleMessage); useEffect(() => { sendRef.current = send; }, [send]); + // ── Draw loop ── useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -466,29 +762,150 @@ const Grid = forwardRef(function Grid( } } - // Tokens (skip the one being dragged) - tokensRef.current.forEach((token) => { - if (isDragging.current && dragTokenId.current === token.id) return; - drawToken(ctx, token.x, token.y, token.label, token.color, cam); - }); + // Tokens — larger tokens drawn first (appear behind), smaller on top + const draggingIds = new Set( + dragTokenIds.current.length > 0 + ? dragTokenIds.current + : dragTokenId.current + ? [dragTokenId.current] + : [], + ); - // Drag ghost - if (isDragging.current && dragCellPos.current && dragTokenId.current) { - const tok = tokensRef.current.get(dragTokenId.current); - if (tok) { - ctx.globalAlpha = 0.6; - drawToken( - ctx, - dragCellPos.current.x, - dragCellPos.current.y, - tok.label, - tok.color, - cam, - ); - ctx.globalAlpha = 1; + // Sort: big first (size desc), so smaller tokens are drawn last = on top + const activeTokens = [...tokensRef.current.values()] + .filter((t) => !(isDragging.current && draggingIds.has(t.id))) + .sort((a, b) => (b.size ?? 1) - (a.size ?? 1)); + + // Count tokens per anchor for stack badges; track smallest (top) per anchor + const anchorCounts = new Map(); + const anchorTopToken = new Map(); + for (const t of activeTokens) { + const key = cellKey(t.x, t.y); + anchorCounts.set(key, (anchorCounts.get(key) ?? 0) + 1); + // Since we iterate big→small, last assignment per anchor = smallest = top + anchorTopToken.set(key, t); + } + + // Pass 1: draw all token bodies (big first → small last → small on top) + for (const t of activeTokens) { + drawToken(ctx, t.x, t.y, t.label, t.color, cam, t.size ?? 1); + } + + // Pass 2: draw stack badges on top of everything + for (const [key, tok] of anchorTopToken) { + const count = anchorCounts.get(key)!; + if (count > 1) { + drawStackBadge(ctx, tok.x, tok.y, count, cam, tok.size ?? 1); } } - }, [tick]); + + // Movement ruler — anchored to token centers + if (isDragging.current && dragStartCell.current && dragCellPos.current) { + const src = dragStartCell.current; + const dst = dragCellPos.current; + const srcPx = cellToCanvas(src.x, src.y, cam); + const dstPx = cellToCanvas(dst.x, dst.y, cam); + const tokSz = dragTokenSize.current; + const x1 = srcPx.x + (zoom * tokSz) / 2; + const y1 = srcPx.y + (zoom * tokSz) / 2; + const x2 = dstPx.x + (zoom * tokSz) / 2; + const y2 = dstPx.y + (zoom * tokSz) / 2; + + ctx.save(); + ctx.setLineDash([Math.max(3, zoom * 0.1), Math.max(3, zoom * 0.08)]); + ctx.strokeStyle = "rgba(255,255,255,0.75)"; + ctx.lineWidth = Math.max(1.5, zoom * 0.035); + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = "rgba(255,255,255,0.9)"; + ctx.beginPath(); + ctx.arc(x1, y1, Math.max(3, zoom * 0.07), 0, Math.PI * 2); + ctx.fill(); + + const squares = calcDndSquares( + dst.x - src.x, + dst.y - src.y, + movementRule, + ); + const distance = squares * unitsPerSquare; + if (squares > 0) { + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const label = `${distance} ${unitLabel}`; + const fontSize = Math.max(11, Math.round(zoom * 0.28)); + ctx.font = `bold ${fontSize}px system-ui, sans-serif`; + const tw = ctx.measureText(label).width; + const pad = 5; + ctx.fillStyle = "rgba(0,0,0,0.72)"; + ctx.beginPath(); + ctx.roundRect( + mx - tw / 2 - pad, + my - fontSize / 2 - pad / 2, + tw + pad * 2, + fontSize + pad, + 4, + ); + ctx.fill(); + ctx.fillStyle = "#ffffff"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, mx, my); + } + ctx.restore(); + } + + // Drag ghost + if (isDragging.current && dragCellPos.current) { + ctx.globalAlpha = 0.6; + if (dragTokenIds.current.length > 0) { + const dragToks = dragTokenIds.current + .map((id) => tokensRef.current.get(id)) + .filter(Boolean) as GridToken[]; + if (dragToks.length > 0) { + const topTok = [...dragToks].sort( + (a, b) => (a.size ?? 1) - (b.size ?? 1), + )[0]; + drawToken( + ctx, + dragCellPos.current.x, + dragCellPos.current.y, + topTok.label, + topTok.color, + cam, + topTok.size ?? 1, + ); + if (dragToks.length > 1) { + drawStackBadge( + ctx, + dragCellPos.current.x, + dragCellPos.current.y, + dragToks.length, + cam, + topTok.size ?? 1, + ); + } + } + } else if (dragTokenId.current) { + const tok = tokensRef.current.get(dragTokenId.current); + if (tok) { + drawToken( + ctx, + dragCellPos.current.x, + dragCellPos.current.y, + tok.label, + tok.color, + cam, + tok.size ?? 1, + ); + } + } + ctx.globalAlpha = 1; + } + }, [tick]); // eslint-disable-line react-hooks/exhaustive-deps function applyClampAndRedraw() { const canvas = canvasRef.current; @@ -530,6 +947,7 @@ const Grid = forwardRef(function Grid( return () => canvas.removeEventListener("wheel", onWheel); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // WASD + undo/redo keyboard handler useEffect(() => { function rafTick(timestamp: number) { const keys = keysHeld.current; @@ -574,7 +992,23 @@ const Grid = forwardRef(function Grid( e.target instanceof HTMLTextAreaElement ) return; - // Don't intercept WASD when modifier keys are held (e.g. Shift+keys are for tool shortcuts) + + // Undo / Redo + if (e.ctrlKey || e.metaKey) { + const k = e.key.toLowerCase(); + if (k === "z" && !e.shiftKey) { + e.preventDefault(); + performUndo(); + return; + } + if (k === "y" || (k === "z" && e.shiftKey)) { + e.preventDefault(); + performRedo(); + return; + } + } + + // Don't intercept WASD when modifier keys are held if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; const key = e.key.toLowerCase(); if (["w", "a", "s", "d"].includes(key)) { @@ -601,18 +1035,97 @@ const Grid = forwardRef(function Grid( rafId.current = null; } }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [performUndo, performRedo]); // eslint-disable-line react-hooks/exhaustive-deps function getCanvasPoint(e: React.MouseEvent) { const rect = canvasRef.current!.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } - function tokenAtCell(x: number, y: number): GridToken | null { + /** Returns all tokens whose bounding box (size × size) covers cell (x,y). */ + function tokensAtCell(x: number, y: number): GridToken[] { + const result: GridToken[] = []; for (const t of tokensRef.current.values()) { - if (t.x === x && t.y === y) return t; + const s = t.size ?? 1; + if (x >= t.x && x < t.x + s && y >= t.y && y < t.y + s) { + result.push(t); + } } - return null; + return result; + } + + /** Returns the topmost (smallest) token at the given cell, or undefined. */ + function topTokenAtCell(x: number, y: number): GridToken | undefined { + const stack = tokensAtCell(x, y); + if (stack.length === 0) return undefined; + return stack.sort((a, b) => (a.size ?? 1) - (b.size ?? 1))[0]; + } + + /** Finishes an in-progress stroke and pushes undo/redo entry. */ + function finalizeStroke() { + const before = new Map(strokeBeforeState.current); + const isErase = strokeIsErase.current; + const newColor = strokeColor.current; + strokeBeforeState.current.clear(); + + // Only push if something changed + let anyChanged = false; + for (const [key, prevCell] of before) { + const afterCell = cellsRef.current.get(key); + if ((prevCell?.color ?? null) !== (afterCell?.color ?? null)) { + anyChanged = true; + break; + } + } + if (!anyChanged) return; + + pushUndoAction({ + revert: () => { + const toRepaint: Array<{ x: number; y: number; color: string }> = []; + const toErase: Array<{ x: number; y: number }> = []; + for (const [key, prevCell] of before) { + const { x, y } = parseCellKey(key); + if (prevCell) { + cellsRef.current.set(key, prevCell); + toRepaint.push({ x, y, color: prevCell.color }); + } else { + cellsRef.current.delete(key); + toErase.push({ x, y }); + } + } + redraw(); + if (toRepaint.length === 1) { + sendRef.current({ type: "paint_cell", ...toRepaint[0] }); + } else if (toRepaint.length > 1) { + sendRef.current({ type: "paint_cells", cells: toRepaint }); + } + for (const { x, y } of toErase) { + sendRef.current({ type: "erase_cell", x, y }); + } + }, + apply: () => { + if (isErase) { + for (const [key] of before) { + const { x, y } = parseCellKey(key); + cellsRef.current.delete(key); + sendRef.current({ type: "erase_cell", x, y }); + } + } else { + const cells: Array<{ x: number; y: number; color: string }> = []; + for (const [key] of before) { + const { x, y } = parseCellKey(key); + cellsRef.current.set(key, { map_id: mapId, x, y, color: newColor }); + cells.push({ x, y, color: newColor }); + } + if (cells.length === 1) { + sendRef.current({ type: "paint_cell", ...cells[0] }); + } else { + sendRef.current({ type: "paint_cells", cells }); + } + } + redraw(); + }, + }); } function handleMouseDown(e: React.MouseEvent) { @@ -620,6 +1133,28 @@ const Grid = forwardRef(function Grid( const { x: mx, y: my } = getCanvasPoint(e); const cell = canvasToCell(mx, my, cameraRef.current); + // ---- Middle mouse button → pan (regardless of tool) ---- + if (e.button === 1) { + middleMousePanning.current = true; + isPanning.current = true; + panStart.current = { + mx, + my, + ox: cameraRef.current.offsetX, + oy: cameraRef.current.offsetY, + }; + setCursor("grabbing"); + return; + } + + if (isDragging.current) { + if (e.button === 0) { + dragCellPos.current = { x: cell.x, y: cell.y }; + redraw(); + } + return; + } + // ---- Pan tool ---- if (tool === "pan" && e.button === 0) { isPanning.current = true; @@ -644,29 +1179,199 @@ const Grid = forwardRef(function Grid( if (tool === "draw") { if (e.button === 0) { if (e.shiftKey) { - // Shift+click → flood fill uncolored region + // Shift+click → flood fill (empty region OR same-color region) const key = cellKey(cell.x, cell.y); - if (!cellsRef.current.has(key)) { + const existing = cellsRef.current.get(key); + + if (!existing) { + // Empty cell: flood-fill uncolored region with paintColor const region = floodFill(cell.x, cell.y, cellsRef.current); if (region === null || region.length === 1) { - // Unbounded or trivially single cell → paint one cell + // Unbounded or single cell — paint just this one + const prevCell = cellsRef.current.get(key); // undefined + cellsRef.current.set(key, { + map_id: mapId, + x: cell.x, + y: cell.y, + color: paintColor, + }); + redraw(); sendRef.current({ type: "paint_cell", x: cell.x, y: cell.y, color: paintColor, }); + const cx = cell.x, + cy = cell.y, + pc = paintColor; + pushUndoAction({ + revert: () => { + if (prevCell) { + cellsRef.current.set(cellKey(cx, cy), prevCell); + sendRef.current({ + type: "paint_cell", + x: cx, + y: cy, + color: prevCell.color, + }); + } else { + cellsRef.current.delete(cellKey(cx, cy)); + sendRef.current({ type: "erase_cell", x: cx, y: cy }); + } + redraw(); + }, + apply: () => { + cellsRef.current.set(cellKey(cx, cy), { + map_id: mapId, + x: cx, + y: cy, + color: pc, + }); + sendRef.current({ + type: "paint_cell", + x: cx, + y: cy, + color: pc, + }); + redraw(); + }, + }); } else { - // Bounded enclosed region → batch paint - sendRef.current({ - type: "paint_cells", - cells: region.map(({ x, y }) => ({ x, y, color: paintColor })), + // Bounded region: batch paint all empty cells + const prevStates = new Map(); + for (const { x, y } of region) { + prevStates.set( + cellKey(x, y), + cellsRef.current.get(cellKey(x, y)), + ); + cellsRef.current.set(cellKey(x, y), { + map_id: mapId, + x, + y, + color: paintColor, + }); + } + redraw(); + const batchCells = region.map(({ x, y }) => ({ + x, + y, + color: paintColor, + })); + sendRef.current({ type: "paint_cells", cells: batchCells }); + const pc = paintColor; + pushUndoAction({ + revert: () => { + const toRepaint: Array<{ + x: number; + y: number; + color: string; + }> = []; + const toErase: Array<{ x: number; y: number }> = []; + for (const [key, prev] of prevStates) { + const { x, y } = parseCellKey(key); + if (prev) { + cellsRef.current.set(key, prev); + toRepaint.push({ x, y, color: prev.color }); + } else { + cellsRef.current.delete(key); + toErase.push({ x, y }); + } + } + redraw(); + if (toRepaint.length > 0) + sendRef.current({ type: "paint_cells", cells: toRepaint }); + for (const { x, y } of toErase) + sendRef.current({ type: "erase_cell", x, y }); + }, + apply: () => { + const cells = region.map(({ x, y }) => ({ x, y, color: pc })); + for (const { x, y } of region) + cellsRef.current.set(cellKey(x, y), { + map_id: mapId, + x, + y, + color: pc, + }); + redraw(); + sendRef.current({ type: "paint_cells", cells }); + }, }); } + } else if (existing.color !== paintColor) { + // Colored cell with different color: replace all connected same-color cells + const region = floodFillColored( + cell.x, + cell.y, + existing.color, + cellsRef.current, + ); + const target = region ?? [{ x: cell.x, y: cell.y }]; + const prevStates = new Map(); + for (const { x, y } of target) { + prevStates.set( + cellKey(x, y), + cellsRef.current.get(cellKey(x, y))!.color, + ); + cellsRef.current.set(cellKey(x, y), { + map_id: mapId, + x, + y, + color: paintColor, + }); + } + redraw(); + const batchCells = target.map(({ x, y }) => ({ + x, + y, + color: paintColor, + })); + sendRef.current({ type: "paint_cells", cells: batchCells }); + const pc = paintColor; + pushUndoAction({ + revert: () => { + const cells = [...prevStates.entries()].map(([key, color]) => { + const { x, y } = parseCellKey(key); + cellsRef.current.set(key, { map_id: mapId, x, y, color }); + return { x, y, color }; + }); + redraw(); + sendRef.current({ type: "paint_cells", cells }); + }, + apply: () => { + const cells = target.map(({ x, y }) => ({ x, y, color: pc })); + for (const { x, y } of target) + cellsRef.current.set(cellKey(x, y), { + map_id: mapId, + x, + y, + color: pc, + }); + redraw(); + sendRef.current({ type: "paint_cells", cells }); + }, + }); } + // Same color → no-op } else { + // Normal draw stroke isDrawing.current = true; - lastPainted.current = cellKey(cell.x, cell.y); + strokeIsErase.current = false; + strokeColor.current = paintColor; + strokeBeforeState.current.clear(); + + const key = cellKey(cell.x, cell.y); + lastPainted.current = key; + if (!strokeBeforeState.current.has(key)) { + strokeBeforeState.current.set(key, cellsRef.current.get(key)); + } + cellsRef.current.set(key, { + map_id: mapId, + x: cell.x, + y: cell.y, + color: paintColor, + }); + redraw(); sendRef.current({ type: "paint_cell", x: cell.x, @@ -674,11 +1379,65 @@ const Grid = forwardRef(function Grid( color: paintColor, }); } - } else if (e.button === 2) { + } else if (e.button === 2 && e.shiftKey) { + // Shift+right-click: flood-erase entire connected same-color region + const key = cellKey(cell.x, cell.y); + const existing = cellsRef.current.get(key); + if (existing) { + const region = floodFillColored( + cell.x, + cell.y, + existing.color, + cellsRef.current, + ) ?? [{ x: cell.x, y: cell.y }]; + const prevStates = new Map(); + for (const { x, y } of region) { + const k = cellKey(x, y); + const c = cellsRef.current.get(k); + if (c) { + prevStates.set(k, c.color); + cellsRef.current.delete(k); + } + } + redraw(); + for (const { x, y } of region) { + sendRef.current({ type: "erase_cell", x, y }); + } + pushUndoAction({ + revert: () => { + const cells = [...prevStates.entries()].map(([k, color]) => { + const { x, y } = parseCellKey(k); + cellsRef.current.set(k, { map_id: mapId, x, y, color }); + return { x, y, color }; + }); + redraw(); + if (cells.length > 0) + sendRef.current({ type: "paint_cells", cells }); + }, + apply: () => { + for (const [k] of prevStates) { + const { x, y } = parseCellKey(k); + cellsRef.current.delete(k); + sendRef.current({ type: "erase_cell", x, y }); + } + redraw(); + }, + }); + } + } else if (e.button === 2 && !e.shiftKey) { + // Normal right-click erase stroke isErasing.current = true; + strokeIsErase.current = true; + strokeBeforeState.current.clear(); + const key = cellKey(cell.x, cell.y); lastPainted.current = key; if (cellsRef.current.has(key)) { + if (!strokeBeforeState.current.has(key)) { + strokeBeforeState.current.set(key, cellsRef.current.get(key)); + } + cellsRef.current.delete(key); + redraw(); sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y }); } } @@ -688,19 +1447,99 @@ const Grid = forwardRef(function Grid( // ---- Token tool ---- if (tool === "token") { if (e.button === 2) { - const tok = tokenAtCell(cell.x, cell.y); - if (tok) sendRef.current({ type: "delete_token", id: tok.id }); + // Right-click: delete the top (smallest) token at this cell + const top = topTokenAtCell(cell.x, cell.y); + if (top) { + const storedTok = { ...top }; + let activeId = top.id; + // Optimistic delete + tokensRef.current.delete(top.id); + redraw(); + sendRef.current({ type: "delete_token", id: top.id }); + pushUndoAction({ + revert: () => { + // Re-add the deleted token + const tempId = `__rd_${Date.now()}`; + tokensRef.current.set(tempId, { ...storedTok, id: tempId }); + redraw(); + sendRef.current({ + type: "add_token", + x: storedTok.x, + y: storedTok.y, + label: storedTok.label, + color: storedTok.color, + }); + pendingTokenAdded.current.push((newId: string) => { + tokensRef.current.delete(tempId); + activeId = newId; + redraw(); + }); + }, + apply: () => { + sendRef.current({ type: "delete_token", id: activeId }); + tokensRef.current.delete(activeId); + redraw(); + }, + }); + } return; } + if (e.button === 0) { - const tok = tokenAtCell(cell.x, cell.y); - if (tok) { + // Shift+left-click on a token: start a resize drag + if (e.shiftKey) { + const top = topTokenAtCell(cell.x, cell.y); + if (top) { + const size = top.size ?? 1; + isResizing.current = true; + resizeTokenId.current = top.id; + resizeTokenOriginalSize.current = size; + resizeTokenCenter.current = { + x: top.x + size / 2, + y: top.y + size / 2, + }; + setCursor("col-resize"); + } + return; + } + + const stack = tokensAtCell(cell.x, cell.y); + if (stack.length === 0) { + // Empty cell — open add-token dialog + setDialogPos({ x: cell.x, y: cell.y }); + } else if (stack.length === 1) { + // Single token — start dragging with center-anchor snapping + const tok = stack[0]; + const size = tok.size ?? 1; isDragging.current = true; dragTokenId.current = tok.id; - dragCellPos.current = { x: cell.x, y: cell.y }; + dragTokenSize.current = size; + dragTokenPrevPos.current = { x: tok.x, y: tok.y }; + dragCellPos.current = canvasToCenterAnchor( + mx, + my, + size, + cameraRef.current, + ); + dragStartCell.current = { x: tok.x, y: tok.y }; redraw(); } else { - setDialogPos({ x: cell.x, y: cell.y }); + // Multiple tokens — show stack picker (sorted smallest = top, first in list) + const sorted = [...stack].sort( + (a, b) => (a.size ?? 1) - (b.size ?? 1), + ); + const { x: canvasX, y: canvasY } = cellToCanvas( + cell.x, + cell.y, + cameraRef.current, + ); + setStackPickerState({ + tokens: sorted, + cellX: cell.x, + cellY: cell.y, + canvasX: canvasX + cameraRef.current.zoom + 4, + canvasY, + }); } } } @@ -727,6 +1566,16 @@ const Grid = forwardRef(function Grid( if (lastPainted.current !== key) { lastPainted.current = key; if (isDrawing.current) { + if (!strokeBeforeState.current.has(key)) { + strokeBeforeState.current.set(key, cellsRef.current.get(key)); + } + cellsRef.current.set(key, { + map_id: mapId, + x: cell.x, + y: cell.y, + color: paintColor, + }); + redraw(); sendRef.current({ type: "paint_cell", x: cell.x, @@ -734,74 +1583,288 @@ const Grid = forwardRef(function Grid( color: paintColor, }); } else if (isErasing.current && cellsRef.current.has(key)) { + if (!strokeBeforeState.current.has(key)) { + strokeBeforeState.current.set(key, cellsRef.current.get(key)); + } + cellsRef.current.delete(key); + redraw(); sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y }); } } return; } - // Token drag - if (isDragging.current && dragCellPos.current) { - const cell = canvasToCell(mx, my, cameraRef.current); - if ( - dragCellPos.current.x !== cell.x || - dragCellPos.current.y !== cell.y - ) { - dragCellPos.current = { x: cell.x, y: cell.y }; + // Token resize drag + if ( + isResizing.current && + resizeTokenCenter.current && + resizeTokenId.current + ) { + const cam = cameraRef.current; + const worldX = mx / cam.zoom + cam.offsetX; + const worldY = my / cam.zoom + cam.offsetY; + const cx = resizeTokenCenter.current.x; + const cy = resizeTokenCenter.current.y; + const dist = Math.sqrt((worldX - cx) ** 2 + (worldY - cy) ** 2); + // radius = size * 0.38 in world units → size = dist / 0.38 + const newSize = Math.max( + 1, + Math.min(MAX_TOKEN_SIZE, Math.round(dist / 0.38)), + ); + const tok = tokensRef.current.get(resizeTokenId.current); + if (tok && (tok.size ?? 1) !== newSize) { + tokensRef.current.set(resizeTokenId.current, { ...tok, size: newSize }); redraw(); } + return; + } + + // Token drag (center-anchored) + if (isDragging.current && dragCellPos.current) { + const newPos = canvasToCenterAnchor( + mx, + my, + dragTokenSize.current, + cameraRef.current, + ); + if ( + dragCellPos.current.x !== newPos.x || + dragCellPos.current.y !== newPos.y + ) { + dragCellPos.current = newPos; + redraw(); + } + return; + } + + // Hover: detect token under cursor + if (!isPanning.current && !isDrawing.current && !isErasing.current) { + const cell = canvasToCell(mx, my, cameraRef.current); + const tok = topTokenAtCell(cell.x, cell.y); + if (tok) { + const { x: cx, y: cy } = cellToCanvas(tok.x, tok.y, cameraRef.current); + setHoveredToken({ + token: tok, + canvasX: cx + (cameraRef.current.zoom * (tok.size ?? 1)) / 2, + canvasY: cy, + }); + } else { + setHoveredToken(null); + } } } - function handleMouseUp(_e: React.MouseEvent) { - if (isPanning.current) { + function handleMouseUp(e: React.MouseEvent) { + // Middle mouse release + if (e.button === 1 && middleMousePanning.current) { + middleMousePanning.current = false; + isPanning.current = false; + panStart.current = null; + restoreToolCursor(); + return; + } + + if (isPanning.current && !middleMousePanning.current) { isPanning.current = false; panStart.current = null; setCursor("grab"); return; } + // Token resize commit + if (isResizing.current) { + const id = resizeTokenId.current!; + const originalSize = resizeTokenOriginalSize.current; + const tok = tokensRef.current.get(id); + const newSize = tok?.size ?? originalSize; + + isResizing.current = false; + resizeTokenId.current = null; + resizeTokenCenter.current = null; + setCursor("crosshair"); + + if (tok && newSize !== originalSize) { + sendRef.current({ type: "resize_token", id, size: newSize }); + pushUndoAction({ + revert: () => { + const t = tokensRef.current.get(id); + if (t) tokensRef.current.set(id, { ...t, size: originalSize }); + sendRef.current({ type: "resize_token", id, size: originalSize }); + redraw(); + }, + apply: () => { + const t = tokensRef.current.get(id); + if (t) tokensRef.current.set(id, { ...t, size: newSize }); + sendRef.current({ type: "resize_token", id, size: newSize }); + redraw(); + }, + }); + } + return; + } + if (isDrawing.current || isErasing.current) { isDrawing.current = false; isErasing.current = false; lastPainted.current = null; + finalizeStroke(); return; } - if (isDragging.current && dragTokenId.current && dragCellPos.current) { - const tok = tokensRef.current.get(dragTokenId.current); - if ( - tok && - (tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y) - ) { - sendRef.current({ - type: "move_token", - id: dragTokenId.current, - x: dragCellPos.current.x, - y: dragCellPos.current.y, + if (isDragging.current && dragCellPos.current) { + const dest = dragCellPos.current; + + if (dragTokenIds.current.length > 0) { + // Move-stack + const prevPositions = dragStackPrevPositions.current; + const destX = dest.x; + const destY = dest.y; + let anyMoved = false; + + dragTokenIds.current.forEach((id) => { + const tok = tokensRef.current.get(id); + if (tok && (tok.x !== dest.x || tok.y !== dest.y)) { + tokensRef.current.set(id, { ...tok, x: dest.x, y: dest.y }); + sendRef.current({ type: "move_token", id, x: dest.x, y: dest.y }); + anyMoved = true; + } }); + + if (anyMoved) { + pushUndoAction({ + revert: () => { + for (const { id, x, y } of prevPositions) { + const t = tokensRef.current.get(id); + if (t) tokensRef.current.set(id, { ...t, x, y }); + sendRef.current({ type: "move_token", id, x, y }); + } + redraw(); + }, + apply: () => { + for (const { id } of prevPositions) { + const t = tokensRef.current.get(id); + if (t) tokensRef.current.set(id, { ...t, x: destX, y: destY }); + sendRef.current({ type: "move_token", id, x: destX, y: destY }); + } + redraw(); + }, + }); + } + + dragTokenIds.current = []; + dragStackPrevPositions.current = []; + } else if (dragTokenId.current) { + const id = dragTokenId.current; + const tok = tokensRef.current.get(id); + const prevX = dragTokenPrevPos.current?.x ?? tok?.x ?? 0; + const prevY = dragTokenPrevPos.current?.y ?? tok?.y ?? 0; + const destX = dest.x; + const destY = dest.y; + + if (tok && (tok.x !== dest.x || tok.y !== dest.y)) { + tokensRef.current.set(id, { ...tok, x: dest.x, y: dest.y }); + sendRef.current({ type: "move_token", id, x: dest.x, y: dest.y }); + + pushUndoAction({ + revert: () => { + const t = tokensRef.current.get(id); + if (t) tokensRef.current.set(id, { ...t, x: prevX, y: prevY }); + sendRef.current({ type: "move_token", id, x: prevX, y: prevY }); + redraw(); + }, + apply: () => { + const t = tokensRef.current.get(id); + if (t) tokensRef.current.set(id, { ...t, x: destX, y: destY }); + sendRef.current({ type: "move_token", id, x: destX, y: destY }); + redraw(); + }, + }); + } + + dragTokenId.current = null; + dragTokenPrevPos.current = null; } + isDragging.current = false; - dragTokenId.current = null; dragCellPos.current = null; + dragStartCell.current = null; redraw(); } } function handleMouseLeave() { - isPanning.current = false; - panStart.current = null; - isDrawing.current = false; - isErasing.current = false; - lastPainted.current = null; + if (isPanning.current) { + isPanning.current = false; + panStart.current = null; + middleMousePanning.current = false; + } + // Cancel resize: revert preview to original size + if (isResizing.current) { + const id = resizeTokenId.current!; + const originalSize = resizeTokenOriginalSize.current; + const tok = tokensRef.current.get(id); + if (tok && (tok.size ?? 1) !== originalSize) { + tokensRef.current.set(id, { ...tok, size: originalSize }); + redraw(); + } + isResizing.current = false; + resizeTokenId.current = null; + resizeTokenCenter.current = null; + } + if (isDrawing.current || isErasing.current) { + isDrawing.current = false; + isErasing.current = false; + lastPainted.current = null; + finalizeStroke(); + } + setHoveredToken(null); if (isDragging.current) { isDragging.current = false; dragTokenId.current = null; + dragTokenIds.current = []; + dragStackPrevPositions.current = []; + dragTokenPrevPos.current = null; dragCellPos.current = null; + dragStartCell.current = null; redraw(); } } + function handleDoubleClick(e: React.MouseEvent) { + if (tool !== "token") return; + const { x: mx, y: my } = getCanvasPoint(e); + const cell = canvasToCell(mx, my, cameraRef.current); + const tok = topTokenAtCell(cell.x, cell.y); + if (!tok) return; + + // Cancel any drag started by the first click of the double-click + isDragging.current = false; + dragTokenId.current = null; + dragTokenIds.current = []; + dragStackPrevPositions.current = []; + dragTokenPrevPos.current = null; + dragCellPos.current = null; + dragStartCell.current = null; + setStackPickerState(null); + + setEditToken(tok); + redraw(); + } + + function restoreToolCursor() { + switch (tool) { + case "pan": + setCursor("grab"); + break; + case "zoom": + setCursor("zoom-in"); + break; + default: + setCursor("crosshair"); + break; + } + } + // Sync cursor CSS to active tool useEffect(() => { switch (tool) { @@ -822,16 +1885,95 @@ const Grid = forwardRef(function Grid( function handleAddToken(label: string, color: string) { if (!dialogPos) return; - sendRef.current({ - type: "add_token", - x: dialogPos.x, - y: dialogPos.y, - label, - color, - }); + const x = dialogPos.x; + const y = dialogPos.y; + pendingNewTokenUndo.current = { x, y, label, color }; + sendRef.current({ type: "add_token", x, y, label, color }); setDialogPos(null); } + function handleEditToken(id: string, label: string, color: string) { + const tok = tokensRef.current.get(id); + if (!tok) { + setEditToken(null); + return; + } + const prevLabel = tok.label; + const prevColor = tok.color; + + // Optimistic update + tokensRef.current.set(id, { ...tok, label, color }); + redraw(); + sendRef.current({ type: "update_token", id, label, color }); + + pushUndoAction({ + revert: () => { + const t = tokensRef.current.get(id); + if (t) + tokensRef.current.set(id, { + ...t, + label: prevLabel, + color: prevColor, + }); + sendRef.current({ + type: "update_token", + id, + label: prevLabel, + color: prevColor, + }); + redraw(); + }, + apply: () => { + const t = tokensRef.current.get(id); + if (t) tokensRef.current.set(id, { ...t, label, color }); + sendRef.current({ type: "update_token", id, label, color }); + redraw(); + }, + }); + + setEditToken(null); + } + + /** Called from the stack picker when the user picks a single token. */ + function handlePickToken(tok: GridToken) { + setStackPickerState(null); + const size = tok.size ?? 1; + isDragging.current = true; + dragTokenId.current = tok.id; + dragTokenSize.current = size; + dragTokenPrevPos.current = { x: tok.x, y: tok.y }; + dragCellPos.current = { x: tok.x, y: tok.y }; + dragStartCell.current = { x: tok.x, y: tok.y }; + redraw(); + } + + /** Called from the stack picker when the user wants to move the whole stack. */ + function handleMoveStack() { + if (!stackPickerState) return; + setStackPickerState(null); + dragTokenIds.current = stackPickerState.tokens.map((t) => t.id); + dragStackPrevPositions.current = stackPickerState.tokens.map((t) => ({ + id: t.id, + x: t.x, + y: t.y, + })); + // Use the smallest (top) token's size for center-anchor calculations + const smallestSize = Math.min( + ...stackPickerState.tokens.map((t) => t.size ?? 1), + ); + dragTokenSize.current = smallestSize; + isDragging.current = true; + dragCellPos.current = { + x: stackPickerState.cellX, + y: stackPickerState.cellY, + }; + dragStartCell.current = { + x: stackPickerState.cellX, + y: stackPickerState.cellY, + }; + redraw(); + } + return (
(function Grid( onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} + onDoubleClick={handleDoubleClick} + onAuxClick={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()} /> + {!connected && ( +
⟳ Reconnecting…
+ )} + {/* Hover tooltip */} + {hoveredToken && + !isDragging.current && + !stackPickerState && + !editToken && ( +
+ {hoveredToken.token.label} +
+ )} + {/* Stack picker */} + {stackPickerState && ( + setStackPickerState(null)} + /> + )} + {/* Add token dialog */} {dialogPos && ( (function Grid( onCancel={() => setDialogPos(null)} /> )} + {/* Edit token dialog */} + {editToken && ( + + handleEditToken(editToken.id, label, color) + } + onCancel={() => setEditToken(null)} + /> + )}
); }); diff --git a/ui/src/components/TokenDialog.tsx b/ui/src/components/TokenDialog.tsx index f1ee636..29c40dc 100644 --- a/ui/src/components/TokenDialog.tsx +++ b/ui/src/components/TokenDialog.tsx @@ -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(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 (
e.stopPropagation()}> -

Add Token

+

{isEdit ? "Edit Token" : "Add Token"}

diff --git a/ui/src/components/TokenStackPicker.css b/ui/src/components/TokenStackPicker.css new file mode 100644 index 0000000..0284997 --- /dev/null +++ b/ui/src/components/TokenStackPicker.css @@ -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; +} diff --git a/ui/src/components/TokenStackPicker.tsx b/ui/src/components/TokenStackPicker.tsx new file mode 100644 index 0000000..f3135a3 --- /dev/null +++ b/ui/src/components/TokenStackPicker.tsx @@ -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 */} +
+
e.stopPropagation()} + > +
{tokens.length} tokens stacked
+ + {tokens.map((tok) => ( + + ))} + +
+ + +
+ + ); +} diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 16b7096..b8c88b8 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -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 | 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 }; } diff --git a/ui/src/pages/AccountPage.tsx b/ui/src/pages/AccountPage.tsx index 2ab1383..9db722c 100644 --- a/ui/src/pages/AccountPage.tsx +++ b/ui/src/pages/AccountPage.tsx @@ -13,7 +13,9 @@ export default function AccountPage() { const hasDiscord = user.connections.some((c) => c.provider === "discord"); return ( -
+
navigate("/map")} diff --git a/ui/src/pages/MapPage.tsx b/ui/src/pages/MapPage.tsx index dee133a..3ff68a8 100644 --- a/ui/src/pages/MapPage.tsx +++ b/ui/src/pages/MapPage.tsx @@ -66,6 +66,10 @@ export default function MapPage({ setMapTitle }: Props) { const [mapColors, setMapColors] = useState(DEFAULT_COLORS); const gridRef = useRef(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); + }} />
- + gridRef.current?.undo()} + onRedo={() => gridRef.current?.redo()} + />