From ca95582d928252e0ec28549d9ea08fe0ab833e39 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Sat, 4 Apr 2026 18:31:28 -0400 Subject: [PATCH] Updates to pages --- Taskfile.yml | 40 +- crates/siren-api/src/admin/mod.rs | 190 ++++++++ crates/siren-api/src/audio/mod.rs | 152 ++++++- crates/siren-api/src/auth/discord.rs | 10 +- crates/siren-api/src/auth/local.rs | 21 +- crates/siren-api/src/auth/middleware.rs | 46 +- crates/siren-api/src/auth/mod.rs | 2 +- crates/siren-api/src/lib.rs | 5 +- crates/siren-bot/Cargo.toml | 1 + crates/siren-bot/src/commands/audio/mod.rs | 1 + crates/siren-bot/src/commands/audio/play.rs | 23 +- crates/siren-bot/src/commands/audio/queue.rs | 88 ++++ crates/siren-bot/src/commands/audio/skip.rs | 33 +- crates/siren-bot/src/commands/audio/stop.rs | 23 +- crates/siren-bot/src/handler.rs | 5 +- Dockerfile => docker/Dockerfile | 19 +- docker/Dockerfile.ui | 30 ++ .../docker-compose.yml | 25 +- docker/nginx.conf | 32 ++ migrations/000_initial.sql | 2 + ui/package.json | 3 +- ui/src/App.tsx | 393 ++--------------- ui/src/api.ts | 67 +++ ui/src/components/AccountPanel.css | 18 + ui/src/components/AccountPanel.tsx | 417 +++++++++--------- ui/src/components/AdminPanel.css | 273 ++++++++++++ ui/src/components/AdminPanel.tsx | 243 ++++++++++ ui/src/components/DiscordPanel.css | 297 +++++++++++++ ui/src/components/DiscordPanel.tsx | 295 +++++++++++++ ui/src/components/EditMapModal.tsx | 6 +- ui/src/components/Header.css | 49 ++ ui/src/components/Header.tsx | 126 ++++-- ui/src/components/MapListModal.tsx | 6 +- ui/src/components/Modal.css | 11 + ui/src/components/NewMapModal.tsx | 8 +- ui/src/context/AuthContext.tsx | 47 ++ ui/src/main.tsx | 8 +- ui/src/pages/AccountPage.tsx | 26 ++ ui/src/pages/AdminPage.tsx | 21 + ui/src/pages/MapPage.tsx | 351 +++++++++++++++ ui/src/pages/Pages.css | 25 ++ ui/src/types.ts | 33 ++ 42 files changed, 2831 insertions(+), 640 deletions(-) create mode 100644 crates/siren-api/src/admin/mod.rs create mode 100644 crates/siren-bot/src/commands/audio/queue.rs rename Dockerfile => docker/Dockerfile (76%) create mode 100644 docker/Dockerfile.ui rename docker-compose.yml => docker/docker-compose.yml (69%) create mode 100644 docker/nginx.conf create mode 100644 ui/src/components/AdminPanel.css create mode 100644 ui/src/components/AdminPanel.tsx create mode 100644 ui/src/components/DiscordPanel.css create mode 100644 ui/src/components/DiscordPanel.tsx create mode 100644 ui/src/context/AuthContext.tsx create mode 100644 ui/src/pages/AccountPage.tsx create mode 100644 ui/src/pages/AdminPage.tsx create mode 100644 ui/src/pages/MapPage.tsx create mode 100644 ui/src/pages/Pages.css diff --git a/Taskfile.yml b/Taskfile.yml index 99dcf0c..afa5c6a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -90,34 +90,40 @@ tasks: # Docker # ----------------------------------------------------------- docker:build: - desc: "Build the Docker image (use v=x.x.x to set version, default is \"latest\")" + desc: "Build the Rust app Docker image (use v=x.x.x to set version, default is \"latest\")" cmds: - - docker build -f Dockerfile -t siren:{{.VERSION}} . + - docker build -f docker/Dockerfile -t siren:{{.VERSION}} . + silent: true + + docker:build:ui: + desc: "Build the UI Docker image (use v=x.x.x to set version, default is \"latest\")" + cmds: + - docker build -f docker/Dockerfile.ui -t siren-ui:{{.VERSION}} . silent: true docker:up: - desc: "Start backend containers" + desc: "Start backend containers (postgres + valkey)" cmds: - - docker compose up -d + - docker compose -f docker/docker-compose.yml up -d silent: true docker:up:all: - desc: "Start all containers" + desc: "Start all containers (app + ui + postgres + valkey)" cmds: - - docker compose --profile app up -d + - docker compose -f docker/docker-compose.yml --profile app --profile ui up -d silent: true docker:down: desc: "Stop all containers" cmds: - - docker compose --profile app down + - docker compose -f docker/docker-compose.yml --profile app --profile ui down silent: true docker:clean: desc: "Stop all containers and remove volumes" prompt: "This will remove all docker containers, networks, volumes, and images. Are you sure?" cmds: - - docker compose --profile app down -v + - docker compose -f docker/docker-compose.yml --profile app --profile ui down -v silent: true docker:refresh: @@ -169,13 +175,25 @@ tasks: # Utilities # ----------------------------------------------------------- psql: - desc: Connect to the database + desc: "Connect to the docker database" + vars: + POSTGRES_USER: '{{.POSTGRES_USER | default "postgres"}}' + POSTGRES_DB: '{{.POSTGRES_DB | default "siren"}}' + deps: [ docker:up ] cmds: - - docker exec -it siren-postgres psql -U $DATABASE_USER -P pager=off + - docker exec -it siren-postgres psql -U {{.POSTGRES_USER}} -d {{.POSTGRES_DB}} -P pager=off + silent: true + + psql:admin: + desc: "Grant admin role to a user" + cmds: + - docker exec -it siren-postgres psql -U {{.POSTGRES_USER}} -d {{.POSTGRES_DB}} -c "UPDATE users SET role = 'admin' WHERE username = '{{.user}}'" + requires: + vars: [ user ] silent: true ngrok: - desc: Start ngrok tunnel + desc: "Start ngrok tunnel" vars: UI_PORT: '{{.UI_PORT | default "5173"}}' cmds: diff --git a/crates/siren-api/src/admin/mod.rs b/crates/siren-api/src/admin/mod.rs new file mode 100644 index 0000000..dbf530b --- /dev/null +++ b/crates/siren-api/src/admin/mod.rs @@ -0,0 +1,190 @@ +use crate::{ + AppState, + auth::AdminAuthorization, + error::{Error, Result}, +}; +use axum::{ + Json, + Router, + extract::Path, + http::StatusCode, + routing::{delete, get, put}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use siren_core::data; +use std::sync::Arc; +use uuid::Uuid; + +pub fn get_routes() -> Router> { + Router::new() + .route("/users", get(list_users)) + .route("/users/{id}/role", put(set_user_role)) + .route("/users/{id}/ban", put(ban_user)) + .route("/users/{id}/unban", put(unban_user)) + .route("/users/{id}", delete(delete_user)) +} + +/// Minimal user record returned by the admin list endpoint. +#[derive(Serialize, sqlx::FromRow)] +pub struct AdminUserRecord { + pub id: String, + pub username: String, + pub email: Option, + pub role: String, + pub status: String, + pub created_at: DateTime, +} + +#[derive(sqlx::FromRow)] +struct DbAdminUser { + id: Uuid, + username: String, + email: Option, + role: String, + status: String, + created_at: DateTime, +} + +#[derive(Deserialize)] +struct SetRolePayload { + role: String, +} + +/// `GET /admin/users` — list all user accounts (admin only). +async fn list_users( + AdminAuthorization(_session): AdminAuthorization, +) -> Result>> { + let pool = data::pool(); + + let rows: Vec = sqlx::query_as( + "SELECT id, username, email, role, status, created_at \ + FROM users \ + ORDER BY created_at ASC", + ) + .fetch_all(pool) + .await?; + + let records = rows + .into_iter() + .map(|r| AdminUserRecord { + id: r.id.to_string(), + username: r.username, + email: r.email, + role: r.role, + status: r.status, + created_at: r.created_at, + }) + .collect(); + + Ok(Json(records)) +} + +/// `PUT /admin/users/{id}/role` — promote or demote a user (admin only). +async fn set_user_role( + AdminAuthorization(session): AdminAuthorization, + Path(id): Path, + Json(payload): Json, +) -> Result { + if payload.role != "admin" && payload.role != "user" { + return Err(Error::new(422, "role must be 'admin' or 'user'".into())); + } + + // Prevent an admin from demoting themselves + if id == session.user_id && payload.role != "admin" { + return Err(Error::new( + 422, + "You cannot remove your own admin role".into(), + )); + } + + let pool = data::pool(); + + let affected = sqlx::query("UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2") + .bind(&payload.role) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if affected == 0 { + return Err(Error::not_found(format!("User {id} not found"))); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// `PUT /admin/users/{id}/ban` — ban a user account (admin only). +async fn ban_user( + AdminAuthorization(session): AdminAuthorization, + Path(id): Path, +) -> Result { + // Admins cannot ban themselves + if id == session.user_id { + return Err(Error::new(422, "You cannot ban yourself".into())); + } + + let pool = data::pool(); + + let affected = + sqlx::query("UPDATE users SET status = 'banned', updated_at = NOW() WHERE id = $1") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if affected == 0 { + return Err(Error::not_found(format!("User {id} not found"))); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// `PUT /admin/users/{id}/unban` — reinstate a banned user (admin only). +async fn unban_user( + AdminAuthorization(_session): AdminAuthorization, + Path(id): Path, +) -> Result { + let pool = data::pool(); + + let affected = + sqlx::query("UPDATE users SET status = 'active', updated_at = NOW() WHERE id = $1") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if affected == 0 { + return Err(Error::not_found(format!("User {id} not found"))); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// `DELETE /admin/users/{id}` — permanently delete a user account (admin only). +async fn delete_user( + AdminAuthorization(session): AdminAuthorization, + Path(id): Path, +) -> Result { + // Admins cannot delete themselves + if id == session.user_id { + return Err(Error::new( + 422, + "You cannot delete your own account via the admin panel".into(), + )); + } + + let pool = data::pool(); + + let affected = sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if affected == 0 { + return Err(Error::not_found(format!("User {id} not found"))); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/siren-api/src/audio/mod.rs b/crates/siren-api/src/audio/mod.rs index 8605ae2..954bf49 100644 --- a/crates/siren-api/src/audio/mod.rs +++ b/crates/siren-api/src/audio/mod.rs @@ -8,28 +8,42 @@ use axum::{ Router, extract::{Path, State}, http::StatusCode, - routing::post, + routing::{get, post}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use siren_bot::{ commands::audio::{ join_voice_channel, pause::pause_track, play::enqueue_track, + queue::{TrackInfo, get_is_paused, get_queue}, resume::resume_track, + skip::skip_track, + stop::stop_track, }, handler::get_songbird, }; use std::sync::Arc; use uuid::Uuid; +/// Routes that don't require a guild_id (nested at /api/audio) pub fn get_routes() -> Router> { + Router::new().route("/guilds", get(list_guilds)) +} + +/// Routes that operate on a specific guild (nested at /api/audio/{guild_id}) +pub fn get_guild_routes() -> Router> { Router::new() .route("/play", post(play_audio)) .route("/pause", post(pause_audio)) .route("/resume", post(resume_audio)) + .route("/stop", post(stop_audio)) + .route("/skip", post(skip_audio)) + .route("/status", get(audio_status)) } +// ── Shared helpers ──────────────────────────────────────────────────────────── + #[derive(Deserialize)] struct PlayTrackRequest { url: String, @@ -52,6 +66,38 @@ async fn get_discord_snowflake(local_user_id: Uuid) -> Result { .ok_or_else(|| Error::not_found("Discord account not connected".to_string())) } +// ── GET /api/audio/guilds ───────────────────────────────────────────────────── + +#[derive(Serialize)] +struct GuildInfo { + id: String, + name: String, +} + +/// Returns all guilds the bot is currently in (from its Discord cache). +async fn list_guilds( + SessionAuthorization(session): SessionAuthorization, + State(state): State>, +) -> Result>> { + session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + + let guilds: Vec = state + .cache + .guilds() + .into_iter() + .filter_map(|guild_id| { + state.cache.guild(guild_id).map(|g| GuildInfo { + id: guild_id.get().to_string(), + name: g.name.clone(), + }) + }) + .collect(); + + Ok(Json(guilds)) +} + +// ── POST /api/audio/{guild_id}/play ────────────────────────────────────────── + async fn play_audio( SessionAuthorization(session): SessionAuthorization, State(state): State>, @@ -88,6 +134,8 @@ async fn play_audio( Ok(()) } +// ── POST /api/audio/{guild_id}/pause ───────────────────────────────────────── + async fn pause_audio( SessionAuthorization(session): SessionAuthorization, State(state): State>, @@ -96,18 +144,18 @@ async fn pause_audio( session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; log::debug!("Pausing audio in guild: {}", guild_id); - // Validate if the guild exists in the cache let guild_id = match state.cache.guild(guild_id) { Some(guild) => guild.id, None => return Err(Error::not_found("Guild not found".to_string())), }; - // Pause the track let manager = get_songbird(); pause_track(manager, &guild_id).await?; Ok(()) } +// ── POST /api/audio/{guild_id}/resume ──────────────────────────────────────── + async fn resume_audio( SessionAuthorization(session): SessionAuthorization, State(state): State>, @@ -116,14 +164,106 @@ async fn resume_audio( session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; log::debug!("Resuming audio in guild: {}", guild_id); - // Validate if the guild exists in the cache let guild_id = match state.cache.guild(guild_id) { Some(guild) => guild.id, None => return Err(Error::not_found("Guild not found".to_string())), }; - // Resume the track let manager = get_songbird(); resume_track(manager, &guild_id).await?; Ok(()) } + +// ── POST /api/audio/{guild_id}/stop ────────────────────────────────────────── + +async fn stop_audio( + SessionAuthorization(session): SessionAuthorization, + State(state): State>, + Path(guild_id): Path, +) -> Result<()> { + session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + log::debug!("Stopping audio in guild: {}", guild_id); + + 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 manager = get_songbird(); + stop_track(manager, &guild_id) + .await + .map_err(|e| Error::new(500, e))?; + Ok(()) +} + +async fn skip_audio( + SessionAuthorization(session): SessionAuthorization, + State(state): State>, + Path(guild_id): Path, +) -> Result<()> { + session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + log::debug!("<{}> Skipping audio", guild_id); + + 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 manager = get_songbird(); + skip_track(manager, &guild_id) + .await + .map_err(|e| Error::new(500, e))?; + Ok(()) +} + +#[derive(Serialize)] +struct AudioStatus { + voice_channel: Option, + is_paused: bool, + current_track: Option, + queue: Vec, +} + +async fn audio_status( + SessionAuthorization(session): SessionAuthorization, + State(state): State>, + Path(guild_id): Path, +) -> Result> { + session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + + let guild_id_snowflake = match state.cache.guild(guild_id) { + Some(guild) => guild.id, + None => return Err(Error::not_found("Guild not found".to_string())), + }; + + // ── Voice channel: look up the bot's own voice state + channel name from cache ── + let bot_user_id = state.cache.current_user().id; + let voice_channel = state + .cache + .guild(guild_id_snowflake) + .and_then(|guild| { + let ch_id = guild + .voice_states + .get(&bot_user_id) + .and_then(|vs| vs.channel_id)?; + guild.channels.get(&ch_id).map(|ch| ch.name.clone()) + }); + + // ── Playback paused state (delegated to siren-bot to keep songbird internal) ── + let is_paused = get_is_paused(guild_id).await; + + // ── Queue metadata from our store (index 0 = currently playing) ── + let mut full_queue = get_queue(guild_id); + let current_track = if !full_queue.is_empty() { + Some(full_queue.remove(0)) + } else { + None + }; + + Ok(Json(AudioStatus { + voice_channel, + is_paused, + current_track, + queue: full_queue, + })) +} diff --git a/crates/siren-api/src/auth/discord.rs b/crates/siren-api/src/auth/discord.rs index f7bf6f5..7e0ec31 100644 --- a/crates/siren-api/src/auth/discord.rs +++ b/crates/siren-api/src/auth/discord.rs @@ -294,8 +294,8 @@ async fn do_oauth_callback( None => { // Find existing connection → local user_id - let local_user_id: Option<(Uuid, String)> = sqlx::query_as( - "SELECT u.id, u.username \ + let local_user_id: Option<(Uuid, String, String)> = sqlx::query_as( + "SELECT u.id, u.username, u.status \ FROM user_connections uc \ JOIN users u ON u.id = uc.user_id \ WHERE uc.provider = 'discord' AND uc.provider_user_id = $1", @@ -311,6 +311,10 @@ async fn do_oauth_callback( let (user_id, username) = match local_user_id { // Already linked — use the existing local user Some(row) => { + // Reject banned accounts + if row.2 == "banned" { + return err_redirect(StatusCode::FORBIDDEN); + } // Keep provider fields up to date sqlx::query( "UPDATE user_connections \ @@ -327,7 +331,7 @@ async fn do_oauth_callback( err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() })?; - row + (row.0, row.1) } // First login — create a local user + connection diff --git a/crates/siren-api/src/auth/local.rs b/crates/siren-api/src/auth/local.rs index 928ff48..04f12f4 100644 --- a/crates/siren-api/src/auth/local.rs +++ b/crates/siren-api/src/auth/local.rs @@ -85,6 +85,10 @@ pub struct UserInfo { /// OAuth and can safely disconnect OAuth providers). pub has_password: bool, pub connections: Vec, + /// Site-level role: `"admin"` or `"user"`. + pub role: String, + /// Account status: `"active"` or `"banned"`. + pub status: String, } #[derive(sqlx::FromRow)] @@ -95,6 +99,8 @@ struct DbUser { last_name: Option, email: Option, password_hash: Option, + role: String, + status: String, } #[derive(sqlx::FromRow)] @@ -176,7 +182,8 @@ async fn load_user_info(user_id: Uuid) -> Result { let pool = data::pool(); let user: DbUser = sqlx::query_as( - "SELECT id, username, first_name, last_name, email, password_hash FROM users WHERE id = $1", + "SELECT id, username, first_name, last_name, email, password_hash, role, status \ + FROM users WHERE id = $1", ) .bind(user_id) .fetch_one(pool) @@ -197,6 +204,8 @@ async fn load_user_info(user_id: Uuid) -> Result { last_name: user.last_name, email: user.email, has_password: user.password_hash.is_some(), + role: user.role, + status: user.status, connections: connections .into_iter() .map(|c| ConnectionInfo { @@ -260,13 +269,13 @@ async fn login( ) -> Result { let pool = data::pool(); - let row: Option<(Uuid, String, Option)> = - sqlx::query_as("SELECT id, username, password_hash FROM users WHERE username = $1") + let row: Option<(Uuid, String, Option, String)> = + sqlx::query_as("SELECT id, username, password_hash, status FROM users WHERE username = $1") .bind(&payload.username) .fetch_optional(pool) .await?; - let (user_id, username, password_hash) = + let (user_id, username, password_hash, status) = row.ok_or_else(|| Error::new(401, "Invalid username or password".into()))?; let hash = @@ -276,6 +285,10 @@ async fn login( return Err(Error::new(401, "Invalid username or password".into())); } + if status == "banned" { + return Err(Error::new(403, "This account has been banned".into())); + } + let ip = extract_ip(&headers); let user_agent = headers .get("user-agent") diff --git a/crates/siren-api/src/auth/middleware.rs b/crates/siren-api/src/auth/middleware.rs index a52eb75..2df7ecd 100644 --- a/crates/siren-api/src/auth/middleware.rs +++ b/crates/siren-api/src/auth/middleware.rs @@ -1,6 +1,6 @@ use crate::{ auth::{bearer_token::BearerTokenClaims, session::Session}, - error::Result, + error::{Error, Result}, }; use axum::{ extract::FromRequestParts, @@ -10,6 +10,7 @@ use axum_extra::extract::CookieJar; use chrono::Utc; use jsonwebtoken::{DecodingKey, Validation, decode}; use sha2::{Digest, Sha256}; +use siren_core::data; pub const COOKIE_NAME: &str = "siren_session"; @@ -130,3 +131,46 @@ pub async fn check_cookie_from_header_str( } None } + +/// Extractor that requires the caller to be an authenticated site admin. +/// +/// Returns `401 Unauthorized` if there is no valid session, or +/// `403 Forbidden` if the user's role is not `"admin"`. +/// On success, the inner `Session` is available to the handler. +pub struct AdminAuthorization(pub Session); + +impl FromRequestParts for AdminAuthorization +where + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result { + let SessionAuthorization(maybe_session) = + SessionAuthorization::from_request_parts(parts, state) + .await + .unwrap(); + + let session = maybe_session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; + + // Verify admin role in the database + let pool = data::pool(); + let role: Option = sqlx::query_scalar("SELECT role FROM users WHERE id = $1") + .bind(session.user_id) + .fetch_optional(pool) + .await + .map_err(|e| { + log::error!("DB error checking admin role: {e}"); + Error::from(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + match role.as_deref() { + Some("admin") => Ok(AdminAuthorization(session)), + Some(_) => Err(Error::from(StatusCode::FORBIDDEN)), + None => Err(Error::from(StatusCode::UNAUTHORIZED)), + } + } +} diff --git a/crates/siren-api/src/auth/mod.rs b/crates/siren-api/src/auth/mod.rs index f0dc209..3a73fac 100644 --- a/crates/siren-api/src/auth/mod.rs +++ b/crates/siren-api/src/auth/mod.rs @@ -11,7 +11,7 @@ pub use local::UserInfo; pub use session::Session; pub mod middleware; -pub use middleware::SessionAuthorization; +pub use middleware::{AdminAuthorization, SessionAuthorization}; pub fn get_routes() -> Router> { Router::new() diff --git a/crates/siren-api/src/lib.rs b/crates/siren-api/src/lib.rs index db46f4d..d6bc518 100644 --- a/crates/siren-api/src/lib.rs +++ b/crates/siren-api/src/lib.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod app; mod app_state; pub mod audio; @@ -13,8 +14,10 @@ use std::sync::Arc; pub fn get_routes() -> Router> { Router::new() + .nest("/admin", admin::get_routes()) .nest("/auth", auth::get_routes()) - .nest("/audio/{guild_id}", audio::get_routes()) + .nest("/audio", audio::get_routes()) + .nest("/audio/{guild_id}", audio::get_guild_routes()) .nest("/dice", dice::get_routes()) .nest("/grid", grid::get_routes()) } diff --git a/crates/siren-bot/Cargo.toml b/crates/siren-bot/Cargo.toml index 3d79176..001b366 100644 --- a/crates/siren-bot/Cargo.toml +++ b/crates/siren-bot/Cargo.toml @@ -20,3 +20,4 @@ chrono = { workspace = true } regex = { workspace = true } uuid = { workspace = true } lazy_static = { workspace = true } +dashmap = { workspace = true } diff --git a/crates/siren-bot/src/commands/audio/mod.rs b/crates/siren-bot/src/commands/audio/mod.rs index bd9b32a..5e3ef7c 100644 --- a/crates/siren-bot/src/commands/audio/mod.rs +++ b/crates/siren-bot/src/commands/audio/mod.rs @@ -11,6 +11,7 @@ use std::sync::Arc; pub mod mute; pub mod pause; pub mod play; +pub mod queue; pub mod resume; pub mod skip; pub mod stop; diff --git a/crates/siren-bot/src/commands/audio/play.rs b/crates/siren-bot/src/commands/audio/play.rs index f06236c..c988655 100644 --- a/crates/siren-bot/src/commands/audio/play.rs +++ b/crates/siren-bot/src/commands/audio/play.rs @@ -1,4 +1,9 @@ -use super::{is_valid_url, join_voice_channel, leave_voice_channel}; +use super::{ + is_valid_url, + join_voice_channel, + leave_voice_channel, + queue::{TrackInfo, enqueue_tracks, pop_front}, +}; use crate::{ chat::{create_message_response, edit_response, process_message}, error::{Error, Result}, @@ -113,6 +118,15 @@ pub async fn enqueue_track( playlist_items = get_ytdlp_items(track_url)?; + // Collect TrackInfo for the queue store before borrowing `item` in the loop + let track_infos: Vec = playlist_items + .iter() + .map(|item| TrackInfo { + title: item.get_title().to_owned(), + url: item.get_url().to_owned(), + }) + .collect(); + // Add each track to the queue for item in &playlist_items { let volume = guild.volume as f32 / 100.0; @@ -137,6 +151,10 @@ pub async fn enqueue_track( }, ); } + + // Store track metadata so the REST API can expose queue info + enqueue_tracks(guild_id.get(), track_infos); + if handler.queue().is_empty() { let _ = handler.queue().resume(); } @@ -204,6 +222,9 @@ struct TrackEndNotifier { impl EventHandler for TrackEndNotifier { async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option { if let songbird::EventContext::Track(_track_list) = ctx { + // Remove the finished track from our metadata store + pop_front(self.guild_id.get()); + if let Some(call) = self.call.get(self.guild_id) { let mut handler = call.lock().await; if handler.queue().is_empty() { diff --git a/crates/siren-bot/src/commands/audio/queue.rs b/crates/siren-bot/src/commands/audio/queue.rs new file mode 100644 index 0000000..80e0fdc --- /dev/null +++ b/crates/siren-bot/src/commands/audio/queue.rs @@ -0,0 +1,88 @@ +use crate::handler::get_songbird; +use dashmap::DashMap; +use serde::Serialize; +use serenity::model::prelude::GuildId; +use songbird::tracks::PlayMode; +use std::{ + collections::VecDeque, + sync::{Arc, OnceLock}, +}; + +/// Metadata for a single track stored in our queue. +#[derive(Debug, Clone, Serialize)] +pub struct TrackInfo { + pub title: String, + pub url: String, +} + +/// Global map of guild_id → ordered queue of TrackInfo. +/// Initialised once by the bot handler's `ready` event. +static TRACK_QUEUES: OnceLock>>> = OnceLock::new(); + +/// Call once from the `ready` event handler to initialise the store. +pub fn init_track_queues() { + TRACK_QUEUES + .set(Arc::new(DashMap::new())) + .ok(); +} + +/// Returns a reference to the global TRACK_QUEUES map. +fn queues() -> &'static Arc>> { + TRACK_QUEUES + .get() + .expect("TRACK_QUEUES not initialised – call init_track_queues() in the ready handler") +} + +/// Append one or more tracks to the end of a guild's queue. +pub fn enqueue_tracks(guild_id: u64, tracks: Vec) { + let mut entry = queues().entry(guild_id).or_default(); + for t in tracks { + entry.push_back(t); + } +} + +/// Remove and return the front track (called when a track finishes). +pub fn pop_front(guild_id: u64) -> Option { + queues() + .get_mut(&guild_id) + .and_then(|mut q: dashmap::mapref::one::RefMut>| q.pop_front()) +} + +/// Clear the entire queue for a guild (called on stop). +pub fn clear_queue(guild_id: u64) { + if let Some(mut q) = queues().get_mut(&guild_id) { + let q: &mut VecDeque = q.value_mut(); + q.clear(); + } +} + +/// Return a snapshot of the current queue for a guild. +/// Index 0 is the currently-playing track, index 1+ are upcoming. +pub fn get_queue(guild_id: u64) -> Vec { + queues() + .get(&guild_id) + .map(|q: dashmap::mapref::one::Ref>| { + q.iter().cloned().collect() + }) + .unwrap_or_default() +} + +/// Returns `true` if the bot is currently paused in the given guild. +/// Encapsulates the songbird dependency so `siren-api` doesn't need it directly. +pub async fn get_is_paused(guild_id: u64) -> bool { + 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.playing == PlayMode::Pause) + .unwrap_or(false); + } + } + false +} diff --git a/crates/siren-bot/src/commands/audio/skip.rs b/crates/siren-bot/src/commands/audio/skip.rs index 39b3cbd..9611c18 100644 --- a/crates/siren-bot/src/commands/audio/skip.rs +++ b/crates/siren-bot/src/commands/audio/skip.rs @@ -1,11 +1,14 @@ use crate::{ chat::{edit_response, process_message}, + commands::audio::queue::pop_front, handler::get_songbird, }; use serenity::{ - all::{CommandInteraction, CreateCommand}, + all::{CommandInteraction, CreateCommand, GuildId}, prelude::*, }; +use songbird::Songbird; +use std::sync::Arc; pub async fn run(ctx: &Context, command: &CommandInteraction) { // Create the initial response @@ -29,17 +32,27 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { }; // Skip the track + match skip_track(manager, guild_id).await { + Ok(_) => { + log::debug!("<{guild_id}> Skipped the track"); + edit_response(ctx, command, "Skipping the track".to_string()).await; + } + Err(err) => edit_response(ctx, command, format!("Failed to skip: {}", err)).await, + } +} + +pub async fn skip_track(manager: &Arc, guild_id: &GuildId) -> Result<(), String> { if let Some(handler_lock) = manager.get(guild_id.to_owned()) { let handler = handler_lock.lock().await; - match handler.queue().skip() { - Ok(_) => { - log::debug!("<{guild_id}> Skipped the track"); - edit_response(ctx, command, "Skipping the track".to_string()).await; - } - Err(err) => { - edit_response(ctx, command, format!("Failed to skip: {}", err)).await; - } - } + handler + .queue() + .skip() + .map_err(|e| e.to_string())?; + // Pop the current track from our metadata store; the next track (if any) moves to front + pop_front(guild_id.get()); + Ok(()) + } else { + Err("No active audio session in this guild".to_string()) } } diff --git a/crates/siren-bot/src/commands/audio/stop.rs b/crates/siren-bot/src/commands/audio/stop.rs index 54c2630..c11ec6b 100644 --- a/crates/siren-bot/src/commands/audio/stop.rs +++ b/crates/siren-bot/src/commands/audio/stop.rs @@ -1,11 +1,14 @@ use crate::{ chat::{edit_response, process_message}, + commands::audio::queue::clear_queue, handler::get_songbird, }; use serenity::{ - all::{CommandInteraction, CreateCommand}, + all::{CommandInteraction, CreateCommand, GuildId}, prelude::*, }; +use songbird::Songbird; +use std::sync::Arc; pub async fn run(ctx: &Context, command: &CommandInteraction) { // Create the initial response @@ -29,11 +32,23 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) { }; // Stop the track and clear the queue - if let Some(handler_lock) = manager.get(guild_id) { + match stop_track(manager, &guild_id).await { + Ok(_) => { + log::debug!("<{guild_id}> Stopped the track"); + edit_response(ctx, command, "Stopping the tracks".to_string()).await; + } + Err(err) => edit_response(ctx, command, format!("Failed to stop: {}", err)).await, + } +} + +pub async fn stop_track(manager: &Arc, guild_id: &GuildId) -> Result<(), String> { + if let Some(handler_lock) = manager.get(guild_id.to_owned()) { let handler = handler_lock.lock().await; handler.queue().stop(); - log::debug!("<{guild_id}> Stopped the track"); - edit_response(ctx, command, "Stopping the tracks".to_string()).await; + clear_queue(guild_id.get()); + Ok(()) + } else { + Err("No active audio session in this guild".to_string()) } } diff --git a/crates/siren-bot/src/handler.rs b/crates/siren-bot/src/handler.rs index b34c702..f72d116 100644 --- a/crates/siren-bot/src/handler.rs +++ b/crates/siren-bot/src/handler.rs @@ -1,7 +1,7 @@ use super::{chat::create_modal_response, commands}; use crate::{ HttpKey, - commands::fun::roll::{format_roll, roll_dice, send_roll_message}, + commands::{audio::queue::init_track_queues, fun::roll::{format_roll, roll_dice, send_roll_message}}, }; use serenity::{ all::{ @@ -64,6 +64,9 @@ impl EventHandler for BotHandler { log::warn!("No ready guilds found"); } + // Initialise the track-queue metadata store (idempotent) + init_track_queues(); + if SONGBIRD.get().is_none() { let songbird = songbird::get(&ctx).await.unwrap(); SONGBIRD diff --git a/Dockerfile b/docker/Dockerfile similarity index 76% rename from Dockerfile rename to docker/Dockerfile index 6760f8e..0f1380a 100644 --- a/Dockerfile +++ b/docker/Dockerfile @@ -3,13 +3,14 @@ # ========= # Builder # ========= -FROM rust:1.94-slim-bookworm AS builder +FROM rust:1.87-slim-bookworm AS builder +WORKDIR /app COPY . . RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ - --mount=type=cache,target=/target,sharing=locked \ + --mount=type=cache,target=/app/target,sharing=locked \ cargo build --release --bin siren && \ - cp /target/release/siren /siren + cp /app/target/release/siren /siren # ========== # Packages @@ -41,8 +42,16 @@ WORKDIR /siren USER root COPY --from=builder /siren /usr/local/bin/siren -COPY --from=packages /packages /usr/bin +COPY --from=packages /packages/yt-dlp /usr/bin/yt-dlp +COPY --from=packages /packages/ffmpeg /usr/bin/ffmpeg -RUN apt-get update && apt-get install -y libc6 libc6-dev libopus-dev libpq5 libpq-dev python3-pip ffmpeg +RUN apt-get update && apt-get install -y --no-install-recommends \ + libc6 \ + libc6-dev \ + libopus-dev \ + libpq5 \ + libpq-dev \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* CMD ["siren"] diff --git a/docker/Dockerfile.ui b/docker/Dockerfile.ui new file mode 100644 index 0000000..998a3b7 --- /dev/null +++ b/docker/Dockerfile.ui @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +# ========= +# Builder +# ========= +FROM node:lts-alpine AS builder +WORKDIR /app + +# Install dependencies first (better layer caching) +COPY ui/package.json ui/package-lock.json ./ +RUN npm ci + +# Copy the rest of the UI source and build +COPY ui/ ./ +RUN npm run build + +# ========= +# Runtime +# ========= +FROM nginx:alpine AS runtime + +# Replace the default nginx config with our custom one +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +# Copy the built assets from the builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker/docker-compose.yml similarity index 69% rename from docker-compose.yml rename to docker/docker-compose.yml index a8d923b..08ab28a 100644 --- a/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,5 +1,5 @@ x-env_file: &env - - path: .env + - path: ../.env required: true x-restart: &default_restart @@ -8,6 +8,9 @@ x-restart: &default_restart name: siren services: app: + build: + context: .. + dockerfile: docker/Dockerfile image: siren:${SIREN_VERSION:-latest} container_name: siren-app env_file: *env @@ -18,13 +21,27 @@ services: VALKEY_PORT: 6379 DATA_DIR_PATH: /data volumes: - - ${DATA_DIR_PATH:-./data}:/data + - ${DATA_DIR_PATH:-../data}:/data depends_on: - postgres profiles: - app <<: *default_restart + ui: + build: + context: .. + dockerfile: docker/Dockerfile.ui + image: siren-ui:${SIREN_VERSION:-latest} + container_name: siren-ui + ports: + - ${UI_PORT:-80}:80 + depends_on: + - app + profiles: + - ui + <<: *default_restart + postgres: image: postgres:18.0 container_name: siren-postgres @@ -35,8 +52,8 @@ services: POSTGRES_DB: ${POSTGRES_DB} PGDATA: /var/lib/postgresql/data volumes: - - postgres:/var/lib/postgresql/data - - postgres_logs:/var/log + - postgres:/var/lib/postgresql/data + - postgres_logs:/var/log ports: - ${DATABASE_PORT:-5432}:5432 <<: *default_restart diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..b41123a --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Proxy API requests to the backend + location /api/ { + proxy_pass http://siren-app:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Serve the React SPA — fall back to index.html for client-side routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } +} diff --git a/migrations/000_initial.sql b/migrations/000_initial.sql index 9cc152c..5ca3d4c 100644 --- a/migrations/000_initial.sql +++ b/migrations/000_initial.sql @@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT UNIQUE, first_name TEXT, last_name TEXT, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'banned')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/ui/package.json b/ui/package.json index 144d20b..5d47b1c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,7 +12,8 @@ "dependencies": { "react": "^19.2.4", "react-dom": "^19.2.4", - "react-icons": "^5.6.0" + "react-icons": "^5.6.0", + "react-router-dom": "^7.14.0" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e81e1c8..17fd22e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,371 +1,50 @@ -import { useState, useEffect, useRef } from "react"; -import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types"; -import type { GridHandle } from "./components/Grid"; -import { api, auth } from "./api"; +import { useState } from "react"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { useAuth } from "./context/AuthContext"; import Header from "./components/Header"; -import ControlPanel from "./components/ControlPanel"; -import ColorPanel from "./components/ColorPanel"; -import Grid from "./components/Grid"; -import LoginModal from "./components/LoginModal"; -import AccountPanel from "./components/AccountPanel"; -import FloatingMapControls from "./components/FloatingMapControls"; -import NewMapModal from "./components/NewMapModal"; -import EditMapModal from "./components/EditMapModal"; -import MapListModal from "./components/MapListModal"; -import "./components/Modal.css"; +import MapPage from "./pages/MapPage"; +import AccountPage from "./pages/AccountPage"; +import AdminPage from "./pages/AdminPage"; import "./App.css"; -const DEFAULT_COLORS = [ - "#6b7280", - "#92400e", - "#15803d", - "#1d4ed8", - "#7c3aed", - "#dc2626", - "#ca8a04", - "#0f172a", - "#f9fafb", -]; - -function getMapIdFromUrl(): string | null { - const match = window.location.pathname.match(/^\/map\/([^/]+)/); - return match ? decodeURIComponent(match[1]) : null; -} - -function getQueryParam(key: string): string | null { - return new URLSearchParams(window.location.search).get(key); -} - -function removeQueryParam(key: string) { - const url = new URL(window.location.href); - url.searchParams.delete(key); - window.history.replaceState( - null, - "", - url.pathname + (url.search !== "?" ? url.search : ""), - ); -} - export default function App() { - // ── Auth state ── - const [user, setUser] = useState(null); - const [authLoading, setAuthLoading] = useState(true); + const { user, authLoading } = useAuth(); + const [mapTitle, setMapTitle] = useState(null); - // ── Map state ── - const [maps, setMaps] = useState([]); - const [selectedId, setSelectedId] = useState(getMapIdFromUrl); - /** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */ - const [directMapInfo, setDirectMapInfo] = useState(null); - /** True when the current selectedId returned 403 (no access). */ - const [accessDenied, setAccessDenied] = useState(false); - const [accessRequestSent, setAccessRequestSent] = useState(false); - - // ── Tool + color ── - const [tool, setTool] = useState("pan"); - const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]); - const [mapColors, setMapColors] = useState(DEFAULT_COLORS); - const gridRef = useRef(null); - - // ── Modal visibility ── - const [showLoginModal, setShowLoginModal] = useState(false); - const [showAccountPanel, setShowAccountPanel] = useState(false); - const [showNewMap, setShowNewMap] = useState(false); - const [showEditMap, setShowEditMap] = useState(false); - const [showMapList, setShowMapList] = useState(false); - - // ── Derived ── - const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null; - const selectedMapInfo: GridMap | ListedMap | null = - selectedMapFromList ?? directMapInfo; - const isOwner = - user !== null && - selectedMapInfo !== null && - selectedMapInfo.owner_id === user.id; - - // ── On mount: load session + handle OAuth errors ── - useEffect(() => { - auth.me().then((u) => { - setUser(u); - setAuthLoading(false); - }); - const error = getQueryParam("error"); - if (error) { - console.error("OAuth error:", error); - removeQueryParam("error"); - } - }, []); - - // ── Load map list after auth resolves ── - useEffect(() => { - if (!authLoading) { - api.listMaps().then(setMaps).catch(console.error); - } - }, [user, authLoading]); - - // ── Direct fetch for URL-accessed maps not in the user's list ── - useEffect(() => { - if (!selectedId || authLoading) { - setDirectMapInfo(null); - setAccessDenied(false); - return; - } - const inList = maps.some((m) => m.id === selectedId); - if (inList) { - setDirectMapInfo(null); - setAccessDenied(false); - return; - } - - setDirectMapInfo(null); - setAccessDenied(false); - setAccessRequestSent(false); - - api - .getMap(selectedId) - .then((state) => { - setDirectMapInfo(state.map); - }) - .catch((err) => { - const msg = err instanceof Error ? err.message : String(err); - if (msg.startsWith("403")) { - setAccessDenied(true); - } else { - // 404 or unknown — clear invalid URL - setSelectedId(null); - window.history.replaceState(null, "", "/map"); - } - }); - }, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps - - // ── Keep URL in sync ── - useEffect(() => { - const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map"; - window.history.replaceState(null, "", path); - }, [selectedId]); - - // ── Reset palette + access state when map deselected ── - useEffect(() => { - if (!selectedId) { - setMapColors(DEFAULT_COLORS); - setActiveColor(DEFAULT_COLORS[0]); - setAccessRequestSent(false); - } - }, [selectedId]); - - // ── Handlers ── - - async function handleCreate(name: string, publicAccess: PublicAccess) { - const m = await api.createMap(name, publicAccess); - // Optimistically add to list as an owner entry - const listed: ListedMap = { - ...m, - owner_username: user!.username, - user_role: "owner", - is_favorited: false, - }; - setMaps((prev) => [listed, ...prev]); - setSelectedId(m.id); - } - - async function handleDelete() { - if (!selectedId) return; - if (!confirm("Delete this map? This cannot be undone.")) return; - try { - await api.deleteMap(selectedId); - setMaps((prev) => prev.filter((m) => m.id !== selectedId)); - setSelectedId(null); - } catch (err) { - console.error("Failed to delete map", err); - } - } - - function handleMapUpdated(updated: GridMap) { - setMaps((prev) => - prev.map((m) => - m.id === updated.id - ? { - ...m, - name: updated.name, - public_access: updated.public_access, - updated_at: updated.updated_at, - } - : m, - ), - ); - if (directMapInfo?.id === updated.id) { - setDirectMapInfo(updated); - } - } - - function handleColorsLoaded(colors: string[]) { - setMapColors(colors); - setActiveColor((prev) => (colors.includes(prev) ? prev : colors[0])); - } - - function handleColorsChange(colors: string[]) { - setMapColors(colors); - gridRef.current?.sendColorUpdate(colors); - } - - async function handleUserRefresh() { - const u = await auth.me(); - setUser(u); - } - - async function handleRequestAccess(role: "viewer" | "editor") { - if (!selectedId) return; - try { - await api.requestAccess(selectedId, role); - setAccessRequestSent(true); - } catch (err) { - console.error("Failed to request access", err); - } - } - - // ── Render ── return (
-
setShowLoginModal(true)} - onAccountClick={() => setShowAccountPanel(true)} - /> - +
-
- {/* Top-left floating map controls */} - setShowNewMap(true)} - onViewMaps={() => setShowMapList(true)} - onEditMap={() => setShowEditMap(true)} - onDeleteMap={handleDelete} + + } /> + } /> + } /> - - {selectedId && !accessDenied ? ( - <> - -
- - -
- - ) : accessDenied ? ( -
-

- You don't have access to this map -

- {!user ? ( -

- {" "} - to request access or view your permissions. -

- ) : accessRequestSent ? ( -

- ✓ Access request sent! The map owner will be notified. -

+ ) : ( -
-

- Request access from the map owner: -

-
- - -
-
- )} -
- ) : ( -
-

Select or create a map to begin

-

- {!user - ? "Log in to create maps and access private maps" - : maps.length === 0 - ? 'Click "+ New Map" in the top-left to get started' - : 'Click "Maps" in the top-left to choose a map'} -

-
- )} -
+ + ) + } + /> + + ) : ( + + ) + } + /> + } /> +
- - {/* ── Global modals (always available regardless of page) ── */} - {showLoginModal && ( - setShowLoginModal(false)} - onLogin={async (u) => { - setUser(u); - api.listMaps().then(setMaps).catch(console.error); - }} - /> - )} - - {showAccountPanel && user && ( - setShowAccountPanel(false)} - onRefresh={handleUserRefresh} - /> - )} - - {showNewMap && ( - setShowNewMap(false)} - onCreate={handleCreate} - /> - )} - - {showEditMap && selectedMapInfo && ( - setShowEditMap(false)} - onUpdated={handleMapUpdated} - /> - )} - - {showMapList && ( - setSelectedId(id)} - onClose={() => setShowMapList(false)} - onMapsChange={setMaps} - /> - )}
); } diff --git a/ui/src/api.ts b/ui/src/api.ts index 227c6a1..83d9c37 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1,4 +1,7 @@ import type { + AdminUser, + AudioStatus, + DiscordGuild, GridMap, ListedMap, MapAccessRequest, @@ -230,3 +233,67 @@ export const auth = { }); }, }; + +const ADMIN_BASE = "/api/admin"; + +export const adminApi = { + /** List all user accounts (admin only). */ + listUsers: (): Promise => + request(`${ADMIN_BASE}/users`), + + /** Change a user's site role (admin only). */ + setUserRole: (id: string, role: "admin" | "user"): Promise => + request(`${ADMIN_BASE}/users/${id}/role`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + }), + + /** Ban a user account (admin only). */ + banUser: (id: string): Promise => + request(`${ADMIN_BASE}/users/${id}/ban`, { method: "PUT" }), + + /** Unban a user account (admin only). */ + unbanUser: (id: string): Promise => + request(`${ADMIN_BASE}/users/${id}/unban`, { method: "PUT" }), + + /** Permanently delete a user account (admin only). */ + deleteUser: (id: string): Promise => + request(`${ADMIN_BASE}/users/${id}`, { method: "DELETE" }), +}; + +const AUDIO_BASE = "/api/audio"; + +export const audioApi = { + /** List all Discord guilds the bot is currently in. */ + listGuilds: (): Promise => + request(`${AUDIO_BASE}/guilds`), + + /** Get the current audio status for a guild (voice channel, track, queue). */ + getStatus: (guildId: string): Promise => + request(`${AUDIO_BASE}/${guildId}/status`), + + /** Enqueue a track URL for playback (bot joins the caller's voice channel). */ + play: (guildId: string, url: string): Promise => + request(`${AUDIO_BASE}/${guildId}/play`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }), + + /** Pause the currently playing track. */ + pause: (guildId: string): Promise => + request(`${AUDIO_BASE}/${guildId}/pause`, { method: "POST" }), + + /** Resume a paused track. */ + resume: (guildId: string): Promise => + request(`${AUDIO_BASE}/${guildId}/resume`, { method: "POST" }), + + /** Stop playback and clear the queue. */ + stop: (guildId: string): Promise => + request(`${AUDIO_BASE}/${guildId}/stop`, { method: "POST" }), + + /** Skip the current track. */ + skip: (guildId: string): Promise => + request(`${AUDIO_BASE}/${guildId}/skip`, { method: "POST" }), +}; diff --git a/ui/src/components/AccountPanel.css b/ui/src/components/AccountPanel.css index 95c9a55..bc0dc63 100644 --- a/ui/src/components/AccountPanel.css +++ b/ui/src/components/AccountPanel.css @@ -290,6 +290,24 @@ cursor: not-allowed; } +/* ── Page mode ── */ +.account-panel-page { + width: 480px; + max-width: 100%; + max-height: none; + border-radius: 10px; + border: 1px solid #2e3348; +} + +.account-back-btn { + font-size: 0.82rem; + color: #818cf8; +} + +.account-back-btn:hover { + color: #a5b4fc; +} + /* ── Footer ── */ .account-footer { border-top: 1px solid #2e3348; diff --git a/ui/src/components/AccountPanel.tsx b/ui/src/components/AccountPanel.tsx index 6c229af..f574a3b 100644 --- a/ui/src/components/AccountPanel.tsx +++ b/ui/src/components/AccountPanel.tsx @@ -8,9 +8,17 @@ interface Props { user: UserInfo; onClose: () => void; onRefresh: () => void; + /** "modal" (default) renders with a full-screen backdrop overlay. + * "page" renders inline for use as a standalone page. */ + mode?: "modal" | "page"; } -export default function AccountPanel({ user, onClose, onRefresh }: Props) { +export default function AccountPanel({ + user, + onClose, + onRefresh, + mode = "modal", +}: Props) { const discordConnection = user.connections.find( (c) => c.provider === "discord", ); @@ -114,15 +122,14 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) { } } - async function handleLogout() { - await auth.logout(); - } - - return ( -
-
e.stopPropagation()}> -
-

