Updates to pages

This commit is contained in:
2026-04-04 18:31:28 -04:00
parent 070337577c
commit ca95582d92
42 changed files with 2831 additions and 640 deletions

View File

@@ -90,34 +90,40 @@ tasks:
# Docker # Docker
# ----------------------------------------------------------- # -----------------------------------------------------------
docker:build: 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: 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 silent: true
docker:up: docker:up:
desc: "Start backend containers" desc: "Start backend containers (postgres + valkey)"
cmds: cmds:
- docker compose up -d - docker compose -f docker/docker-compose.yml up -d
silent: true silent: true
docker:up:all: docker:up:all:
desc: "Start all containers" desc: "Start all containers (app + ui + postgres + valkey)"
cmds: cmds:
- docker compose --profile app up -d - docker compose -f docker/docker-compose.yml --profile app --profile ui up -d
silent: true silent: true
docker:down: docker:down:
desc: "Stop all containers" desc: "Stop all containers"
cmds: cmds:
- docker compose --profile app down - docker compose -f docker/docker-compose.yml --profile app --profile ui down
silent: true silent: true
docker:clean: docker:clean:
desc: "Stop all containers and remove volumes" desc: "Stop all containers and remove volumes"
prompt: "This will remove all docker containers, networks, volumes, and images. Are you sure?" prompt: "This will remove all docker containers, networks, volumes, and images. Are you sure?"
cmds: cmds:
- docker compose --profile app down -v - docker compose -f docker/docker-compose.yml --profile app --profile ui down -v
silent: true silent: true
docker:refresh: docker:refresh:
@@ -169,13 +175,25 @@ tasks:
# Utilities # Utilities
# ----------------------------------------------------------- # -----------------------------------------------------------
psql: 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: 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 silent: true
ngrok: ngrok:
desc: Start ngrok tunnel desc: "Start ngrok tunnel"
vars: vars:
UI_PORT: '{{.UI_PORT | default "5173"}}' UI_PORT: '{{.UI_PORT | default "5173"}}'
cmds: cmds:

View File

