Updated Grid

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

View File

@@ -162,7 +162,7 @@ Siren uses Discord slash commands.
| `/stop` | Stop playback and clear the queue | Done | | `/stop` | Stop playback and clear the queue | Done |
| `/mute` | Mute/unmute the bot | Done | | `/mute` | Mute/unmute the bot | Done |
| `/volume <0100>` | Set the playback volume | Done | | `/volume <0100>` | Set the playback volume | Done |
| `/queue` | Display the current queue | Planned | | `/queue` | Display the current queue | Done |
| `/nowplaying` | Display the currently playing track | Planned | | `/nowplaying` | Display the currently playing track | Planned |
| `/shuffle` | Shuffle the queue | Planned | | `/shuffle` | Shuffle the queue | Planned |
| `/loop` | Toggle looping the current track | Planned | | `/loop` | Toggle looping the current track | Planned |

View File

@@ -0,0 +1,11 @@
meta {
name: Track Status
type: http
seq: 4
}
get {
url: {{BASE_URL}}/audio/{{SERVER}}/status
body: none
auth: inherit
}

4
bruno/audio/folder.bru Normal file
View File

@@ -0,0 +1,4 @@
meta {
name: audio
seq: 2
}

View File

@@ -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
}

View File

@@ -0,0 +1,4 @@
meta {
name: discord
seq: 1
}

8
bruno/auth/folder.bru Normal file
View File