Account

+ const panelContent = ( +
e.stopPropagation()} + > +
+

Account

+ {mode != "page" && ( -
+ )} +
- {/* ── Profile ── */} -
-

Profile

-
+ {/* ── Profile ── */} +
+

Profile

+ +
+ Username + {user.username} +
+
+ + +
+ + {user.email && (
- Username - {user.username} -
-
- - + Email + {user.email}
+ )} - {user.email && ( -
- Email - {user.email} -
- )} + {profileError &&

{profileError}

} + {profileSuccess &&

Profile saved!

} - {profileError &&

{profileError}

} - {profileSuccess && ( -

Profile saved!

- )} - - {profileDirty && ( -
- - -
- )} - -
- - {/* ── Password ── */} -
-
-

{user.has_password ? "Password" : "Set Password"}

- {!showPasswordSection && ( + {profileDirty && ( +
+ - )} -
- - {pwSuccess && !showPasswordSection && ( -

Password updated successfully!

- )} - - {showPasswordSection && ( -
- {user.has_password && ( - - )} - - - - {pwError &&

{pwError}

} - -
- - -
-
- )} -
- - {/* ── Connected services ── */} -
-

Connected Accounts

- -
- - -
- Discord - {discordConnection ? ( - - {discordConnection.provider_username ?? "Connected"} - - ) : ( - Not connected - )}
+ )} + +
+ {/* ── Password ── */} +
+
+

