Updated Grid
This commit is contained in:
@@ -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 <0–100>` | Set the playback volume | 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 |
|
| `/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 |
|
||||||
|
|||||||
11
bruno/audio/Track Status.bru
Normal file
11
bruno/audio/Track Status.bru
Normal 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
4
bruno/audio/folder.bru
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
meta {
|
||||||
|
name: audio
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
15
bruno/auth/discord/Authorize.bru
Normal file
15
bruno/auth/discord/Authorize.bru
Normal 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
|
||||||
|
}
|
||||||
4
bruno/auth/discord/folder.bru
Normal file
4
bruno/auth/discord/folder.bru
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
meta {
|
||||||
|
name: discord
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
8
bruno/auth/folder.bru
Normal file
8
bruno/auth/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: auth
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
23
bruno/auth/local/Change Password.bru
Normal file
23
bruno/auth/local/Change Password.bru
Normal 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
|
||||||
|
}
|
||||||
16
bruno/auth/local/Get Self.bru
Normal file
16
bruno/auth/local/Get Self.bru
Normal 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
|
||||||
|
}
|
||||||
23
bruno/auth/local/Login.bru
Normal file
23
bruno/auth/local/Login.bru
Normal 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
|
||||||
|
}
|
||||||
8
bruno/auth/local/folder.bru
Normal file
8
bruno/auth/local/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: local
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
4
bruno/dice/folder.bru
Normal file
4
bruno/dice/folder.bru
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
meta {
|
||||||
|
name: dice
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Authorize
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/oauth/authorize
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -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>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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
67
migrations/001_map.sql
Normal 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
43
migrations/002_dnd.sql
Normal 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
18
migrations/003_misc.sql
Normal 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
|
||||||
|
);
|
||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
75
ui/src/components/TokenStackPicker.css
Normal file
75
ui/src/components/TokenStackPicker.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
.stack-picker {
|
||||||
|
position: absolute;
|
||||||
|
background: #1f2937;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 220px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-picker-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0.1rem 0.4rem 0.35rem;
|
||||||
|
border-bottom: 1px solid #374151;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-picker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: background 0.1s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-picker-item:hover {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-picker-swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-picker-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-picker-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #374151;
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-picker-move-all {
|
||||||
|
color: #a5b4fc;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-picker-move-all:hover {
|
||||||
|
background: #312e81;
|
||||||
|
}
|
||||||
67
ui/src/components/TokenStackPicker.tsx
Normal file
67
ui/src/components/TokenStackPicker.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { GridToken } from "../types";
|
||||||
|
import "./TokenStackPicker.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tokens: GridToken[];
|
||||||
|
/** Canvas-relative pixel position to anchor the popup */
|
||||||
|
canvasX: number;
|
||||||
|
canvasY: number;
|
||||||
|
onPickToken: (token: GridToken) => void;
|
||||||
|
onMoveStack: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TokenStackPicker({
|
||||||
|
tokens,
|
||||||
|
canvasX,
|
||||||
|
canvasY,
|
||||||
|
onPickToken,
|
||||||
|
onMoveStack,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Invisible full-canvas backdrop to close on outside click */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 49,
|
||||||
|
}}
|
||||||
|
onMouseDown={onCancel}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="stack-picker"
|
||||||
|
style={{ left: canvasX, top: canvasY }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="stack-picker-title">{tokens.length} tokens stacked</div>
|
||||||
|
|
||||||
|
{tokens.map((tok) => (
|
||||||
|
<button
|
||||||
|
key={tok.id}
|
||||||
|
className="stack-picker-item"
|
||||||
|
onClick={() => onPickToken(tok)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="stack-picker-swatch"
|
||||||
|
style={{ background: tok.color }}
|
||||||
|
/>
|
||||||
|
<span className="stack-picker-label" title={tok.label}>
|
||||||
|
{tok.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="stack-picker-divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="stack-picker-item stack-picker-move-all"
|
||||||
|
onClick={onMoveStack}
|
||||||
|
>
|
||||||
|
Move entire stack
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import type { ServerMessage, ClientMessage } from "../types";
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user