@@ -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<Arc<AppState>> {
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<String>,
pub role: String,
pub status: String,
pub created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct DbAdminUser {
id: Uuid,
username: String,
email: Option<String>,
role: String,
status: String,
created_at: DateTime<Utc>,
}
#[derive(Deserialize)]
struct SetRolePayload {
role: String,
}
/// `GET /admin/users` — list all user accounts (admin only).
async fn list_users(
AdminAuthorization(_session): AdminAuthorization,
) -> Result<Json<Vec<AdminUserRecord>>> {
let pool = data::pool();
let rows: Vec<DbAdminUser> = 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<Uuid>,
Json(payload): Json<SetRolePayload>,
) -> Result<StatusCode> {
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<Uuid>,
) -> Result<StatusCode> {
// 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<Uuid>,
) -> Result<StatusCode> {
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<Uuid>,
) -> Result<StatusCode> {
// 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)
}

View File

@@ -8,28 +8,42 @@ use axum::{
Router, Router,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
routing::post, routing::{get, post},
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use siren_bot::{ use siren_bot::{
commands::audio::{ commands::audio::{
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},
resume::resume_track, resume::resume_track,
skip::skip_track,
stop::stop_track,
}, },
handler::get_songbird, handler::get_songbird,
}; };
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
/// Routes that don't require a guild_id (nested at /api/audio)
pub fn get_routes() -> Router<Arc<AppState>> { pub fn get_routes() -> Router<Arc<AppState>> {
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<Arc<AppState>> {
Router::new() Router::new()
.route("/play", post(play_audio)) .route("/play", post(play_audio))
.route("/pause", post(pause_audio)) .route("/pause", post(pause_audio))
.route("/resume", post(resume_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)] #[derive(Deserialize)]
struct PlayTrackRequest { struct PlayTrackRequest {
url: String, url: String,
@@ -52,6 +66,38 @@ async fn get_discord_snowflake(local_user_id: Uuid) -> Result<u64> {
.ok_or_else(|| Error::not_found("Discord account not connected".to_string())) .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<Arc<AppState>>,
) -> Result<Json<Vec<GuildInfo>>> {
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
let guilds: Vec<GuildInfo> = 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( async fn play_audio(
SessionAuthorization(session): SessionAuthorization, SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -88,6 +134,8 @@ async fn play_audio(
Ok(()) Ok(())
} }
// ── POST /api/audio/{guild_id}/pause ─────────────────────────────────────────
async fn pause_audio( async fn pause_audio(
SessionAuthorization(session): SessionAuthorization, SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -96,18 +144,18 @@ async fn pause_audio(
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
log::debug!("Pausing audio in guild: {}", guild_id); 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) { let guild_id = match state.cache.guild(guild_id) {
Some(guild) => guild.id, Some(guild) => guild.id,
None => return Err(Error::not_found("Guild not found".to_string())), None => return Err(Error::not_found("Guild not found".to_string())),
}; };
// Pause the track
let manager = get_songbird(); let manager = get_songbird();
pause_track(manager, &guild_id).await?; pause_track(manager, &guild_id).await?;
Ok(()) Ok(())
} }
// ── POST /api/audio/{guild_id}/resume ────────────────────────────────────────
async fn resume_audio( async fn resume_audio(
SessionAuthorization(session): SessionAuthorization, SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -116,14 +164,106 @@ async fn resume_audio(
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
log::debug!("Resuming audio in guild: {}", guild_id); 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) { let guild_id = match state.cache.guild(guild_id) {
Some(guild) => guild.id, Some(guild) => guild.id,
None => return Err(Error::not_found("Guild not found".to_string())), None => return Err(Error::not_found("Guild not found".to_string())),
}; };
// Resume the track
let manager = get_songbird(); let manager = get_songbird();
resume_track(manager, &guild_id).await?; resume_track(manager, &guild_id).await?;
Ok(()) Ok(())
} }
// ── POST /api/audio/{guild_id}/stop ──────────────────────────────────────────
async fn stop_audio(
SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>,
Path(guild_id): Path<u64>,
) -> 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<Arc<AppState>>,
Path(guild_id): Path<u64>,
) -> 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<String>,
is_paused: bool,
current_track: Option<TrackInfo>,
queue: Vec<TrackInfo>,
}
async fn audio_status(
SessionAuthorization(session): SessionAuthorization,
State(state): State<Arc<AppState>>,
Path(guild_id): Path<u64>,
) -> Result<Json<AudioStatus>> {
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,
}))
}

View File

@@ -294,8 +294,8 @@ async fn do_oauth_callback(
None => { None => {
// Find existing connection → local user_id // Find existing connection → local user_id
let local_user_id: Option<(Uuid, String)> = sqlx::query_as( let local_user_id: Option<(Uuid, String, String)> = sqlx::query_as(
"SELECT u.id, u.username \ "SELECT u.id, u.username, u.status \
FROM user_connections uc \ FROM user_connections uc \
JOIN users u ON u.id = uc.user_id \ JOIN users u ON u.id = uc.user_id \
WHERE uc.provider = 'discord' AND uc.provider_user_id = $1", 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 { let (user_id, username) = match local_user_id {
// Already linked — use the existing local user // Already linked — use the existing local user
Some(row) => { Some(row) => {
// Reject banned accounts
if row.2 == "banned" {
return err_redirect(StatusCode::FORBIDDEN);
}
// Keep provider fields up to date // Keep provider fields up to date
sqlx::query( sqlx::query(
"UPDATE user_connections \ "UPDATE user_connections \
@@ -327,7 +331,7 @@ async fn do_oauth_callback(
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err() err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
})?; })?;
row (row.0, row.1)
} }
// First login — create a local user + connection // First login — create a local user + connection

View File

@@ -85,6 +85,10 @@ pub struct UserInfo {
/// OAuth and can safely disconnect OAuth providers). /// OAuth and can safely disconnect OAuth providers).
pub has_password: bool, pub has_password: bool,
pub connections: Vec<ConnectionInfo>, pub connections: Vec<ConnectionInfo>,
/// Site-level role: `"admin"` or `"user"`.
pub role: String,
/// Account status: `"active"` or `"banned"`.
pub status: String,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -95,6 +99,8 @@ struct DbUser {
last_name: Option<String>, last_name: Option<String>,
email: Option<String>, email: Option<String>,
password_hash: Option<String>, password_hash: Option<String>,
role: String,
status: String,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -176,7 +182,8 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
let pool = data::pool(); let pool = data::pool();
let user: DbUser = sqlx::query_as( 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) .bind(user_id)
.fetch_one(pool) .fetch_one(pool)
@@ -197,6 +204,8 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
last_name: user.last_name, last_name: user.last_name,
email: user.email, email: user.email,
has_password: user.password_hash.is_some(), has_password: user.password_hash.is_some(),
role: user.role,
status: user.status,
connections: connections connections: connections
.into_iter() .into_iter()
.map(|c| ConnectionInfo { .map(|c| ConnectionInfo {
@@ -260,13 +269,13 @@ async fn login(
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let pool = data::pool(); let pool = data::pool();
let row: Option<(Uuid, String, Option<String>)> = let row: Option<(Uuid, String, Option<String>, String)> =
sqlx::query_as("SELECT id, username, password_hash FROM users WHERE username = $1") sqlx::query_as("SELECT id, username, password_hash, status FROM users WHERE username = $1")
.bind(&payload.username) .bind(&payload.username)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .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()))?; row.ok_or_else(|| Error::new(401, "Invalid username or password".into()))?;
let hash = let hash =
@@ -276,6 +285,10 @@ async fn login(
return Err(Error::new(401, "Invalid username or password".into())); 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 ip = extract_ip(&headers);
let user_agent = headers let user_agent = headers
.get("user-agent") .get("user-agent")

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
auth::{bearer_token::BearerTokenClaims, session::Session}, auth::{bearer_token::BearerTokenClaims, session::Session},
error::Result, error::{Error, Result},
}; };
use axum::{ use axum::{
extract::FromRequestParts, extract::FromRequestParts,
@@ -10,6 +10,7 @@ use axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use jsonwebtoken::{DecodingKey, Validation, decode}; use jsonwebtoken::{DecodingKey, Validation, decode};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use siren_core::data;
pub const COOKIE_NAME: &str = "siren_session"; pub const COOKIE_NAME: &str = "siren_session";
@@ -130,3 +131,46 @@ pub async fn check_cookie_from_header_str(
} }
None 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<S> FromRequestParts<S> for AdminAuthorization
where
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
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<String> = 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)),
}
}
}

View File

@@ -11,7 +11,7 @@ pub use local::UserInfo;
pub use session::Session; pub use session::Session;
pub mod middleware; pub mod middleware;
pub use middleware::SessionAuthorization; pub use middleware::{AdminAuthorization, SessionAuthorization};
pub fn get_routes() -> Router<Arc<AppState>> { pub fn get_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()

View File

@@ -1,3 +1,4 @@
pub mod admin;
pub mod app; pub mod app;
mod app_state; mod app_state;
pub mod audio; pub mod audio;
@@ -13,8 +14,10 @@ use std::sync::Arc;
pub fn get_routes() -> Router<Arc<AppState>> { pub fn get_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.nest("/admin", admin::get_routes())
.nest("/auth", auth::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("/dice", dice::get_routes())
.nest("/grid", grid::get_routes()) .nest("/grid", grid::get_routes())
} }

View File

@@ -20,3 +20,4 @@ chrono = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
lazy_static = { workspace = true } lazy_static = { workspace = true }
dashmap = { workspace = true }

View File

@@ -11,6 +11,7 @@ use std::sync::Arc;
pub mod mute; pub mod mute;
pub mod pause; pub mod pause;
pub mod play; pub mod play;
pub mod queue;
pub mod resume; pub mod resume;
pub mod skip; pub mod skip;
pub mod stop; pub mod stop;

View File

@@ -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::{ use crate::{
chat::{create_message_response, edit_response, process_message}, chat::{create_message_response, edit_response, process_message},
error::{Error, Result}, error::{Error, Result},
@@ -113,6 +118,15 @@ pub async fn enqueue_track(
playlist_items = get_ytdlp_items(track_url)?; playlist_items = get_ytdlp_items(track_url)?;
// Collect TrackInfo for the queue store before borrowing `item` in the loop
let track_infos: Vec<TrackInfo> = 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 // Add each track to the queue
for item in &playlist_items { for item in &playlist_items {
let volume = guild.volume as f32 / 100.0; 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() { if handler.queue().is_empty() {
let _ = handler.queue().resume(); let _ = handler.queue().resume();
} }
@@ -204,6 +222,9 @@ struct TrackEndNotifier {
impl EventHandler for TrackEndNotifier { impl EventHandler for TrackEndNotifier {
async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option<songbird::events::Event> { async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option<songbird::events::Event> {
if let songbird::EventContext::Track(_track_list) = ctx { 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) { if let Some(call) = self.call.get(self.guild_id) {
let mut handler = call.lock().await; let mut handler = call.lock().await;
if handler.queue().is_empty() { if handler.queue().is_empty() {

View File

@@ -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<Arc<DashMap<u64, VecDeque<TrackInfo>>>> = 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<DashMap<u64, VecDeque<TrackInfo>>> {
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<TrackInfo>) {
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<TrackInfo> {
queues()
.get_mut(&guild_id)
.and_then(|mut q: dashmap::mapref::one::RefMut<u64, VecDeque<TrackInfo>>| 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<TrackInfo> = 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<TrackInfo> {
queues()
.get(&guild_id)
.map(|q: dashmap::mapref::one::Ref<u64, VecDeque<TrackInfo>>| {
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
}

View File

@@ -1,11 +1,14 @@
use crate::{ use crate::{
chat::{edit_response, process_message}, chat::{edit_response, process_message},
commands::audio::queue::pop_front,
handler::get_songbird, handler::get_songbird,
}; };
use serenity::{ use serenity::{
all::{CommandInteraction, CreateCommand}, all::{CommandInteraction, CreateCommand, GuildId},
prelude::*, prelude::*,
}; };
use songbird::Songbird;
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response // Create the initial response
@@ -29,17 +32,27 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
}; };
// Skip the track // 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<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;
match handler.queue().skip() { handler
Ok(_) => { .queue()
log::debug!("<{guild_id}> Skipped the track"); .skip()
edit_response(ctx, command, "Skipping the track".to_string()).await; .map_err(|e| e.to_string())?;
} // Pop the current track from our metadata store; the next track (if any) moves to front
Err(err) => { pop_front(guild_id.get());
edit_response(ctx, command, format!("Failed to skip: {}", err)).await; Ok(())
} } else {
} Err("No active audio session in this guild".to_string())
} }
} }

View File

@@ -1,11 +1,14 @@
use crate::{ use crate::{
chat::{edit_response, process_message}, chat::{edit_response, process_message},
commands::audio::queue::clear_queue,
handler::get_songbird, handler::get_songbird,
}; };
use serenity::{ use serenity::{
all::{CommandInteraction, CreateCommand}, all::{CommandInteraction, CreateCommand, GuildId},
prelude::*, prelude::*,
}; };
use songbird::Songbird;
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) { pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response // Create the initial response
@@ -29,11 +32,23 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
}; };
// Stop the track and clear the queue // 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<Songbird>, guild_id: &GuildId) -> Result<(), String> {
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await; let handler = handler_lock.lock().await;
handler.queue().stop(); handler.queue().stop();
log::debug!("<{guild_id}> Stopped the track"); clear_queue(guild_id.get());
edit_response(ctx, command, "Stopping the tracks".to_string()).await; Ok(())
} else {
Err("No active audio session in this guild".to_string())
} }
} }

View File

@@ -1,7 +1,7 @@
use super::{chat::create_modal_response, commands}; use super::{chat::create_modal_response, commands};
use crate::{ use crate::{
HttpKey, 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::{ use serenity::{
all::{ all::{
@@ -64,6 +64,9 @@ impl EventHandler for BotHandler {
log::warn!("No ready guilds found"); log::warn!("No ready guilds found");
} }
// Initialise the track-queue metadata store (idempotent)
init_track_queues();
if SONGBIRD.get().is_none() { if SONGBIRD.get().is_none() {
let songbird = songbird::get(&ctx).await.unwrap(); let songbird = songbird::get(&ctx).await.unwrap();
SONGBIRD SONGBIRD

View File

@@ -3,13 +3,14 @@
# ========= # =========
# Builder # Builder
# ========= # =========
FROM rust:1.94-slim-bookworm AS builder FROM rust:1.87-slim-bookworm AS builder
WORKDIR /app
COPY . . COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ 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=/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 && \ cargo build --release --bin siren && \
cp /target/release/siren /siren cp /app/target/release/siren /siren
# ========== # ==========
# Packages # Packages
@@ -41,8 +42,16 @@ WORKDIR /siren
USER root USER root
COPY --from=builder /siren /usr/local/bin/siren 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"] CMD ["siren"]

30
docker/Dockerfile.ui Normal file
View File

@@ -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;"]

View File

@@ -1,5 +1,5 @@
x-env_file: &env x-env_file: &env
- path: .env - path: ../.env
required: true required: true
x-restart: &default_restart x-restart: &default_restart
@@ -8,6 +8,9 @@ x-restart: &default_restart
name: siren name: siren
services: services:
app: app:
build:
context: ..
dockerfile: docker/Dockerfile
image: siren:${SIREN_VERSION:-latest} image: siren:${SIREN_VERSION:-latest}
container_name: siren-app container_name: siren-app
env_file: *env env_file: *env
@@ -18,13 +21,27 @@ services:
VALKEY_PORT: 6379 VALKEY_PORT: 6379
DATA_DIR_PATH: /data DATA_DIR_PATH: /data
volumes: volumes:
- ${DATA_DIR_PATH:-./data}:/data - ${DATA_DIR_PATH:-../data}:/data
depends_on: depends_on:
- postgres - postgres
profiles: profiles:
- app - app
<<: *default_restart <<: *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: postgres:
image: postgres:18.0 image: postgres:18.0
container_name: siren-postgres container_name: siren-postgres
@@ -35,8 +52,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
PGDATA: /var/lib/postgresql/data PGDATA: /var/lib/postgresql/data
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
- postgres_logs:/var/log - postgres_logs:/var/log
ports: ports:
- ${DATABASE_PORT:-5432}:5432 - ${DATABASE_PORT:-5432}:5432
<<: *default_restart <<: *default_restart

32
docker/nginx.conf Normal file
View File

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

View File

@@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT UNIQUE, email TEXT UNIQUE,
first_name TEXT, first_name TEXT,
last_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(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );

View File

@@ -12,7 +12,8 @@
"dependencies": { "dependencies": {
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^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": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",

View File

@@ -1,371 +1,50 @@
import { useState, useEffect, useRef } from "react"; import { useState } from "react";
import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types"; import { Routes, Route, Navigate } from "react-router-dom";
import type { GridHandle } from "./components/Grid"; import { useAuth } from "./context/AuthContext";
import { api, auth } from "./api";
import Header from "./components/Header"; import Header from "./components/Header";
import ControlPanel from "./components/ControlPanel"; import MapPage from "./pages/MapPage";
import ColorPanel from "./components/ColorPanel"; import AccountPage from "./pages/AccountPage";
import Grid from "./components/Grid"; import AdminPage from "./pages/AdminPage";
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 "./App.css"; 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() { export default function App() {
// ── Auth state ── const { user, authLoading } = useAuth();
const [user, setUser] = useState<UserInfo | null>(null); const [mapTitle, setMapTitle] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(true);
// ── Map state ──
const [maps, setMaps] = useState<ListedMap[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
/** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */
const [directMapInfo, setDirectMapInfo] = useState<GridMap | null>(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<Tool>("pan");
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
const gridRef = useRef<GridHandle>(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 ( return (
<div className="app"> <div className="app">
<Header <Header mapTitle={mapTitle} />
user={user}
authLoading={authLoading}
selectedMapName={selectedMapInfo?.name ?? null}
onLoginClick={() => setShowLoginModal(true)}
onAccountClick={() => setShowAccountPanel(true)}
/>
<div className="app-body"> <div className="app-body">
<div className="app-grid-area"> <Routes>
{/* Top-left floating map controls */} <Route index element={<Navigate to="/map" replace />} />
<FloatingMapControls <Route path="/map" element={<MapPage setMapTitle={setMapTitle} />} />
isLoggedIn={!!user} <Route
hasSelectedMap={!!selectedId} path="/map/:mapId"
isOwner={isOwner} element={<MapPage setMapTitle={setMapTitle} />}
onNewMap={() => setShowNewMap(true)}
onViewMaps={() => setShowMapList(true)}
onEditMap={() => setShowEditMap(true)}
onDeleteMap={handleDelete}
/> />
<Route
{selectedId && !accessDenied ? ( path="/account"
<> element={
<Grid authLoading ? null : user ? (
key={selectedId} <AccountPage />
ref={gridRef}
mapId={selectedId}
tool={tool}
paintColor={activeColor}
tokenColor={activeColor}
onColorsLoaded={handleColorsLoaded}
/>
<div className="floating-panels-container">
<ControlPanel tool={tool} onToolChange={setTool} />
<ColorPanel
colors={mapColors}
activeColor={activeColor}
onColorChange={setActiveColor}
onColorsChange={handleColorsChange}
/>
</div>
</>
) : accessDenied ? (
<div className="access-denied-state">
<p className="access-denied-title">
You don't have access to this map
</p>
{!user ? (
<p className="access-denied-hint">
<button
className="link-btn"
onClick={() => setShowLoginModal(true)}
>
Log in
</button>{" "}
to request access or view your permissions.
</p>
) : accessRequestSent ? (
<p className="access-denied-hint access-request-sent">
✓ Access request sent! The map owner will be notified.
</p>
) : ( ) : (
<div className="access-request-actions"> <Navigate to="/map" replace />
<p className="access-denied-hint"> )
Request access from the map owner: }
</p> />
<div className="access-request-btns"> <Route
<button path="/admin"
className="btn-request-access" element={
onClick={() => handleRequestAccess("viewer")} authLoading ? null : user?.role === "admin" ? (
> <AdminPage />
Request Viewer Access ) : (
</button> <Navigate to="/map" replace />
<button )
className="btn-request-access" }
onClick={() => handleRequestAccess("editor")} />
> <Route path="*" element={<Navigate to="/map" replace />} />
Request Editor Access </Routes>
</button>
</div>
</div>
)}
</div>
) : (
<div className="empty-state">
<p>Select or create a map to begin</p>
<p className="empty-hint">
{!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'}
</p>
</div>
)}
</div>
</div> </div>
{/* ── Global modals (always available regardless of page) ── */}
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onLogin={async (u) => {
setUser(u);
api.listMaps().then(setMaps).catch(console.error);
}}
/>
)}
{showAccountPanel && user && (
<AccountPanel
user={user}
onClose={() => setShowAccountPanel(false)}
onRefresh={handleUserRefresh}
/>
)}
{showNewMap && (
<NewMapModal
onClose={() => setShowNewMap(false)}
onCreate={handleCreate}
/>
)}
{showEditMap && selectedMapInfo && (
<EditMapModal
map={selectedMapInfo}
onClose={() => setShowEditMap(false)}
onUpdated={handleMapUpdated}
/>
)}
{showMapList && (
<MapListModal
maps={maps}
selectedMapId={selectedId}
onSelect={(id) => setSelectedId(id)}
onClose={() => setShowMapList(false)}
onMapsChange={setMaps}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,4 +1,7 @@
import type { import type {
AdminUser,
AudioStatus,
DiscordGuild,
GridMap, GridMap,
ListedMap, ListedMap,
MapAccessRequest, MapAccessRequest,
@@ -230,3 +233,67 @@ export const auth = {
}); });
}, },
}; };
const ADMIN_BASE = "/api/admin";
export const adminApi = {
/** List all user accounts (admin only). */
listUsers: (): Promise<AdminUser[]> =>
request<AdminUser[]>(`${ADMIN_BASE}/users`),
/** Change a user's site role (admin only). */
setUserRole: (id: string, role: "admin" | "user"): Promise<void> =>
request<void>(`${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<void> =>
request<void>(`${ADMIN_BASE}/users/${id}/ban`, { method: "PUT" }),
/** Unban a user account (admin only). */
unbanUser: (id: string): Promise<void> =>
request<void>(`${ADMIN_BASE}/users/${id}/unban`, { method: "PUT" }),
/** Permanently delete a user account (admin only). */
deleteUser: (id: string): Promise<void> =>
request<void>(`${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<DiscordGuild[]> =>
request<DiscordGuild[]>(`${AUDIO_BASE}/guilds`),
/** Get the current audio status for a guild (voice channel, track, queue). */
getStatus: (guildId: string): Promise<AudioStatus> =>
request<AudioStatus>(`${AUDIO_BASE}/${guildId}/status`),
/** Enqueue a track URL for playback (bot joins the caller's voice channel). */
play: (guildId: string, url: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/play`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
}),
/** Pause the currently playing track. */
pause: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/pause`, { method: "POST" }),
/** Resume a paused track. */
resume: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/resume`, { method: "POST" }),
/** Stop playback and clear the queue. */
stop: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/stop`, { method: "POST" }),
/** Skip the current track. */
skip: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/skip`, { method: "POST" }),
};

View File

@@ -290,6 +290,24 @@
cursor: not-allowed; 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 ── */ /* ── Footer ── */
.account-footer { .account-footer {
border-top: 1px solid #2e3348; border-top: 1px solid #2e3348;

View File

@@ -8,9 +8,17 @@ interface Props {
user: UserInfo; user: UserInfo;
onClose: () => void; onClose: () => void;
onRefresh: () => 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( const discordConnection = user.connections.find(
(c) => c.provider === "discord", (c) => c.provider === "discord",
); );
@@ -114,15 +122,14 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
} }
} }
async function handleLogout() { const panelContent = (
await auth.logout(); <div
} className={`account-panel${mode === "page" ? " account-panel-page" : ""}`}
onClick={(e) => e.stopPropagation()}
return ( >
<div className="account-backdrop" onClick={onClose}> <div className="account-header">
<div className="account-panel" onClick={(e) => e.stopPropagation()}> <h2>Account</h2>
<div className="account-header"> {mode != "page" && (
<h2>Account</h2>
<button <button
className="account-close" className="account-close"
onClick={onClose} onClick={onClose}
@@ -130,220 +137,230 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
> >
</button> </button>
</div> )}
</div>
{/* ── Profile ── */} {/* ── Profile ── */}
<section className="account-section"> <section className="account-section">
<h3>Profile</h3> <h3>Profile</h3>
<form onSubmit={handleSaveProfile} className="profile-form"> <form onSubmit={handleSaveProfile} className="profile-form">
<div className="account-field readonly-field">
<span className="account-label">Username</span>
<span className="account-value">{user.username}</span>
</div>
<div className="profile-name-row">
<label className="account-field-label">
First Name
<input
type="text"
value={firstName}
onChange={(e) => handleFirstNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
<label className="account-field-label">
Last Name
<input
type="text"
value={lastName}
onChange={(e) => handleLastNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
</div>
{user.email && (
<div className="account-field readonly-field"> <div className="account-field readonly-field">
<span className="account-label">Username</span> <span className="account-label">Email</span>
<span className="account-value">{user.username}</span> <span className="account-value">{user.email}</span>
</div>
<div className="profile-name-row">
<label className="account-field-label">
First Name
<input
type="text"
value={firstName}
onChange={(e) => handleFirstNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
<label className="account-field-label">
Last Name
<input
type="text"
value={lastName}
onChange={(e) => handleLastNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
</div> </div>
)}
{user.email && ( {profileError && <p className="account-error">{profileError}</p>}
<div className="account-field readonly-field"> {profileSuccess && <p className="account-success">Profile saved!</p>}
<span className="account-label">Email</span>
<span className="account-value">{user.email}</span>
</div>
)}
{profileError && <p className="account-error">{profileError}</p>} {profileDirty && (
{profileSuccess && ( <div className="profile-actions">
<p className="account-success">Profile saved!</p>
)}
{profileDirty && (
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setFirstName(user.first_name ?? "");
setLastName(user.last_name ?? "");
setProfileDirty(false);
setProfileError(null);
}}
>
Cancel
</button>
<button
type="submit"
className="btn-save"
disabled={profileSaving}
>
{profileSaving ? "Saving…" : "Save"}
</button>
</div>
)}
</form>
</section>
{/* ── Password ── */}
<section className="account-section">
<div className="section-header-row">
<h3>{user.has_password ? "Password" : "Set Password"}</h3>
{!showPasswordSection && (
<button <button
type="button"
className="btn-text" className="btn-text"
onClick={() => { onClick={() => {
setShowPasswordSection(true); setFirstName(user.first_name ?? "");
setPwError(null); setLastName(user.last_name ?? "");
setPwSuccess(false); setProfileDirty(false);
setProfileError(null);
}} }}
> >
{user.has_password ? "Change" : "Set Password"} Cancel
</button>
<button
type="submit"
className="btn-save"
disabled={profileSaving}
>
{profileSaving ? "Saving…" : "Save"}
</button> </button>
)}
</div>
{pwSuccess && !showPasswordSection && (
<p className="account-success">Password updated successfully!</p>
)}
{showPasswordSection && (
<form onSubmit={handleChangePassword} className="password-form">
{user.has_password && (
<label className="account-field-label">
Current Password
<input
type="password"
value={pwCurrent}
onChange={(e) => setPwCurrent(e.target.value)}
autoComplete="current-password"
required
/>
</label>
)}
<label className="account-field-label">
New Password
<input
type="password"
value={pwNew}
onChange={(e) => setPwNew(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label className="account-field-label">
Confirm New Password
<input
type="password"
value={pwConfirm}
onChange={(e) => setPwConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
{pwError && <p className="account-error">{pwError}</p>}
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setShowPasswordSection(false);
setPwCurrent("");
setPwNew("");
setPwConfirm("");
setPwError(null);
}}
>
Cancel
</button>
<button type="submit" className="btn-save" disabled={pwSaving}>
{pwSaving
? "Saving…"
: user.has_password
? "Update Password"
: "Set Password"}
</button>
</div>
</form>
)}
</section>
{/* ── Connected services ── */}
<section className="account-section">
<h3>Connected Accounts</h3>
<div className="account-connection">
<FaDiscord />
<div className="connection-info">
<span className="connection-name">Discord</span>
{discordConnection ? (
<span className="connection-linked">
{discordConnection.provider_username ?? "Connected"}
</span>
) : (
<span className="connection-unlinked">Not connected</span>
)}
</div> </div>
)}
</form>
</section>
{/* ── Password ── */}
<section className="account-section">
<div className="section-header-row">
<h3>{user.has_password ? "Password" : "Set Password"}</h3>
{!showPasswordSection && (
<button
className="btn-text"
onClick={() => {
setShowPasswordSection(true);
setPwError(null);
setPwSuccess(false);
}}
>
{user.has_password ? "Change" : "Set Password"}
</button>
)}
</div>
{pwSuccess && !showPasswordSection && (
<p className="account-success">Password updated successfully!</p>
)}
{showPasswordSection && (
<form onSubmit={handleChangePassword} className="password-form">
{user.has_password && (
<label className="account-field-label">
Current Password
<input
type="password"
value={pwCurrent}
onChange={(e) => setPwCurrent(e.target.value)}
autoComplete="current-password"
required
/>
</label>
)}
<label className="account-field-label">
New Password
<input
type="password"
value={pwNew}
onChange={(e) => setPwNew(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label className="account-field-label">
Confirm New Password
<input
type="password"
value={pwConfirm}
onChange={(e) => setPwConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
{pwError && <p className="account-error">{pwError}</p>}
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setShowPasswordSection(false);
setPwCurrent("");
setPwNew("");
setPwConfirm("");
setPwError(null);
}}
>
Cancel
</button>
<button type="submit" className="btn-save" disabled={pwSaving}>
{pwSaving
? "Saving…"
: user.has_password
? "Update Password"
: "Set Password"}
</button>
</div>
</form>
)}
</section>
{/* ── Connected services ── */}
<section className="account-section">
<h3>Connected Accounts</h3>
<div className="account-connection">
<FaDiscord />
<div className="connection-info">
<span className="connection-name">Discord</span>
{discordConnection ? ( {discordConnection ? (
<button <span className="connection-linked">
className="btn-disconnect" {discordConnection.provider_username ?? "Connected"}
onClick={handleDisconnectDiscord} </span>
disabled={!user.has_password}
title={
!user.has_password
? "Set a password first before disconnecting Discord"
: "Disconnect Discord"
}
>
Disconnect
</button>
) : ( ) : (
<button <span className="connection-unlinked">Not connected</span>
className="btn-connect-discord"
onClick={handleConnectDiscord}
>
Connect
</button>
)} )}
</div> </div>
{discordConnection && !user.has_password && ( {discordConnection ? (
<p className="connection-hint"> <button
Set a password above before disconnecting Discord to avoid being className="btn-disconnect"
locked out. onClick={handleDisconnectDiscord}
</p> disabled={!user.has_password}
title={
!user.has_password
? "Set a password first before disconnecting Discord"
: "Disconnect Discord"
}
>
Disconnect
</button>
) : (
<button
className="btn-connect-discord"
onClick={handleConnectDiscord}
>
Connect
</button>
)} )}
</section> </div>
{/* ── Footer ── */} {discordConnection && !user.has_password && (
<p className="connection-hint">
Set a password above before disconnecting Discord to avoid being
locked out.
</p>
)}
</section>
{/* ── Footer (modal-only logout) ── */}
{mode === "modal" && (
<div className="account-footer"> <div className="account-footer">
<button className="btn-logout" onClick={handleLogout}> <button className="btn-logout" onClick={() => auth.logout()}>
Log Out Log Out
</button> </button>
</div> </div>
</div> )}
</div>
);
if (mode === "page") {
return panelContent;
}
return (
<div className="account-backdrop" onClick={onClose}>
{panelContent}
</div> </div>
); );
} }

View File

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

View File

@@ -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<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(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 = (
<div
className={`admin-panel${mode === "page" ? " admin-panel-page" : ""}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="admin-header">
<h2>Site Admin Users</h2>
{mode != "page" && (
<button className="admin-close" onClick={onClose} aria-label="Close">
</button>
)}
</div>
{/* Body */}
<div className="admin-body">
{loading ? (
<p className="admin-loading">Loading</p>
) : error ? (
<p className="admin-error">{error}</p>
) : (
<table className="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const isSelf = user.id === currentUser.id;
const isBusy = busy === user.id;
return (
<tr
key={user.id}
className={`admin-row${isSelf ? " admin-row-self" : ""}${user.status === "banned" ? " admin-row-banned" : ""}`}
>
<td className="admin-username">
{user.username}
{isSelf && (
<span className="admin-self-badge"> (you)</span>
)}
</td>
<td className="admin-email">
{user.email ?? <span className="admin-none"></span>}
</td>
<td>
<span className={`admin-role-badge role-${user.role}`}>
{user.role}
</span>
</td>
<td>
<span
className={`admin-status-badge status-${user.status}`}
>
{user.status}
</span>
</td>
<td className="admin-date">
{formatDate(user.created_at)}
</td>
<td className="admin-actions">
{/* Role toggle */}
<button
className={`admin-btn admin-btn-role${user.role === "admin" ? " is-admin" : ""}`}
onClick={() => handleRoleToggle(user)}
disabled={isSelf || isBusy}
title={
isSelf
? "Cannot change your own role"
: user.role === "admin"
? "Demote to user"
: "Promote to admin"
}
>
{user.role === "admin" ? "Demote" : "Make Admin"}
</button>
{/* Ban / Unban toggle */}
<button
className={`admin-btn admin-btn-ban${user.status === "banned" ? " is-banned" : ""}`}
onClick={() => handleBanToggle(user)}
disabled={isSelf || isBusy}
title={
isSelf
? "Cannot ban yourself"
: user.status === "banned"
? "Unban user"
: "Ban user"
}
>
{user.status === "banned" ? "Unban" : "Ban"}
</button>
{/* Delete */}
<button
className="admin-btn admin-btn-delete"
onClick={() => handleDelete(user)}
disabled={isSelf || isBusy}
title={
isSelf
? "Cannot delete yourself"
: "Delete user permanently"
}
>
<FaTrash />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
if (mode === "page") {
return panelContent;
}
return (
<div className="admin-backdrop" onClick={onClose}>
{panelContent}
</div>
);
}

View File

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

View File

@@ -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<DiscordGuild[]>([]);
const [selectedGuildId, setSelectedGuildId] = useState<string | null>(null);
const [guildsLoading, setGuildsLoading] = useState(true);
const [guildsError, setGuildsError] = useState<string | null>(null);
// ── Audio status ──
const [status, setStatus] = useState<AudioStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ── Play input ──
const [playUrl, setPlayUrl] = useState("");
const [playLoading, setPlayLoading] = useState(false);
const [playError, setPlayError] = useState<string | null>(null);
// ── Action feedback ──
const [actionError, setActionError] = useState<string | null>(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<void>) {
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 (
<div className="discord-panel">
{/* Header */}
<div className="discord-header">
<FaDiscord className="discord-header-icon" />
<h2>Discord</h2>
</div>
{/* Guild selector */}
<section className="discord-section">
<h3>Server</h3>
{guildsLoading ? (
<p className="discord-muted">Loading servers</p>
) : guildsError ? (
<p className="discord-error">{guildsError}</p>
) : guilds.length === 0 ? (
<p className="discord-muted">
The bot isn't in any servers yet.
</p>
) : guilds.length === 1 ? (
<p className="discord-guild-name">{guilds[0].name}</p>
) : (
<select
className="discord-select"
value={selectedGuildId ?? ""}
onChange={(e) => setSelectedGuildId(e.target.value || null)}
>
<option value=""> Select a server </option>
{guilds.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
)}
</section>
{/* Voice channel */}
{selectedGuild && (
<section className="discord-section">
<h3>Voice Channel</h3>
{statusError ? (
<p className="discord-error">{statusError}</p>
) : status?.voice_channel ? (
<p className="discord-channel-name">
<span className="discord-channel-hash">#</span>
{status.voice_channel}
</p>
) : (
<p className="discord-muted">Not connected to a voice channel</p>
)}
</section>
)}
{/* Now Playing */}
{selectedGuild && (
<section className="discord-section">
<h3>Now Playing</h3>
{status?.current_track ? (
<div className="discord-now-playing">
<p
className="discord-track-title"
title={status.current_track.title}
>
{status.current_track.title}
</p>
<p className="discord-track-status">
{status.is_paused ? "⏸ Paused" : "▶ Playing"}
</p>
</div>
) : (
<p className="discord-muted">Nothing is playing</p>
)}
{/* Controls */}
<div className="discord-controls">
{status?.is_paused ? (
<button
className="discord-btn discord-btn-play"
title="Resume"
onClick={() =>
runAction(() => audioApi.resume(selectedGuildId!))
}
disabled={!status?.current_track}
>
<FaPlay />
</button>
) : (
<button
className="discord-btn discord-btn-pause"
title="Pause"
onClick={() =>
runAction(() => audioApi.pause(selectedGuildId!))
}
disabled={!status?.current_track}
>
<FaPause />
</button>
)}
<button
className="discord-btn discord-btn-skip"
title="Skip"
onClick={() => runAction(() => audioApi.skip(selectedGuildId!))}
disabled={!status?.current_track}
>
<FaForwardStep />
</button>
<button
className="discord-btn discord-btn-stop"
title="Stop"
onClick={() => runAction(() => audioApi.stop(selectedGuildId!))}
disabled={!status?.current_track}
>
<FaStop />
</button>
</div>
{actionError && <p className="discord-error">{actionError}</p>}
</section>
)}
{/* Queue */}
{selectedGuild && (
<section className="discord-section">
<h3>Queue {status && status.queue.length > 0 && <span className="discord-queue-count">({status.queue.length})</span>}</h3>
{!status || status.queue.length === 0 ? (
<p className="discord-muted">Queue is empty</p>
) : (
<ul className="discord-queue-list">
{status.queue.map((track, i) => (
<li key={i} className="discord-queue-item" title={track.title}>
<span className="discord-queue-index">{i + 1}</span>
<span className="discord-queue-title">{track.title}</span>
</li>
))}
</ul>
)}
</section>
)}
{/* Add to queue */}
{selectedGuild && (
<section className="discord-section">
<h3>Add to Queue</h3>
<form onSubmit={handlePlay} className="discord-play-form">
<input
type="url"
className="discord-url-input"
placeholder="YouTube / SoundCloud URL…"
value={playUrl}
onChange={(e) => {
setPlayUrl(e.target.value);
setPlayError(null);
}}
/>
<button
type="submit"
className="discord-btn-add"
disabled={playLoading || !playUrl.trim()}
>
{playLoading ? "Adding…" : "Add"}
</button>
</form>
{playError && <p className="discord-error">{playError}</p>}
</section>
)}
</div>
);
}

View File

@@ -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"); const nonOwnerPerms = permissions.filter((p) => p.role !== "owner");
return ( return (
<div className="modal-backdrop" onClick={onClose}> <div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
<div <div
className="modal edit-map-modal" className="modal edit-map-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}

View File

@@ -46,3 +46,52 @@
gap: 0.5rem; gap: 0.5rem;
justify-content: flex-end; 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;
}

View File

@@ -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 LoginButton from "./LoginButton";
import LoginModal from "./LoginModal";
import "./Header.css"; import "./Header.css";
interface Props { interface Props {
user: UserInfo | null; mapTitle: string | null;
authLoading: boolean;
selectedMapName: string | null;
onLoginClick: () => void;
onAccountClick: () => void;
} }
export default function Header({ export default function Header({ mapTitle }: Props) {
user, const { user, authLoading, setUser } = useAuth();
authLoading, const navigate = useNavigate();
selectedMapName, const [dropdownOpen, setDropdownOpen] = useState(false);
onLoginClick, const [showLoginModal, setShowLoginModal] = useState(false);
onAccountClick, const dropdownRef = useRef<HTMLDivElement>(null);
}: Props) {
/** Display name: first name if set, otherwise username */ /** Display name: first name if set, otherwise username */
const displayName = user ? user.first_name?.trim() || user.username : null; 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 ( return (
<header className="app-header"> <header className="app-header">
<div className="app-brand"> <div
className="app-brand"
onClick={() => navigate("/map")}
style={{ cursor: "pointer" }}
>
<span>SIREN</span> <span>SIREN</span>
</div> </div>
<div className="app-header-center"> <div className="app-header-center">
{selectedMapName && ( {mapTitle && <span className="header-map-name">{mapTitle}</span>}
<span className="header-map-name">{selectedMapName}</span>
)}
</div> </div>
<div className="app-auth"> <div className="app-auth">
{!authLoading && {!authLoading &&
(user ? ( (user ? (
<button <div className="account-dropdown-wrapper" ref={dropdownRef}>
className="header-btn" <button
onClick={onAccountClick} className="header-btn"
title="Account settings" onClick={() => setDropdownOpen((o) => !o)}
> aria-haspopup="true"
{displayName} aria-expanded={dropdownOpen}
</button> title="Account"
>
{displayName}
</button>
{dropdownOpen && (
<div className="account-dropdown">
<button
className="account-dropdown-item"
onClick={() => {
navigate("/account");
setDropdownOpen(false);
}}
>
Account
</button>
{user.role === "admin" && (
<button
className="account-dropdown-item"
onClick={() => {
navigate("/admin");
setDropdownOpen(false);
}}
>
Admin Dashboard
</button>
)}
<div className="account-dropdown-divider" />
<button
className="account-dropdown-item account-dropdown-logout"
onClick={handleLogout}
>
Log Out
</button>
</div>
)}
</div>
) : ( ) : (
<LoginButton className="header-btn" onClick={onLoginClick} /> <LoginButton
className="header-btn"
onClick={() => setShowLoginModal(true)}
/>
))} ))}
</div> </div>
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onLogin={(u) => {
setUser(u);
setShowLoginModal(false);
}}
/>
)}
</header> </header>
); );
} }

View File

@@ -91,8 +91,12 @@ export default function MapListModal({
onClose(); onClose();
} }
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") onClose();
}
return ( return (
<div className="modal-backdrop" onClick={onClose}> <div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
<div <div
className="modal map-list-modal" className="modal map-list-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}

View File

@@ -106,3 +106,14 @@
.header-btn:hover { .header-btn:hover {
background: #4b5563; 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;
}

View File

@@ -35,8 +35,12 @@ export default function NewMapModal({ onClose, onCreate }: Props) {
} }
} }
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") onClose();
}
return ( return (
<div className="modal-backdrop" onClick={onClose}> <div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
<div className="modal new-map-modal" onClick={(e) => e.stopPropagation()}> <div className="modal new-map-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>New Map</h2> <h2>New Map</h2>
@@ -51,7 +55,7 @@ export default function NewMapModal({ onClose, onCreate }: Props) {
<input <input
ref={nameRef} ref={nameRef}
type="text" type="text"
placeholder="My awesome map…" placeholder="e.g. Ravenloft"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
maxLength={60} maxLength={60}

View File

@@ -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<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(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 (
<AuthContext.Provider value={{ user, authLoading, setUser, refreshUser }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -1,10 +1,16 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx"; import App from "./App.tsx";
import { AuthProvider } from "./context/AuthContext.tsx";
import "./index.css"; import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -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 (
<div className={`page-container ${hasDiscord ? "account-page-layout" : ""}`}>
<AccountPanel
user={user}
onClose={() => navigate("/map")}
onRefresh={refreshUser}
mode="page"
/>
{hasDiscord && <DiscordPanel user={user} />}
</div>
);
}

View File

@@ -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 (
<div className="page-container page-container-wide">
<AdminPanel
currentUser={user}
onClose={() => navigate("/map")}
mode="page"
/>
</div>
);
}

351
ui/src/pages/MapPage.tsx Normal file
View File

@@ -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<ListedMap[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(
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<GridMap | null>(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<Tool>("pan");
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
const gridRef = useRef<GridHandle>(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 (
<div className="app-grid-area">
{/* Top-left floating map controls */}
<FloatingMapControls
isLoggedIn={!!user}
hasSelectedMap={!!selectedId}
isOwner={isOwner}
onNewMap={() => setShowNewMap(true)}
onViewMaps={() => setShowMapList(true)}
onEditMap={() => setShowEditMap(true)}
onDeleteMap={handleDelete}
/>
{selectedId && !accessDenied ? (
<>
<Grid
key={selectedId}
ref={gridRef}
mapId={selectedId}
tool={tool}
paintColor={activeColor}
tokenColor={activeColor}
onColorsLoaded={handleColorsLoaded}
/>
<div className="floating-panels-container">
<ControlPanel tool={tool} onToolChange={setTool} />
<ColorPanel
colors={mapColors}
activeColor={activeColor}
onColorChange={setActiveColor}
onColorsChange={handleColorsChange}
/>
</div>
</>
) : accessDenied ? (
<div className="access-denied-state">
<p className="access-denied-title">
You don't have access to this map
</p>
{!user ? (
<p className="access-denied-hint">
<button
className="link-btn"
onClick={() => setShowLoginModal(true)}
>
Log in
</button>{" "}
to request access or view your permissions.
</p>
) : accessRequestSent ? (
<p className="access-denied-hint access-request-sent">
✓ Access request sent! The map owner will be notified.
</p>
) : (
<div className="access-request-actions">
<p className="access-denied-hint">
Request access from the map owner:
</p>
<div className="access-request-btns">
<button
className="btn-request-access"
onClick={() => handleRequestAccess("viewer")}
>
Request Viewer Access
</button>
<button
className="btn-request-access"
onClick={() => handleRequestAccess("editor")}
>
Request Editor Access
</button>
</div>
</div>
)}
</div>
) : (
<div className="empty-state">
<p>Select or create a map to begin</p>
<p className="empty-hint">
{!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'}
</p>
</div>
)}
{/* ── Modals ── */}
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onLogin={(u) => {
setUser(u);
setShowLoginModal(false);
api.listMaps().then(setMaps).catch(console.error);
}}
/>
)}
{showNewMap && (
<NewMapModal
onClose={() => setShowNewMap(false)}
onCreate={handleCreate}
/>
)}
{showEditMap && selectedMapInfo && (
<EditMapModal
map={selectedMapInfo}
onClose={() => setShowEditMap(false)}
onUpdated={handleMapUpdated}
/>
)}
{showMapList && (
<MapListModal
maps={maps}
selectedMapId={selectedId}
onSelect={(id) => setSelectedId(id)}
onClose={() => setShowMapList(false)}
onMapsChange={setMaps}
/>
)}
</div>
);
}

25
ui/src/pages/Pages.css Normal file
View File

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

View File

@@ -13,6 +13,20 @@ export interface UserInfo {
/** True when the account has a local password (can log in without OAuth). */ /** True when the account has a local password (can log in without OAuth). */
has_password: boolean; has_password: boolean;
connections: ConnectionInfo[]; 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"; export type MapRole = "owner" | "editor" | "viewer";
@@ -82,6 +96,25 @@ export interface MapAccessRequest {
updated_at: string; 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 Tool = "pan" | "zoom" | "draw" | "token";
export type ClientMessage = export type ClientMessage =