{user.has_password ? "Password" : "Set Password"}

+ {!showPasswordSection && ( + + )} +
+ + {pwSuccess && !showPasswordSection && ( +

Password updated successfully!

+ )} + + {showPasswordSection && ( +
+ {user.has_password && ( + + )} + + + + {pwError &&

{pwError}

} + +
+ + +
+
+ )} +
+ + {/* ── Connected services ── */} +
+

Connected Accounts

+ +
+ + +
+ Discord {discordConnection ? ( - + + {discordConnection.provider_username ?? "Connected"} + ) : ( - + Not connected )}
- {discordConnection && !user.has_password && ( -

- Set a password above before disconnecting Discord to avoid being - locked out. -

+ {discordConnection ? ( + + ) : ( + )} -
+
- {/* ── Footer ── */} + {discordConnection && !user.has_password && ( +

+ Set a password above before disconnecting Discord to avoid being + locked out. +

+ )} + + + {/* ── Footer (modal-only logout) ── */} + {mode === "modal" && (
-
-
+ )} +
+ ); + + if (mode === "page") { + return panelContent; + } + + return ( +
+ {panelContent}
); } diff --git a/ui/src/components/AdminPanel.css b/ui/src/components/AdminPanel.css new file mode 100644 index 0000000..4791d5b --- /dev/null +++ b/ui/src/components/AdminPanel.css @@ -0,0 +1,273 @@ +/* ── Backdrop ── */ +.admin-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +/* ── Panel ── */ +.admin-panel { + background: #1e1e2e; + border: 1px solid #3b3b52; + border-radius: 0.75rem; + width: min(95vw, 900px); + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +/* ── Header ── */ +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid #3b3b52; + flex-shrink: 0; +} + +.admin-header h2 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #e2e8f0; +} + +.admin-close { + background: none; + border: none; + color: #94a3b8; + font-size: 1.1rem; + cursor: pointer; + padding: 0.25rem 0.4rem; + border-radius: 4px; + line-height: 1; + transition: + color 0.15s, + background 0.15s; +} + +.admin-close:hover { + color: #e2e8f0; + background: #2d2d44; +} + +/* ── Body ── */ +.admin-body { + overflow-y: auto; + padding: 0.75rem 1rem 1rem; + flex: 1 1 auto; +} + +.admin-loading, +.admin-error { + text-align: center; + padding: 2rem; + color: #94a3b8; + font-size: 0.9rem; +} + +.admin-error { + color: #f87171; +} + +/* ── Table ── */ +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + color: #cbd5e1; +} + +.admin-table th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #3b3b52; + color: #94a3b8; + font-weight: 500; + white-space: nowrap; +} + +.admin-table td { + padding: 0.55rem 0.75rem; + border-bottom: 1px solid #2d2d44; + vertical-align: middle; +} + +/* Row variants */ +.admin-row-self td { + background: rgba(99, 102, 241, 0.06); +} + +.admin-row-banned td { + opacity: 0.6; +} + +.admin-self-badge { + font-size: 0.75rem; + color: #818cf8; + margin-left: 0.25rem; +} + +.admin-none { + color: #475569; +} + +.admin-email { + color: #94a3b8; + font-size: 0.8rem; +} + +.admin-date { + color: #64748b; + font-size: 0.8rem; + white-space: nowrap; +} + +/* ── Role badge ── */ +.admin-role-badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-role-badge.role-admin { + background: rgba(139, 92, 246, 0.2); + color: #a78bfa; + border: 1px solid rgba(139, 92, 246, 0.35); +} + +.admin-role-badge.role-user { + background: rgba(71, 85, 105, 0.3); + color: #94a3b8; + border: 1px solid rgba(71, 85, 105, 0.4); +} + +/* ── Status badge ── */ +.admin-status-badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-status-badge.status-active { + background: rgba(34, 197, 94, 0.15); + color: #4ade80; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.admin-status-badge.status-banned { + background: rgba(239, 68, 68, 0.15); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +/* ── Actions ── */ +.admin-actions { + display: flex; + gap: 0.4rem; + align-items: center; + white-space: nowrap; +} + +.admin-btn { + padding: 0.3rem 0.65rem; + border-radius: 5px; + border: 1px solid transparent; + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + transition: + background 0.15s, + opacity 0.15s; + line-height: 1.2; +} + +.admin-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* Role button */ +.admin-btn-role { + background: rgba(99, 102, 241, 0.15); + color: #818cf8; + border-color: rgba(99, 102, 241, 0.3); +} + +.admin-btn-role:not(:disabled):hover { + background: rgba(99, 102, 241, 0.28); +} + +.admin-btn-role.is-admin { + background: rgba(99, 102, 241, 0.25); + color: #a5b4fc; +} + +/* Ban button */ +.admin-btn-ban { + background: rgba(234, 179, 8, 0.12); + color: #fbbf24; + border-color: rgba(234, 179, 8, 0.25); +} + +.admin-btn-ban:not(:disabled):hover { + background: rgba(234, 179, 8, 0.22); +} + +.admin-btn-ban.is-banned { + background: rgba(34, 197, 94, 0.12); + color: #4ade80; + border-color: rgba(34, 197, 94, 0.25); +} + +.admin-btn-ban.is-banned:not(:disabled):hover { + background: rgba(34, 197, 94, 0.22); +} + +/* Delete button */ +.admin-btn-delete { + background: rgba(239, 68, 68, 0.12); + color: #f87171; + border-color: rgba(239, 68, 68, 0.25); + display: flex; + align-items: center; + justify-content: center; + padding: 0.3rem 0.55rem; +} + +.admin-btn-delete:not(:disabled):hover { + background: rgba(239, 68, 68, 0.25); +} + +/* ── Page mode ── */ +.admin-panel-page { + width: 100%; + max-height: none; + box-shadow: none; + border-radius: 0.75rem; +} + +.admin-back-btn { + font-size: 0.82rem; + color: #818cf8; +} + +.admin-back-btn:hover { + color: #a5b4fc; +} diff --git a/ui/src/components/AdminPanel.tsx b/ui/src/components/AdminPanel.tsx new file mode 100644 index 0000000..add0a60 --- /dev/null +++ b/ui/src/components/AdminPanel.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect, useCallback } from "react"; +import { adminApi } from "../api"; +import type { AdminUser } from "../types"; +import type { UserInfo } from "../types"; +import "./AdminPanel.css"; +import { FaTrash } from "react-icons/fa6"; + +interface Props { + currentUser: UserInfo; + onClose: () => void; + /** "modal" (default) renders with a full-screen backdrop overlay. + * "page" renders inline for use as a standalone page. */ + mode?: "modal" | "page"; +} + +export default function AdminPanel({ + currentUser, + onClose, + mode = "modal", +}: Props) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); // user id being acted on + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = await adminApi.listUsers(); + setUsers(list); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to load users"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + async function handleBanToggle(user: AdminUser) { + setBusy(user.id); + try { + if (user.status === "banned") { + await adminApi.unbanUser(user.id); + } else { + await adminApi.banUser(user.id); + } + setUsers((prev) => + prev.map((u) => + u.id === user.id + ? { ...u, status: u.status === "banned" ? "active" : "banned" } + : u, + ), + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + alert(msg.replace(/^\d+:\s*/, "").trim() || "Action failed"); + } finally { + setBusy(null); + } + } + + async function handleRoleToggle(user: AdminUser) { + const newRole = user.role === "admin" ? "user" : "admin"; + setBusy(user.id); + try { + await adminApi.setUserRole(user.id, newRole); + setUsers((prev) => + prev.map((u) => (u.id === user.id ? { ...u, role: newRole } : u)), + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + alert(msg.replace(/^\d+:\s*/, "").trim() || "Action failed"); + } finally { + setBusy(null); + } + } + + async function handleDelete(user: AdminUser) { + if ( + !window.confirm( + `Permanently delete user "${user.username}"? This cannot be undone.`, + ) + ) + return; + setBusy(user.id); + try { + await adminApi.deleteUser(user.id); + setUsers((prev) => prev.filter((u) => u.id !== user.id)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + alert(msg.replace(/^\d+:\s*/, "").trim() || "Failed to delete user"); + } finally { + setBusy(null); + } + } + + function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } + + const panelContent = ( +
e.stopPropagation()} + > + {/* Header */} +
+