@@ -0,0 +1,8 @@
meta {
name: auth
seq: 4
}
auth {
mode: inherit
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,8 @@
meta {
name: local
seq: 2
}
auth {
mode: inherit
}

4
bruno/dice/folder.bru Normal file
View File

@@ -0,0 +1,4 @@
meta {
name: dice
seq: 3
}

View File

@@ -1,7 +1,8 @@
vars { vars {
baseUrl: http://localhost:3000/api BASE_URL: http://localhost:3000/api
} }
vars:secret [ vars:secret [
server, TEST_SERVER,
apiKey apiKey,
SERVER
] ]

View File

@@ -1,11 +0,0 @@
meta {
name: Authorize
type: http
seq: 1
}
get {
url: {{baseUrl}}/oauth/authorize
body: none
auth: inherit
}

View File

@@ -1,3 +1,4 @@
use crate::grid::model::CellPatch;
use dashmap::DashMap; use dashmap::DashMap;
use serenity::{ use serenity::{
all::{Cache, Http}, all::{Cache, Http},
@@ -30,4 +31,11 @@ pub struct AppState {
/// Per-map WebSocket broadcast channels for real-time collaboration. /// Per-map WebSocket broadcast channels for real-time collaboration.
/// Key is the CSPRNG map ID (TEXT). /// Key is the CSPRNG map ID (TEXT).
pub map_rooms: Arc<DashMap<String, broadcast::Sender<String>>>, pub map_rooms: Arc<DashMap<String, broadcast::Sender<String>>>,
/// 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<DashMap<String, Arc<tokio::sync::Mutex<Vec<CellPatch>>>>>,
/// 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<DashMap<String, bool>>,
} }

View File

@@ -16,7 +16,7 @@ use siren_bot::{
join_voice_channel, join_voice_channel,
pause::pause_track, pause::pause_track,
play::enqueue_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, resume::resume_track,
skip::skip_track, skip::skip_track,
stop::stop_track, stop::stop_track,
@@ -39,6 +39,7 @@ pub fn get_guild_routes() -> Router<Arc<AppState>> {
.route("/resume", post(resume_audio)) .route("/resume", post(resume_audio))
.route("/stop", post(stop_audio)) .route("/stop", post(stop_audio))
.route("/skip", post(skip_audio)) .route("/skip", post(skip_audio))
.route("/loop", post(set_loop_audio))
.route("/status", get(audio_status)) .route("/status", get(audio_status))
} }
@@ -47,6 +48,13 @@ pub fn get_guild_routes() -> Router<Arc<AppState>> {
#[derive(Deserialize)] #[derive(Deserialize)]
struct PlayTrackRequest { struct PlayTrackRequest {
url: String, url: String,
#[serde(default)]
loop_enabled: bool,
}
#[derive(Deserialize)]
struct SetLoopRequest {
enabled: bool,
} }
/// Resolve the Discord snowflake for a local user from `user_connections`. /// Resolve the Discord snowflake for a local user from `user_connections`.
@@ -130,7 +138,13 @@ async fn play_audio(
// Play the track // Play the track
let manager = get_songbird(); let manager = get_songbird();
let _channel_id = join_voice_channel(&state.cache, manager, &guild_id, &user_id).await?; 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(()) Ok(())
} }
@@ -216,10 +230,37 @@ async fn skip_audio(
Ok(()) Ok(())
} }
// ── POST /api/audio/{guild_id}/loop ──────────────────────────────────────────
async fn set_loop_audio(
SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>,
Path(guild_id): Path<u64>,
Json(payload): Json<SetLoopRequest>,
) -> 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)] #[derive(Serialize)]
struct AudioStatus { struct AudioStatus {
voice_channel: Option<String>, voice_channel: Option<String>,
is_paused: bool, is_paused: bool,
/// Elapsed playback position of the current track in seconds.
position_secs: f64,
current_track: Option<TrackInfo>, current_track: Option<TrackInfo>,
queue: Vec<TrackInfo>, queue: Vec<TrackInfo>,
} }
@@ -238,19 +279,17 @@ async fn audio_status(
// ── Voice channel: look up the bot's own voice state + channel name from cache ── // ── Voice channel: look up the bot's own voice state + channel name from cache ──
let bot_user_id = state.cache.current_user().id; let bot_user_id = state.cache.current_user().id;
let voice_channel = state let voice_channel = state.cache.guild(guild_id_snowflake).and_then(|guild| {
.cache let ch_id = guild
.guild(guild_id_snowflake) .voice_states
.and_then(|guild| { .get(&bot_user_id)
let ch_id = guild .and_then(|vs| vs.channel_id)?;
.voice_states guild.channels.get(&ch_id).map(|ch| ch.name.clone())
.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) ── // ── Playback paused state + position (delegated to siren-bot to keep songbird internal) ──
let is_paused = get_is_paused(guild_id).await; 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) ── // ── Queue metadata from our store (index 0 = currently playing) ──
let mut full_queue = get_queue(guild_id); let mut full_queue = get_queue(guild_id);
@@ -263,6 +302,7 @@ async fn audio_status(
Ok(Json(AudioStatus { Ok(Json(AudioStatus {
voice_channel, voice_channel,
is_paused, is_paused,
position_secs,
current_track, current_track,
queue: full_queue, queue: full_queue,
})) }))

View File

@@ -35,9 +35,9 @@ pub fn get_routes() -> Router<Arc<AppState>> {
.route("/register", post(register)) .route("/register", post(register))
.route("/login", post(login)) .route("/login", post(login))
.route("/logout", post(logout)) .route("/logout", post(logout))
.route("/me", get(me)) .route("/user", get(get_self))
.route("/profile", put(update_profile)) .route("/profile", put(update_profile))
.route("/change-password", post(change_password)) .route("/password", put(update_password))
.route("/connections/{provider}", delete(disconnect_provider)) .route("/connections/{provider}", delete(disconnect_provider))
} }
@@ -323,7 +323,7 @@ async fn logout(
(jar.add(removal), StatusCode::NO_CONTENT) (jar.add(removal), StatusCode::NO_CONTENT)
} }
async fn me(SessionAuthorization(session): SessionAuthorization) -> Result<Json<UserInfo>> { async fn get_self(SessionAuthorization(session): SessionAuthorization) -> Result<Json<UserInfo>> {
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
Ok(Json(load_user_info(session.user_id).await?)) 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?)) Ok(Json(load_user_info(session.user_id).await?))
} }
async fn change_password( async fn update_password(
SessionAuthorization(session): SessionAuthorization, SessionAuthorization(session): SessionAuthorization,
Json(payload): Json<ChangePasswordPayload>, Json(payload): Json<ChangePasswordPayload>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {

View File

@@ -21,6 +21,7 @@ use axum::{
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use model::{ use model::{
AccessRequestWithUser, AccessRequestWithUser,
CellPatch,
ClientMessage, ClientMessage,
CreateAccessRequestPayload, CreateAccessRequestPayload,
CreateMapPayload, CreateMapPayload,
@@ -37,8 +38,11 @@ use model::{
UpdatePermissionPayload, UpdatePermissionPayload,
}; };
use siren_core::utils::csprng; use siren_core::utils::csprng;
use std::sync::Arc; use std::{collections::HashMap, sync::Arc};
use tokio::sync::broadcast; use tokio::{
sync::{broadcast, broadcast::error::RecvError},
time::Duration,
};
use uuid::Uuid; use uuid::Uuid;
pub fn get_routes() -> Router<Arc<AppState>> { pub fn get_routes() -> Router<Arc<AppState>> {
@@ -52,10 +56,10 @@ pub fn get_routes() -> Router<Arc<AppState>> {
.route("/maps/{id}/permissions", put(update_permission)) .route("/maps/{id}/permissions", put(update_permission))
.route("/maps/{id}/favorite", post(favorite_map)) .route("/maps/{id}/favorite", post(favorite_map))
.route("/maps/{id}/favorite", delete(unfavorite_map)) .route("/maps/{id}/favorite", delete(unfavorite_map))
.route("/maps/{id}/access-requests", post(create_access_request)) .route("/maps/{id}/access", post(create_access_request))
.route("/maps/{id}/access-requests", get(list_access_requests)) .route("/maps/{id}/access", get(list_access_requests))
.route( .route(
"/maps/{id}/access-requests/{request_id}", "/maps/{id}/access/{request_id}",
put(resolve_access_request), put(resolve_access_request),
) )
.route("/maps/{id}/ws", get(ws_handler)) .route("/maps/{id}/ws", get(ws_handler))
@@ -126,7 +130,9 @@ pub async fn list_maps(
"SELECT "SELECT
gm.id, gm.name, gm.public_access, gm.owner_id, gm.id, gm.name, gm.public_access, gm.owner_id,
u.username AS owner_username, 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, mp.role AS user_role,
(mf.user_id IS NOT NULL) AS is_favorited (mf.user_id IS NOT NULL) AS is_favorited
FROM grid_maps gm 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_name = payload.name.as_deref().unwrap_or(&map.name);
let new_pa = payload let new_pa = payload
.public_access .public_access
.as_deref() .as_deref()
.unwrap_or(&map.public_access); .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( let updated: GridMap = sqlx::query_as(
"UPDATE grid_maps SET name = $1, public_access = $2, updated_at = NOW() "UPDATE grid_maps
WHERE id = $3 RETURNING *", 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_name)
.bind(new_pa) .bind(new_pa)
.bind(new_ups)
.bind(&new_ul)
.bind(&new_mr)
.bind(&id) .bind(&id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@@ -595,16 +630,51 @@ async fn handle_socket(
let editor = can_edit(&map_state.map, &session).await; let editor = can_edit(&map_state.map, &session).await;
// ── Broadcast channel (1024 slots — reduces RecvError::Lagged risk) ──────
let tx = state let tx = state
.map_rooms .map_rooms
.entry(map_id.clone()) .entry(map_id.clone())
.or_insert_with(|| { .or_insert_with(|| {
let (tx, _) = broadcast::channel(256); let (tx, _) = broadcast::channel(1024);
tx tx
}) })
.clone(); .clone();
let mut rx = tx.subscribe(); 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<CellPatch> = {
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 (mut ws_tx, mut ws_rx) = socket.split();
let init_msg = ServerMessage::State { let init_msg = ServerMessage::State {
@@ -616,20 +686,52 @@ async fn handle_socket(
let _ = ws_tx.send(Message::Text(json.into())).await; 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 { let mut send_task = tokio::spawn(async move {
while let Ok(json) = rx.recv().await { let mut ping_interval = tokio::time::interval(Duration::from_secs(30));
if ws_tx.send(Message::Text(json.into())).await.is_err() { ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
break; // 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 tx_clone = tx.clone();
let mut recv_task = tokio::spawn(async move { let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_rx.next().await { while let Some(Ok(msg)) = ws_rx.next().await {
match msg { match msg {
Message::Text(text) => { 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, Message::Close(_) => break,
_ => {} _ => {}
@@ -664,11 +766,18 @@ async fn fetch_map_state(map_id: &str) -> crate::error::Result<MapState> {
Ok(MapState { map, cells, tokens }) 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( async fn handle_client_message(
raw: &str, raw: &str,
map_id: &str, map_id: &str,
can_edit: bool, can_edit: bool,
tx: &broadcast::Sender<String>, tx: &broadcast::Sender<String>,
paint_buffer: Arc<tokio::sync::Mutex<Vec<CellPatch>>>,
) { ) {
let client_msg: ClientMessage = match serde_json::from_str(raw) { let client_msg: ClientMessage = match serde_json::from_str(raw) {
Ok(m) => m, Ok(m) => m,
@@ -691,26 +800,18 @@ async fn handle_client_message(
let pool = siren_core::data::pool(); let pool = siren_core::data::pool();
let server_msg: Option<ServerMessage> = match client_msg { let server_msg: Option<ServerMessage> = 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 } => { ClientMessage::PaintCell { x, y, color } => {
let result = sqlx::query( {
"INSERT INTO grid_cells (map_id, x, y, color) let mut buf = paint_buffer.lock().await;
VALUES ($1, $2, $3, $4) buf.push(CellPatch {
ON CONFLICT (map_id, x, y) DO UPDATE SET color = EXCLUDED.color", x,
) y,
.bind(map_id) color: color.clone(),
.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
}
} }
Some(ServerMessage::CellPainted { x, y, color })
} }
ClientMessage::PaintCells { cells } => { ClientMessage::PaintCells { cells } => {
@@ -795,6 +896,7 @@ async fn handle_client_message(
y: token.y, y: token.y,
label: token.label, label: token.label,
color: token.color, color: token.color,
size: token.size,
}), }),
Err(e) => { Err(e) => {
log::error!("DB error adding token: {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 } => { ClientMessage::DeleteToken { id } => {
let result = sqlx::query("DELETE FROM grid_tokens WHERE id = $1 AND map_id = $2") let result = sqlx::query("DELETE FROM grid_tokens WHERE id = $1 AND map_id = $2")
.bind(&id) .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<CellPatch>) {
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}");
}
}

View File

@@ -41,6 +41,12 @@ pub struct GridMap {
pub public_access: String, pub public_access: String,
pub owner_id: Uuid, pub owner_id: Uuid,
pub colors: Vec<String>, pub colors: Vec<String>,
/// 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<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -56,6 +62,9 @@ pub struct ListedMap {
pub owner_id: Uuid, pub owner_id: Uuid,
pub owner_username: String, pub owner_username: String,
pub colors: Vec<String>, pub colors: Vec<String>,
pub units_per_square: i32,
pub unit_label: String,
pub movement_rule: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
/// The authenticated caller's role on this map, or NULL if they only have it /// 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 struct UpdateMapPayload {
pub name: Option<String>, pub name: Option<String>,
pub public_access: Option<String>, pub public_access: Option<String>,
pub units_per_square: Option<i32>,
pub unit_label: Option<String>,
pub movement_rule: Option<String>,
} }
#[derive(Deserialize, Clone, Debug)] #[derive(Deserialize, Clone, Debug)]
@@ -138,6 +150,12 @@ pub struct GridToken {
pub y: i32, pub y: i32,
pub label: String, pub label: String,
pub color: String, pub color: String,
#[serde(default = "default_token_size")]
pub size: i32,
}
fn default_token_size() -> i32 {
1
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -176,6 +194,15 @@ pub enum ClientMessage {
DeleteToken { DeleteToken {
id: String, id: String,
}, },
UpdateToken {
id: String,
label: String,
color: String,
},
ResizeToken {
id: String,
size: i32,
},
UpdateColors { UpdateColors {
colors: Vec<String>, colors: Vec<String>,
}, },
@@ -207,6 +234,7 @@ pub enum ServerMessage {
y: i32, y: i32,
label: String, label: String,
color: String, color: String,
size: i32,
}, },
TokenMoved { TokenMoved {
id: String, id: String,
@@ -216,6 +244,15 @@ pub enum ServerMessage {
TokenDeleted { TokenDeleted {
id: String, id: String,
}, },
TokenUpdated {
id: String,
label: String,
color: String,
},
TokenResized {
id: String,
size: i32,
},
ColorsUpdated { ColorsUpdated {
colors: Vec<String>, colors: Vec<String>,
}, },

View File

@@ -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:?}" "<{guild_id}> Play command executed on channel {channel_id} with track: {track_url:?}"
); );
// Handle the 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) => { Ok(items) => {
let mut message = format!("Added {} tracks", items.len()); let mut message = format!("Added {} tracks", items.len());
if items.is_empty() { if items.is_empty() {
@@ -103,45 +103,59 @@ pub async fn enqueue_track(
manager: &Arc<Songbird>, manager: &Arc<Songbird>,
guild_id: GuildId, guild_id: GuildId,
track_url: &str, track_url: &str,
loop_enabled: bool,
) -> Result<Vec<YtDlpItem>> { ) -> Result<Vec<YtDlpItem>> {
let mut playlist_items: Vec<YtDlpItem> = Vec::new(); // Validate URL before doing any I/O
if let Some(handler_lock) = manager.get(guild_id) { if !is_valid_url(track_url) {
let mut handler = handler_lock.lock().await; log::warn!("<{guild_id}> Invalid track url: {}", track_url);
let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap(); return Err(Error::new(422, format!("Invalid track url: {}", track_url)));
let valid = is_valid_url(track_url); }
// Check if the URL is valid // Verify there is an active voice session
if !valid { if manager.get(guild_id).is_none() {
log::warn!("<{guild_id}> Invalid track url: {}", track_url); return Ok(Vec::new());
return Err(Error::new(422, format!("Invalid track url: {}", track_url))); }
}
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 // Fetch guild config
let track_infos: Vec<TrackInfo> = playlist_items let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
.iter() let volume = guild.volume as f32 / 100.0;
.map(|item| TrackInfo {
title: item.get_title().to_owned(),
url: item.get_url().to_owned(),
})
.collect();
// Add each track to the queue // Store track metadata
for item in &playlist_items { let track_infos: Vec<TrackInfo> = playlist_items
let volume = guild.volume as f32 / 100.0; .iter()
let http_client = get_client(); .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 source = YoutubeDl::new(http_client.to_owned(), item.get_url().to_owned());
let input: Input = source.into(); let input: Input = source.into();
let track_title = item.get_title().to_owned(); let track_title = item.get_title().to_owned();
let mut handler = handler_lock.lock().await;
let track_handle: TrackHandle = handler.enqueue_input(input).await; let track_handle: TrackHandle = handler.enqueue_input(input).await;
if let Err(err) = track_handle.set_volume(volume) {
// Set the volume log::warn!("Failed to set volume for track {}: {}", track_title, err);
let _ = track_handle.set_volume(volume); };
if loop_enabled {
log::debug!("<{guild_id}> Added track: {}", track_title); 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.remove_all_global_events();
handler.add_global_event( handler.add_global_event(
Event::Track(TrackEvent::End), Event::Track(TrackEvent::End),
@@ -150,25 +164,23 @@ pub async fn enqueue_track(
call: manager.clone(), call: manager.clone(),
}, },
); );
} // Release the lock
drop(handler);
// Store track metadata so the REST API can expose queue info log::debug!("<{guild_id}> Added track: {}", track_title);
enqueue_tracks(guild_id.get(), track_infos);
if handler.queue().is_empty() {
let _ = handler.queue().resume();
} }
} }
Ok(playlist_items) Ok(playlist_items)
} }
pub fn get_ytdlp_items(url: &str) -> Result<Vec<YtDlpItem>> { pub async fn get_ytdlp_items(url: &str) -> Result<Vec<YtDlpItem>> {
let output = YtDlp::new() let output = YtDlp::new()
.arg("--flat-playlist") .arg("--flat-playlist")
.arg("--dump-json") .arg("--dump-json")
.arg("--no-check-formats") .arg("--no-check-formats")
.arg(url) .arg(url)
.execute()?; .execute()
.await?;
// Check if yt-dlp exited successfully; log stderr if not // Check if yt-dlp exited successfully; log stderr if not
if !output.status.success() { if !output.status.success() {

View File

@@ -13,6 +13,10 @@ use std::{
pub struct TrackInfo { pub struct TrackInfo {
pub title: String, pub title: String,
pub url: String, pub url: String,
/// Total duration in seconds, if known (from yt-dlp metadata).
pub duration_secs: Option<f64>,
/// Whether this track should loop indefinitely.
pub loop_enabled: bool,
} }
/// Global map of guild_id → ordered queue of TrackInfo. /// Global map of guild_id → ordered queue of TrackInfo.
@@ -21,9 +25,7 @@ static TRACK_QUEUES: OnceLock<Arc<DashMap<u64, VecDeque<TrackInfo>>>> = OnceLock
/// Call once from the `ready` event handler to initialise the store. /// Call once from the `ready` event handler to initialise the store.
pub fn init_track_queues() { pub fn init_track_queues() {
TRACK_QUEUES TRACK_QUEUES.set(Arc::new(DashMap::new())).ok();
.set(Arc::new(DashMap::new()))
.ok();
} }
/// Returns a reference to the global TRACK_QUEUES map. /// 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<TrackInfo> { pub fn get_queue(guild_id: u64) -> Vec<TrackInfo> {
queues() queues()
.get(&guild_id) .get(&guild_id)
.map(|q: dashmap::mapref::one::Ref<u64, VecDeque<TrackInfo>>| { .map(|q: dashmap::mapref::one::Ref<u64, VecDeque<TrackInfo>>| q.iter().cloned().collect())
q.iter().cloned().collect()
})
.unwrap_or_default() .unwrap_or_default()
} }
@@ -86,3 +86,61 @@ pub async fn get_is_paused(guild_id: u64) -> bool {
} }
false 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
}

View File

@@ -44,12 +44,10 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
pub async fn skip_track(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<(), String> { pub async fn skip_track(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<(), String> {
if let Some(handler_lock) = manager.get(guild_id.to_owned()) { if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await; let handler = handler_lock.lock().await;
handler handler.queue().skip().map_err(|e| e.to_string())?;
.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 the current track from our metadata store; the next track (if any) moves to front
pop_front(guild_id.get()); pop_front(guild_id.get());
drop(handler);
Ok(()) Ok(())
} else { } else {
Err("No active audio session in this guild".to_string()) Err("No active audio session in this guild".to_string())

View File

@@ -1,7 +1,10 @@
use super::{chat::create_modal_response, commands}; use super::{chat::create_modal_response, commands};
use crate::{ use crate::{
HttpKey, 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::{ use serenity::{
all::{ all::{
@@ -97,9 +100,7 @@ impl EventHandler for BotHandler {
} }
} }
async fn resume(&self, _: Context, _: ResumedEvent) { async fn resume(&self, _: Context, _: ResumedEvent) {}
log::trace!("Resumed");
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) { async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::Command(command) = interaction { if let Interaction::Command(command) = interaction {

View File

@@ -1,12 +1,11 @@
mod model; mod model;
pub use model::*; pub use model::*;
use std::process::{Child, Command, Output, Stdio}; use std::process::{Output, Stdio};
const YOUTUBE_DL_COMMAND: &str = "yt-dlp"; const YOUTUBE_DL_COMMAND: &str = "yt-dlp";
pub struct YtDlp { pub struct YtDlp {
command: Command,
args: Vec<String>, args: Vec<String>,
} }
@@ -17,29 +16,26 @@ impl Default for YtDlp {
} }
impl YtDlp { impl YtDlp {
/// Create a new yt-dlp command builder
pub fn new() -> Self { pub fn new() -> Self {
let mut cmd = Command::new(YOUTUBE_DL_COMMAND); Self { args: Vec::new() }
cmd
.env("LC_ALL", "en_US.UTF-8")
.stdout(Stdio::piped())
.stdin(Stdio::piped())
.stderr(Stdio::piped());
Self {
command: cmd,
args: Vec::new(),
}
} }
/// Add an argument to the yt-dlp command
pub fn arg(&mut self, arg: &str) -> &mut Self { pub fn arg(&mut self, arg: &str) -> &mut Self {
self.args.push(arg.to_owned()); self.args.push(arg.to_owned());
self self
} }
pub fn execute(&mut self) -> std::io::Result<Output> { /// Execute the yt-dlp command asynchronously
self pub async fn execute(&mut self) -> std::io::Result<Output> {
.command tokio::process::Command::new(YOUTUBE_DL_COMMAND)
.args(self.args.clone()) .env("LC_ALL", "en_US.UTF-8")
.spawn() .stdout(Stdio::piped())
.and_then(Child::wait_with_output) .stdin(Stdio::piped())
.stderr(Stdio::piped())
.args(&self.args)
.output()
.await
} }
} }

View File

@@ -32,4 +32,11 @@ impl YtDlpItem {
YtDlpItem::VideoItem { webpage_url, .. } => webpage_url, YtDlpItem::VideoItem { webpage_url, .. } => webpage_url,
} }
} }
pub fn get_duration(&self) -> Option<f64> {
match self {
YtDlpItem::PlaylistItem { duration, .. } => *duration,
YtDlpItem::VideoItem { duration, .. } => *duration,
}
}
} }

View File

@@ -43,6 +43,8 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
http: Arc::clone(&client.http), http: Arc::clone(&client.http),
cache: Arc::clone(&client.cache), cache: Arc::clone(&client.cache),
map_rooms: Arc::new(DashMap::new()), map_rooms: Arc::new(DashMap::new()),
map_paint_buffer: Arc::new(DashMap::new()),
map_flush_tasks: Arc::new(DashMap::new()),
}; };
log::debug!( log::debug!(

View File

@@ -4,73 +4,7 @@ CREATE TABLE IF NOT EXISTS guilds (
owner_id BIGINT, owner_id BIGINT,
volume INTEGER NOT NULL 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 ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
@@ -84,7 +18,6 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
-- External OAuth provider connections (Discord, etc.)
CREATE TABLE IF NOT EXISTS user_connections ( CREATE TABLE IF NOT EXISTS user_connections (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL, provider TEXT NOT NULL,
@@ -94,71 +27,3 @@ CREATE TABLE IF NOT EXISTS user_connections (
PRIMARY KEY (user_id, provider), PRIMARY KEY (user_id, provider),
UNIQUE (provider, provider_user_id) 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'
);

67
migrations/001_map.sql Normal file
View File

@@ -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)
);

43
migrations/002_dnd.sql Normal file
View File

@@ -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
);

18
migrations/003_misc.sql Normal file
View File

@@ -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
);

View File

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

View File

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

View File

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

View File

@@ -139,6 +139,30 @@
color: #8892a4; 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 ── */ /* ── Playback controls ── */
.discord-controls { .discord-controls {
display: flex; display: flex;
@@ -198,6 +222,24 @@
color: #f87171; 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 ── */ /* ── Queue ── */
.discord-queue-count { .discord-queue-count {
font-size: 0.7rem; font-size: 0.7rem;
@@ -246,6 +288,25 @@
min-width: 0; 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 ── */ /* ── Add to queue form ── */
.discord-play-form { .discord-play-form {
display: flex; display: flex;

View File

@@ -3,6 +3,7 @@ import {
FaDiscord, FaDiscord,
FaPause, FaPause,
FaPlay, FaPlay,
FaRepeat,
FaStop, FaStop,
FaForwardStep, FaForwardStep,
} from "react-icons/fa6"; } from "react-icons/fa6";
@@ -10,6 +11,14 @@ import { audioApi } from "../api";
import type { AudioStatus, DiscordGuild, UserInfo } from "../types"; import type { AudioStatus, DiscordGuild, UserInfo } from "../types";
import "./DiscordPanel.css"; 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 { interface Props {
user: UserInfo; user: UserInfo;
} }
@@ -32,8 +41,17 @@ export default function DiscordPanel({ user }: Props) {
const [statusError, setStatusError] = useState<string | null>(null); const [statusError, setStatusError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ── Progress tracking ──
// Interpolated playback position, ticked client-side between polls
const [positionSecs, setPositionSecs] = useState(0);
const [durationSecs, setDurationSecs] = useState<number | null>(null);
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Used to detect when the track changes so we can reset position
const currentTrackTitleRef = useRef<string | null>(null);
// ── Play input ── // ── Play input ──
const [playUrl, setPlayUrl] = useState(""); const [playUrl, setPlayUrl] = useState("");
const [loopOnAdd, setLoopOnAdd] = useState(false);
const [playLoading, setPlayLoading] = useState(false); const [playLoading, setPlayLoading] = useState(false);
const [playError, setPlayError] = useState<string | null>(null); const [playError, setPlayError] = useState<string | null>(null);
@@ -85,6 +103,49 @@ export default function DiscordPanel({ user }: Props) {
}; };
}, [selectedGuildId, fetchStatus]); }, [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 ── // ── Helpers ──
function errMsg(err: unknown) { function errMsg(err: unknown) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -108,7 +169,7 @@ export default function DiscordPanel({ user }: Props) {
setPlayLoading(true); setPlayLoading(true);
setPlayError(null); setPlayError(null);
try { try {
await audioApi.play(selectedGuildId, playUrl.trim()); await audioApi.play(selectedGuildId, playUrl.trim(), loopOnAdd);
setPlayUrl(""); setPlayUrl("");
if (selectedGuildId) await fetchStatus(selectedGuildId); if (selectedGuildId) await fetchStatus(selectedGuildId);
} catch (err) { } catch (err) {
@@ -122,7 +183,6 @@ export default function DiscordPanel({ user }: Props) {
if (!discordConnection) return null; if (!discordConnection) return null;
const selectedGuild = guilds.find((g) => g.id === selectedGuildId); const selectedGuild = guilds.find((g) => g.id === selectedGuildId);
const isPlaying = !!status?.current_track && !status?.is_paused;
return ( return (
<div className="discord-panel"> <div className="discord-panel">
@@ -140,9 +200,7 @@ export default function DiscordPanel({ user }: Props) {
) : guildsError ? ( ) : guildsError ? (
<p className="discord-error">{guildsError}</p> <p className="discord-error">{guildsError}</p>
) : guilds.length === 0 ? ( ) : guilds.length === 0 ? (
<p className="discord-muted"> <p className="discord-muted">The bot isn't in any servers yet.</p>
The bot isn't in any servers yet.
</p>
) : guilds.length === 1 ? ( ) : guilds.length === 1 ? (
<p className="discord-guild-name">{guilds[0].name}</p> <p className="discord-guild-name">{guilds[0].name}</p>
) : ( ) : (
@@ -193,6 +251,24 @@ export default function DiscordPanel({ user }: Props) {
<p className="discord-track-status"> <p className="discord-track-status">
{status.is_paused ? "⏸ Paused" : "▶ Playing"} {status.is_paused ? "⏸ Paused" : "▶ Playing"}
</p> </p>
{/* Progress bar */}
<div className="discord-progress-bar">
<div
className="discord-progress-fill"
style={{
width:
durationSecs !== null && durationSecs > 0
? `${Math.min((positionSecs / durationSecs) * 100, 100)}%`
: "0%",
}}
/>
</div>
<div className="discord-progress-time">
<span>{formatTime(positionSecs)}</span>
{durationSecs !== null && (
<span>{formatTime(durationSecs)}</span>
)}
</div>
</div> </div>
) : ( ) : (
<p className="discord-muted">Nothing is playing</p> <p className="discord-muted">Nothing is playing</p>
@@ -239,6 +315,25 @@ export default function DiscordPanel({ user }: Props) {
> >
<FaStop /> <FaStop />
</button> </button>
<button
className={`discord-btn discord-btn-loop${status?.current_track?.loop_enabled ? " discord-btn-loop-active" : ""}`}
title={
status?.current_track?.loop_enabled
? "Disable loop"
: "Enable loop"
}
onClick={() =>
runAction(() =>
audioApi.setLoop(
selectedGuildId!,
!status?.current_track?.loop_enabled,
),
)
}
disabled={!status?.current_track}
>
<FaRepeat />
</button>
</div> </div>
{actionError && <p className="discord-error">{actionError}</p>} {actionError && <p className="discord-error">{actionError}</p>}
@@ -248,7 +343,14 @@ export default function DiscordPanel({ user }: Props) {
{/* Queue */} {/* Queue */}
{selectedGuild && ( {selectedGuild && (
<section className="discord-section"> <section className="discord-section">
<h3>Queue {status && status.queue.length > 0 && <span className="discord-queue-count">({status.queue.length})</span>}</h3> <h3>
Queue{" "}
{status && status.queue.length > 0 && (
<span className="discord-queue-count">
({status.queue.length})
</span>
)}
</h3>
{!status || status.queue.length === 0 ? ( {!status || status.queue.length === 0 ? (
<p className="discord-muted">Queue is empty</p> <p className="discord-muted">Queue is empty</p>
) : ( ) : (
@@ -287,6 +389,14 @@ export default function DiscordPanel({ user }: Props) {
{playLoading ? "Adding…" : "Add"} {playLoading ? "Adding…" : "Add"}
</button> </button>
</form> </form>
<label className="discord-loop-label">
<input
type="checkbox"
checked={loopOnAdd}
onChange={(e) => setLoopOnAdd(e.target.checked)}
/>
Loop
</label>
{playError && <p className="discord-error">{playError}</p>} {playError && <p className="discord-error">{playError}</p>}
</section> </section>
)} )}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import type { ServerMessage, ClientMessage } from "../types"; 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( export function useWebSocket(
mapId: string, mapId: string,
onMessage: (msg: ServerMessage) => void, onMessage: (msg: ServerMessage) => void,
@@ -10,39 +14,93 @@ export function useWebSocket(
const onMessageRef = useRef(onMessage); const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage; onMessageRef.current = onMessage;
const [connected, setConnected] = useState(false);
// Reconnect state — live across re-renders without triggering them
const retryCountRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set to true when the effect tears down so we stop reconnecting
const destroyedRef = useRef(false);
useEffect(() => { useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; destroyedRef.current = false;
// The browser automatically sends the siren_session cookie with the retryCountRef.current = 0;
// WebSocket upgrade request — no manual token query param needed.
const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws`;
const ws = new WebSocket(url); function connect() {
wsRef.current = ws; if (destroyedRef.current) return;
ws.onopen = () => { const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
console.log(`[WS] Connected to map ${mapId}`); // 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) => { const ws = new WebSocket(url);
try { wsRef.current = ws;
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) => { ws.onopen = () => {
console.error("[WS] Error:", err); console.log(`[WS] Connected to map ${mapId}`);
}; retryCountRef.current = 0;
setConnected(true);
};
ws.onclose = () => { ws.onmessage = (event: MessageEvent) => {
console.log(`[WS] Disconnected from map ${mapId}`); 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 () => { return () => {
ws.close(); destroyedRef.current = true;
wsRef.current = null; if (retryTimerRef.current !== null) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setConnected(false);
}; };
}, [mapId]); }, [mapId]);
@@ -53,5 +111,5 @@ export function useWebSocket(
} }
}, []); }, []);
return { send }; return { send, connected };
} }

View File

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

View File

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

View File

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