Site Admin — Users

+ {mode != "page" && ( + + )} +
+ + {/* Body */} +
+ {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : ( + + + + + + + + + + + + + {users.map((user) => { + const isSelf = user.id === currentUser.id; + const isBusy = busy === user.id; + + return ( + + + + + + + + + ); + })} + +
UsernameEmailRoleStatusJoinedActions
+ {user.username} + {isSelf && ( + (you) + )} + + {user.email ?? } + + + {user.role} + + + + {user.status} + + + {formatDate(user.created_at)} + + {/* Role toggle */} + + + {/* Ban / Unban toggle */} + + + {/* Delete */} + +
+ )} +
+
+ ); + + if (mode === "page") { + return panelContent; + } + + return ( +
+ {panelContent} +
+ ); +} diff --git a/ui/src/components/DiscordPanel.css b/ui/src/components/DiscordPanel.css new file mode 100644 index 0000000..866facc --- /dev/null +++ b/ui/src/components/DiscordPanel.css @@ -0,0 +1,297 @@ +/* ── Panel card ── */ +.discord-panel { + background: #1e2130; + border: 1px solid #2e3348; + border-radius: 10px; + padding: 1.5rem; + width: 380px; + max-width: 100%; + max-height: none; + display: flex; + flex-direction: column; + gap: 0; + align-self: flex-start; +} + +/* ── Header ── */ +.discord-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.discord-header h2 { + margin: 0; + font-size: 1.1rem; + color: #e2e8f0; +} + +.discord-header-icon { + color: #5865f2; + font-size: 1.25rem; + flex-shrink: 0; +} + +/* ── Section ── */ +.discord-section { + border-top: 1px solid #2e3348; + padding-top: 1rem; + margin-top: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.discord-section h3 { + margin: 0; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #8892a4; + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* ── Shared text variants ── */ +.discord-muted { + margin: 0; + font-size: 0.85rem; + color: #8892a4; +} + +.discord-error { + margin: 0; + font-size: 0.8rem; + color: #f87171; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 5px; + padding: 0.35rem 0.6rem; +} + +/* ── Guild info ── */ +.discord-guild-name { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: #e2e8f0; +} + +.discord-select { + background: #141622; + border: 1px solid #2e3348; + border-radius: 6px; + color: #e2e8f0; + font-size: 0.85rem; + padding: 0.35rem 0.6rem; + outline: none; + cursor: pointer; + transition: border-color 0.12s; + width: 100%; +} + +.discord-select:focus { + border-color: #5865f2; +} + +/* ── Voice channel ── */ +.discord-channel-name { + margin: 0; + font-size: 0.9rem; + color: #e2e8f0; + display: flex; + align-items: center; + gap: 0.15rem; +} + +.discord-channel-hash { + color: #5865f2; + font-weight: 700; + font-size: 1rem; +} + +/* ── Now playing ── */ +.discord-now-playing { + background: #141622; + border: 1px solid #2e3348; + border-radius: 8px; + padding: 0.65rem 0.9rem; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.discord-track-title { + margin: 0; + font-size: 0.88rem; + font-weight: 600; + color: #e2e8f0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.discord-track-status { + margin: 0; + font-size: 0.75rem; + color: #8892a4; +} + +/* ── Playback controls ── */ +.discord-controls { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.discord-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + cursor: pointer; + font-size: 0.9rem; + transition: + background 0.12s, + opacity 0.12s; +} + +.discord-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.discord-btn-play, +.discord-btn-pause { + background: #5865f2; + color: #fff; +} + +.discord-btn-play:hover:not(:disabled), +.discord-btn-pause:hover:not(:disabled) { + background: #4752c4; +} + +.discord-btn-skip { + background: #2e3348; + color: #c4cde4; +} + +.discord-btn-skip:hover:not(:disabled) { + background: #3c4460; + color: #e2e8f0; +} + +.discord-btn-stop { + background: transparent; + border: 1px solid #4a5568; + color: #8892a4; +} + +.discord-btn-stop:hover:not(:disabled) { + border-color: #ef4444; + color: #f87171; +} + +/* ── Queue ── */ +.discord-queue-count { + font-size: 0.7rem; + color: #6b7280; + letter-spacing: 0; + text-transform: none; + font-weight: 400; +} + +.discord-queue-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 200px; + overflow-y: auto; +} + +.discord-queue-item { + display: flex; + align-items: center; + gap: 0.6rem; + background: #141622; + border: 1px solid #2e3348; + border-radius: 6px; + padding: 0.4rem 0.7rem; + min-width: 0; +} + +.discord-queue-index { + font-size: 0.72rem; + color: #8892a4; + min-width: 16px; + text-align: right; + flex-shrink: 0; +} + +.discord-queue-title { + font-size: 0.82rem; + color: #c4cde4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* ── Add to queue form ── */ +.discord-play-form { + display: flex; + gap: 0.5rem; +} + +.discord-url-input { + flex: 1; + background: #141622; + border: 1px solid #2e3348; + border-radius: 6px; + color: #e2e8f0; + font-size: 0.82rem; + padding: 0.35rem 0.6rem; + outline: none; + transition: border-color 0.12s; + min-width: 0; +} + +.discord-url-input::placeholder { + color: #4a5568; +} + +.discord-url-input:focus { + border-color: #5865f2; +} + +.discord-btn-add { + background: #5865f2; + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + padding: 0.35rem 0.9rem; + white-space: nowrap; + transition: background 0.12s; + flex-shrink: 0; +} + +.discord-btn-add:hover:not(:disabled) { + background: #4752c4; +} + +.discord-btn-add:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/ui/src/components/DiscordPanel.tsx b/ui/src/components/DiscordPanel.tsx new file mode 100644 index 0000000..824d49e --- /dev/null +++ b/ui/src/components/DiscordPanel.tsx @@ -0,0 +1,295 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + FaDiscord, + FaPause, + FaPlay, + FaStop, + FaForwardStep, +} from "react-icons/fa6"; +import { audioApi } from "../api"; +import type { AudioStatus, DiscordGuild, UserInfo } from "../types"; +import "./DiscordPanel.css"; + +interface Props { + user: UserInfo; +} + +const POLL_INTERVAL_MS = 5000; + +export default function DiscordPanel({ user }: Props) { + const discordConnection = user.connections.find( + (c) => c.provider === "discord", + ); + + // ── Guild selection ── + const [guilds, setGuilds] = useState([]); + const [selectedGuildId, setSelectedGuildId] = useState(null); + const [guildsLoading, setGuildsLoading] = useState(true); + const [guildsError, setGuildsError] = useState(null); + + // ── Audio status ── + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + const pollRef = useRef | null>(null); + + // ── Play input ── + const [playUrl, setPlayUrl] = useState(""); + const [playLoading, setPlayLoading] = useState(false); + const [playError, setPlayError] = useState(null); + + // ── Action feedback ── + const [actionError, setActionError] = useState(null); + + // ── Load guilds on mount ── + useEffect(() => { + if (!discordConnection) return; + setGuildsLoading(true); + audioApi + .listGuilds() + .then((list) => { + setGuilds(list); + if (list.length === 1) setSelectedGuildId(list[0].id); + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + setGuildsError(msg.replace(/^\d+:\s*/, "").trim()); + }) + .finally(() => setGuildsLoading(false)); + }, [discordConnection]); + + // ── Poll status whenever a guild is selected ── + const fetchStatus = useCallback(async (guildId: string) => { + try { + const s = await audioApi.getStatus(guildId); + setStatus(s); + setStatusError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setStatusError(msg.replace(/^\d+:\s*/, "").trim()); + } + }, []); + + useEffect(() => { + if (pollRef.current) clearInterval(pollRef.current); + if (!selectedGuildId) { + setStatus(null); + return; + } + fetchStatus(selectedGuildId); + pollRef.current = setInterval( + () => fetchStatus(selectedGuildId), + POLL_INTERVAL_MS, + ); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [selectedGuildId, fetchStatus]); + + // ── Helpers ── + function errMsg(err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return msg.replace(/^\d+:\s*/, "").trim(); + } + + async function runAction(fn: () => Promise) { + setActionError(null); + try { + await fn(); + // Immediate status refresh after any action + if (selectedGuildId) await fetchStatus(selectedGuildId); + } catch (err) { + setActionError(errMsg(err)); + } + } + + async function handlePlay(e: React.FormEvent) { + e.preventDefault(); + if (!selectedGuildId || !playUrl.trim()) return; + setPlayLoading(true); + setPlayError(null); + try { + await audioApi.play(selectedGuildId, playUrl.trim()); + setPlayUrl(""); + if (selectedGuildId) await fetchStatus(selectedGuildId); + } catch (err) { + setPlayError(errMsg(err)); + } finally { + setPlayLoading(false); + } + } + + // ── Don't render if no Discord connection ── + if (!discordConnection) return null; + + const selectedGuild = guilds.find((g) => g.id === selectedGuildId); + const isPlaying = !!status?.current_track && !status?.is_paused; + + return ( +
+ {/* Header */} +
+ +

Discord

+
+ + {/* Guild selector */} +
+

Server

+ {guildsLoading ? ( +

Loading servers…

+ ) : guildsError ? ( +

{guildsError}

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

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

+ ) : guilds.length === 1 ? ( +

{guilds[0].name}

+ ) : ( + + )} +
+ + {/* Voice channel */} + {selectedGuild && ( +
+

Voice Channel

+ {statusError ? ( +

{statusError}

+ ) : status?.voice_channel ? ( +

+ # + {status.voice_channel} +

+ ) : ( +

Not connected to a voice channel

+ )} +
+ )} + + {/* Now Playing */} + {selectedGuild && ( +
+

Now Playing

+ {status?.current_track ? ( +
+

+ {status.current_track.title} +

+

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

+
+ ) : ( +

Nothing is playing

+ )} + + {/* Controls */} +
+ {status?.is_paused ? ( + + ) : ( + + )} + + +
+ + {actionError &&

{actionError}

} +
+ )} + + {/* Queue */} + {selectedGuild && ( +
+

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

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

Queue is empty

+ ) : ( +
    + {status.queue.map((track, i) => ( +
  • + {i + 1} + {track.title} +
  • + ))} +
+ )} +
+ )} + + {/* Add to queue */} + {selectedGuild && ( +
+

Add to Queue

+
+ { + setPlayUrl(e.target.value); + setPlayError(null); + }} + /> + +
+ {playError &&

{playError}

} +
+ )} +
+ ); +} diff --git a/ui/src/components/EditMapModal.tsx b/ui/src/components/EditMapModal.tsx index 3894e16..6c42f05 100644 --- a/ui/src/components/EditMapModal.tsx +++ b/ui/src/components/EditMapModal.tsx @@ -130,10 +130,14 @@ export default function EditMapModal({ map, onClose, onUpdated }: Props) { } } + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + const nonOwnerPerms = permissions.filter((p) => p.role !== "owner"); return ( -
+
e.stopPropagation()} diff --git a/ui/src/components/Header.css b/ui/src/components/Header.css index 3ed4dda..9ec92b7 100644 --- a/ui/src/components/Header.css +++ b/ui/src/components/Header.css @@ -46,3 +46,52 @@ gap: 0.5rem; justify-content: flex-end; } + +/* ── Account dropdown ── */ +.account-dropdown-wrapper { + position: relative; +} + +.account-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: #1f2937; + border: 1px solid #374151; + border-radius: 8px; + min-width: 180px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); + z-index: 200; + overflow: hidden; +} + +.account-dropdown-item { + display: block; + width: 100%; + padding: 0.6rem 1rem; + background: none; + border: none; + color: #e5e7eb; + font-size: 0.85rem; + text-align: left; + cursor: pointer; + transition: background 0.12s; +} + +.account-dropdown-item:hover { + background: #374151; +} + +.account-dropdown-divider { + height: 1px; + background: #374151; + margin: 0.2rem 0; +} + +.account-dropdown-logout { + color: #f87171; +} + +.account-dropdown-logout:hover { + background: rgba(239, 68, 68, 0.1) !important; +} diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 0bf51ce..7510f11 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -1,51 +1,127 @@ -import type { UserInfo } from "../types"; +import { useState, useRef, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import { auth } from "../api"; import LoginButton from "./LoginButton"; +import LoginModal from "./LoginModal"; import "./Header.css"; interface Props { - user: UserInfo | null; - authLoading: boolean; - selectedMapName: string | null; - onLoginClick: () => void; - onAccountClick: () => void; + mapTitle: string | null; } -export default function Header({ - user, - authLoading, - selectedMapName, - onLoginClick, - onAccountClick, -}: Props) { +export default function Header({ mapTitle }: Props) { + const { user, authLoading, setUser } = useAuth(); + const navigate = useNavigate(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [showLoginModal, setShowLoginModal] = useState(false); + const dropdownRef = useRef(null); + /** Display name: first name if set, otherwise username */ const displayName = user ? user.first_name?.trim() || user.username : null; + // Close dropdown on outside click + useEffect(() => { + if (!dropdownOpen) return; + function handleOutsideClick(e: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setDropdownOpen(false); + } + } + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, [dropdownOpen]); + + async function handleLogout() { + setDropdownOpen(false); + await auth.logout(); + setUser(null); + navigate("/map"); + } + return (
-
+
navigate("/map")} + style={{ cursor: "pointer" }} + > SIREN
- {selectedMapName && ( - {selectedMapName} - )} + {mapTitle && {mapTitle}}
{!authLoading && (user ? ( - +
+ + + {dropdownOpen && ( +
+ + + {user.role === "admin" && ( + + )} + +
+ + +
+ )} +
) : ( - + setShowLoginModal(true)} + /> ))}
+ + {showLoginModal && ( + setShowLoginModal(false)} + onLogin={(u) => { + setUser(u); + setShowLoginModal(false); + }} + /> + )}
); } diff --git a/ui/src/components/MapListModal.tsx b/ui/src/components/MapListModal.tsx index 8060d73..1967fd6 100644 --- a/ui/src/components/MapListModal.tsx +++ b/ui/src/components/MapListModal.tsx @@ -91,8 +91,12 @@ export default function MapListModal({ onClose(); } + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + return ( -
+
e.stopPropagation()} diff --git a/ui/src/components/Modal.css b/ui/src/components/Modal.css index 593bebc..b378bf2 100644 --- a/ui/src/components/Modal.css +++ b/ui/src/components/Modal.css @@ -106,3 +106,14 @@ .header-btn:hover { background: #4b5563; } + +/* Admin variant — subtle purple accent */ +.header-btn-admin { + border-color: rgba(139, 92, 246, 0.45); + color: #c4b5fd; +} +.header-btn-admin:hover { + background: #4b5563; + border-color: #7c3aed; + color: #ddd6fe; +} diff --git a/ui/src/components/NewMapModal.tsx b/ui/src/components/NewMapModal.tsx index c52dd0e..2667117 100644 --- a/ui/src/components/NewMapModal.tsx +++ b/ui/src/components/NewMapModal.tsx @@ -35,8 +35,12 @@ export default function NewMapModal({ onClose, onCreate }: Props) { } } + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + return ( -
+
e.stopPropagation()}>

New Map

@@ -51,7 +55,7 @@ export default function NewMapModal({ onClose, onCreate }: Props) { setName(e.target.value)} maxLength={60} diff --git a/ui/src/context/AuthContext.tsx b/ui/src/context/AuthContext.tsx new file mode 100644 index 0000000..5d3cc1a --- /dev/null +++ b/ui/src/context/AuthContext.tsx @@ -0,0 +1,47 @@ +import { + createContext, + useContext, + useState, + useEffect, + type ReactNode, +} from "react"; +import { auth } from "../api"; +import type { UserInfo } from "../types"; + +interface AuthContextValue { + user: UserInfo | null; + authLoading: boolean; + setUser: (user: UserInfo | null) => void; + refreshUser: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + + async function refreshUser() { + const u = await auth.me(); + setUser(u); + } + + useEffect(() => { + auth.me().then((u) => { + setUser(u); + setAuthLoading(false); + }); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index f25366e..b733bc0 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,10 +1,16 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; import App from "./App.tsx"; +import { AuthProvider } from "./context/AuthContext.tsx"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + + + , ); diff --git a/ui/src/pages/AccountPage.tsx b/ui/src/pages/AccountPage.tsx new file mode 100644 index 0000000..2ab1383 --- /dev/null +++ b/ui/src/pages/AccountPage.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import AccountPanel from "../components/AccountPanel"; +import DiscordPanel from "../components/DiscordPanel"; +import "./Pages.css"; + +export default function AccountPage() { + const { user, refreshUser } = useAuth(); + const navigate = useNavigate(); + + if (!user) return null; + + const hasDiscord = user.connections.some((c) => c.provider === "discord"); + + return ( +
+ navigate("/map")} + onRefresh={refreshUser} + mode="page" + /> + {hasDiscord && } +
+ ); +} diff --git a/ui/src/pages/AdminPage.tsx b/ui/src/pages/AdminPage.tsx new file mode 100644 index 0000000..91a5379 --- /dev/null +++ b/ui/src/pages/AdminPage.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import AdminPanel from "../components/AdminPanel"; +import "./Pages.css"; + +export default function AdminPage() { + const { user } = useAuth(); + const navigate = useNavigate(); + + if (!user || user.role !== "admin") return null; + + return ( +
+ navigate("/map")} + mode="page" + /> +
+ ); +} diff --git a/ui/src/pages/MapPage.tsx b/ui/src/pages/MapPage.tsx new file mode 100644 index 0000000..dee133a --- /dev/null +++ b/ui/src/pages/MapPage.tsx @@ -0,0 +1,351 @@ +import { useState, useEffect, useRef } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import type { GridMap, ListedMap, PublicAccess, Tool } from "../types"; +import type { GridHandle } from "../components/Grid"; +import { api } from "../api"; +import { useAuth } from "../context/AuthContext"; +import ControlPanel from "../components/ControlPanel"; +import ColorPanel from "../components/ColorPanel"; +import Grid from "../components/Grid"; +import LoginModal from "../components/LoginModal"; +import FloatingMapControls from "../components/FloatingMapControls"; +import NewMapModal from "../components/NewMapModal"; +import EditMapModal from "../components/EditMapModal"; +import MapListModal from "../components/MapListModal"; +import "../components/Modal.css"; + +const DEFAULT_COLORS = [ + "#6b7280", + "#92400e", + "#15803d", + "#1d4ed8", + "#7c3aed", + "#dc2626", + "#ca8a04", + "#0f172a", + "#f9fafb", +]; + +function getQueryParam(key: string): string | null { + return new URLSearchParams(window.location.search).get(key); +} + +function removeQueryParam(key: string) { + const url = new URL(window.location.href); + url.searchParams.delete(key); + window.history.replaceState( + null, + "", + url.pathname + (url.search !== "?" ? url.search : ""), + ); +} + +interface Props { + setMapTitle: (title: string | null) => void; +} + +export default function MapPage({ setMapTitle }: Props) { + const { mapId: urlMapId } = useParams(); + const navigate = useNavigate(); + const { user, authLoading, setUser } = useAuth(); + + // ── Map state ── + const [maps, setMaps] = useState([]); + const [selectedId, setSelectedId] = useState( + urlMapId ? decodeURIComponent(urlMapId) : null, + ); + /** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */ + const [directMapInfo, setDirectMapInfo] = useState(null); + /** True when the current selectedId returned 403 (no access). */ + const [accessDenied, setAccessDenied] = useState(false); + const [accessRequestSent, setAccessRequestSent] = useState(false); + + // ── Tool + color ── + const [tool, setTool] = useState("pan"); + const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]); + const [mapColors, setMapColors] = useState(DEFAULT_COLORS); + const gridRef = useRef(null); + + // ── Modal visibility ── + const [showLoginModal, setShowLoginModal] = useState(false); + const [showNewMap, setShowNewMap] = useState(false); + const [showEditMap, setShowEditMap] = useState(false); + const [showMapList, setShowMapList] = useState(false); + + // ── Derived ── + const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null; + const selectedMapInfo: GridMap | ListedMap | null = + selectedMapFromList ?? directMapInfo; + const isOwner = + user !== null && + selectedMapInfo !== null && + selectedMapInfo.owner_id === user.id; + + // ── On mount: handle OAuth errors ── + useEffect(() => { + const error = getQueryParam("error"); + if (error) { + console.error("OAuth error:", error); + removeQueryParam("error"); + } + }, []); + + // ── Load map list after auth resolves ── + useEffect(() => { + if (!authLoading) { + api.listMaps().then(setMaps).catch(console.error); + } + }, [user, authLoading]); + + // ── Direct fetch for URL-accessed maps not in the user's list ── + useEffect(() => { + if (!selectedId || authLoading) { + setDirectMapInfo(null); + setAccessDenied(false); + return; + } + const inList = maps.some((m) => m.id === selectedId); + if (inList) { + setDirectMapInfo(null); + setAccessDenied(false); + return; + } + + setDirectMapInfo(null); + setAccessDenied(false); + setAccessRequestSent(false); + + api + .getMap(selectedId) + .then((state) => { + setDirectMapInfo(state.map); + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + if (msg.startsWith("403")) { + setAccessDenied(true); + } else { + // 404 or unknown — clear invalid URL + setSelectedId(null); + navigate("/map", { replace: true }); + } + }); + }, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Keep URL in sync ── + useEffect(() => { + const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map"; + navigate(path, { replace: true }); + }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Sync map title to header ── + useEffect(() => { + setMapTitle(selectedMapInfo?.name ?? null); + }, [selectedMapInfo?.name]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Clear map title on unmount ── + useEffect(() => { + return () => setMapTitle(null); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Reset palette + access state when map deselected ── + useEffect(() => { + if (!selectedId) { + setMapColors(DEFAULT_COLORS); + setActiveColor(DEFAULT_COLORS[0]); + setAccessRequestSent(false); + } + }, [selectedId]); + + // ── Handlers ── + + async function handleCreate(name: string, publicAccess: PublicAccess) { + const m = await api.createMap(name, publicAccess); + const listed: ListedMap = { + ...m, + owner_username: user!.username, + user_role: "owner", + is_favorited: false, + }; + setMaps((prev) => [listed, ...prev]); + setSelectedId(m.id); + } + + async function handleDelete() { + if (!selectedId) return; + if (!confirm("Delete this map? This cannot be undone.")) return; + try { + await api.deleteMap(selectedId); + setMaps((prev) => prev.filter((m) => m.id !== selectedId)); + setSelectedId(null); + } catch (err) { + console.error("Failed to delete map", err); + } + } + + function handleMapUpdated(updated: GridMap) { + setMaps((prev) => + prev.map((m) => + m.id === updated.id + ? { + ...m, + name: updated.name, + public_access: updated.public_access, + updated_at: updated.updated_at, + } + : m, + ), + ); + if (directMapInfo?.id === updated.id) { + setDirectMapInfo(updated); + } + } + + function handleColorsLoaded(colors: string[]) { + setMapColors(colors); + setActiveColor((prev) => (colors.includes(prev) ? prev : colors[0])); + } + + function handleColorsChange(colors: string[]) { + setMapColors(colors); + gridRef.current?.sendColorUpdate(colors); + } + + async function handleRequestAccess(role: "viewer" | "editor") { + if (!selectedId) return; + try { + await api.requestAccess(selectedId, role); + setAccessRequestSent(true); + } catch (err) { + console.error("Failed to request access", err); + } + } + + // ── Render ── + return ( +
+ {/* Top-left floating map controls */} + setShowNewMap(true)} + onViewMaps={() => setShowMapList(true)} + onEditMap={() => setShowEditMap(true)} + onDeleteMap={handleDelete} + /> + + {selectedId && !accessDenied ? ( + <> + +
+ + +
+ + ) : accessDenied ? ( +
+

+ You don't have access to this map +

+ {!user ? ( +

+ {" "} + to request access or view your permissions. +

+ ) : accessRequestSent ? ( +

+ ✓ Access request sent! The map owner will be notified. +

+ ) : ( +
+

+ Request access from the map owner: +

+
+ + +
+
+ )} +
+ ) : ( +
+

Select or create a map to begin

+

+ {!user + ? "Log in to create maps and access private maps" + : maps.length === 0 + ? 'Click "+ New Map" in the top-left to get started' + : 'Click "Maps" in the top-left to choose a map'} +

+
+ )} + + {/* ── Modals ── */} + {showLoginModal && ( + setShowLoginModal(false)} + onLogin={(u) => { + setUser(u); + setShowLoginModal(false); + api.listMaps().then(setMaps).catch(console.error); + }} + /> + )} + + {showNewMap && ( + setShowNewMap(false)} + onCreate={handleCreate} + /> + )} + + {showEditMap && selectedMapInfo && ( + setShowEditMap(false)} + onUpdated={handleMapUpdated} + /> + )} + + {showMapList && ( + setSelectedId(id)} + onClose={() => setShowMapList(false)} + onMapsChange={setMaps} + /> + )} +
+ ); +} diff --git a/ui/src/pages/Pages.css b/ui/src/pages/Pages.css new file mode 100644 index 0000000..e8ebf42 --- /dev/null +++ b/ui/src/pages/Pages.css @@ -0,0 +1,25 @@ +/* ── Shared page layout ── + page-container is a direct flex child of .app-body, so it must + use flex: 1 + overflow-y: auto to fill the viewport and scroll. */ +.page-container { + flex: 1; + overflow-y: auto; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 2rem 1rem; +} + +.page-container-wide { + align-items: stretch; + padding: 1.5rem 1rem; +} + +/* ── Account page: side-by-side panels ── */ +.account-page-layout { + flex-wrap: wrap; + flex-direction: row; + gap: 1.5rem; + justify-content: center; + align-items: flex-start; +} diff --git a/ui/src/types.ts b/ui/src/types.ts index d2075d0..d28a550 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -13,6 +13,20 @@ export interface UserInfo { /** True when the account has a local password (can log in without OAuth). */ has_password: boolean; connections: ConnectionInfo[]; + /** Site-level role: "admin" | "user" */ + role: "admin" | "user"; + /** Account status: "active" | "banned" */ + status: "active" | "banned"; +} + +/** User record returned by the admin user list endpoint. */ +export interface AdminUser { + id: string; // UUID + username: string; + email: string | null; + role: "admin" | "user"; + status: "active" | "banned"; + created_at: string; // ISO datetime } export type MapRole = "owner" | "editor" | "viewer"; @@ -82,6 +96,25 @@ export interface MapAccessRequest { updated_at: string; } +// ── Discord / Audio ────────────────────────────────────────────────────────── + +export interface DiscordGuild { + id: string; + name: string; +} + +export interface TrackInfo { + title: string; + url: string; +} + +export interface AudioStatus { + voice_channel: string | null; + is_paused: boolean; + current_track: TrackInfo | null; + queue: TrackInfo[]; +} + export type Tool = "pan" | "zoom" | "draw" | "token"; export type ClientMessage =