Updates to pages
This commit is contained in:
40
Taskfile.yml
40
Taskfile.yml
@@ -90,34 +90,40 @@ tasks:
|
||||
# Docker
|
||||
# -----------------------------------------------------------
|
||||
docker:build:
|
||||
desc: "Build the Docker image (use v=x.x.x to set version, default is \"latest\")"
|
||||
desc: "Build the Rust app Docker image (use v=x.x.x to set version, default is \"latest\")"
|
||||
cmds:
|
||||
- docker build -f Dockerfile -t siren:{{.VERSION}} .
|
||||
- docker build -f docker/Dockerfile -t siren:{{.VERSION}} .
|
||||
silent: true
|
||||
|
||||
docker:build:ui:
|
||||
desc: "Build the UI Docker image (use v=x.x.x to set version, default is \"latest\")"
|
||||
cmds:
|
||||
- docker build -f docker/Dockerfile.ui -t siren-ui:{{.VERSION}} .
|
||||
silent: true
|
||||
|
||||
docker:up:
|
||||
desc: "Start backend containers"
|
||||
desc: "Start backend containers (postgres + valkey)"
|
||||
cmds:
|
||||
- docker compose up -d
|
||||
- docker compose -f docker/docker-compose.yml up -d
|
||||
silent: true
|
||||
|
||||
docker:up:all:
|
||||
desc: "Start all containers"
|
||||
desc: "Start all containers (app + ui + postgres + valkey)"
|
||||
cmds:
|
||||
- docker compose --profile app up -d
|
||||
- docker compose -f docker/docker-compose.yml --profile app --profile ui up -d
|
||||
silent: true
|
||||
|
||||
docker:down:
|
||||
desc: "Stop all containers"
|
||||
cmds:
|
||||
- docker compose --profile app down
|
||||
- docker compose -f docker/docker-compose.yml --profile app --profile ui down
|
||||
silent: true
|
||||
|
||||
docker:clean:
|
||||
desc: "Stop all containers and remove volumes"
|
||||
prompt: "This will remove all docker containers, networks, volumes, and images. Are you sure?"
|
||||
cmds:
|
||||
- docker compose --profile app down -v
|
||||
- docker compose -f docker/docker-compose.yml --profile app --profile ui down -v
|
||||
silent: true
|
||||
|
||||
docker:refresh:
|
||||
@@ -169,13 +175,25 @@ tasks:
|
||||
# Utilities
|
||||
# -----------------------------------------------------------
|
||||
psql:
|
||||
desc: Connect to the database
|
||||
desc: "Connect to the docker database"
|
||||
vars:
|
||||
POSTGRES_USER: '{{.POSTGRES_USER | default "postgres"}}'
|
||||
POSTGRES_DB: '{{.POSTGRES_DB | default "siren"}}'
|
||||
deps: [ docker:up ]
|
||||
cmds:
|
||||
- docker exec -it siren-postgres psql -U $DATABASE_USER -P pager=off
|
||||
- docker exec -it siren-postgres psql -U {{.POSTGRES_USER}} -d {{.POSTGRES_DB}} -P pager=off
|
||||
silent: true
|
||||
|
||||
psql:admin:
|
||||
desc: "Grant admin role to a user"
|
||||
cmds:
|
||||
- docker exec -it siren-postgres psql -U {{.POSTGRES_USER}} -d {{.POSTGRES_DB}} -c "UPDATE users SET role = 'admin' WHERE username = '{{.user}}'"
|
||||
requires:
|
||||
vars: [ user ]
|
||||
silent: true
|
||||
|
||||
ngrok:
|
||||
desc: Start ngrok tunnel
|
||||
desc: "Start ngrok tunnel"
|
||||
vars:
|
||||
UI_PORT: '{{.UI_PORT | default "5173"}}'
|
||||
cmds:
|
||||
|
||||
190
crates/siren-api/src/admin/mod.rs
Normal file
190
crates/siren-api/src/admin/mod.rs
Normal 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)
|
||||
}
|
||||
@@ -8,28 +8,42 @@ use axum::{
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use siren_bot::{
|
||||
commands::audio::{
|
||||
join_voice_channel,
|
||||
pause::pause_track,
|
||||
play::enqueue_track,
|
||||
queue::{TrackInfo, get_is_paused, get_queue},
|
||||
resume::resume_track,
|
||||
skip::skip_track,
|
||||
stop::stop_track,
|
||||
},
|
||||
handler::get_songbird,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Routes that don't require a guild_id (nested at /api/audio)
|
||||
pub fn get_routes() -> Router<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()
|
||||
.route("/play", post(play_audio))
|
||||
.route("/pause", post(pause_audio))
|
||||
.route("/resume", post(resume_audio))
|
||||
.route("/stop", post(stop_audio))
|
||||
.route("/skip", post(skip_audio))
|
||||
.route("/status", get(audio_status))
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PlayTrackRequest {
|
||||
url: String,
|
||||
@@ -52,6 +66,38 @@ async fn get_discord_snowflake(local_user_id: Uuid) -> Result<u64> {
|
||||
.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(
|
||||
SessionAuthorization(session): SessionAuthorization,
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -88,6 +134,8 @@ async fn play_audio(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── POST /api/audio/{guild_id}/pause ─────────────────────────────────────────
|
||||
|
||||
async fn pause_audio(
|
||||
SessionAuthorization(session): SessionAuthorization,
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -96,18 +144,18 @@ async fn pause_audio(
|
||||
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||
log::debug!("Pausing audio in guild: {}", guild_id);
|
||||
|
||||
// Validate if the guild exists in the cache
|
||||
let guild_id = match state.cache.guild(guild_id) {
|
||||
Some(guild) => guild.id,
|
||||
None => return Err(Error::not_found("Guild not found".to_string())),
|
||||
};
|
||||
|
||||
// Pause the track
|
||||
let manager = get_songbird();
|
||||
pause_track(manager, &guild_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── POST /api/audio/{guild_id}/resume ────────────────────────────────────────
|
||||
|
||||
async fn resume_audio(
|
||||
SessionAuthorization(session): SessionAuthorization,
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -116,14 +164,106 @@ async fn resume_audio(
|
||||
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||
log::debug!("Resuming audio in guild: {}", guild_id);
|
||||
|
||||
// Validate if the guild exists in the cache
|
||||
let guild_id = match state.cache.guild(guild_id) {
|
||||
Some(guild) => guild.id,
|
||||
None => return Err(Error::not_found("Guild not found".to_string())),
|
||||
};
|
||||
|
||||
// Resume the track
|
||||
let manager = get_songbird();
|
||||
resume_track(manager, &guild_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── POST /api/audio/{guild_id}/stop ──────────────────────────────────────────
|
||||
|
||||
async fn stop_audio(
|
||||
SessionAuthorization(session): SessionAuthorization,
|
||||
State(state): State<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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -294,8 +294,8 @@ async fn do_oauth_callback(
|
||||
|
||||
None => {
|
||||
// Find existing connection → local user_id
|
||||
let local_user_id: Option<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT u.id, u.username \
|
||||
let local_user_id: Option<(Uuid, String, String)> = sqlx::query_as(
|
||||
"SELECT u.id, u.username, u.status \
|
||||
FROM user_connections uc \
|
||||
JOIN users u ON u.id = uc.user_id \
|
||||
WHERE uc.provider = 'discord' AND uc.provider_user_id = $1",
|
||||
@@ -311,6 +311,10 @@ async fn do_oauth_callback(
|
||||
let (user_id, username) = match local_user_id {
|
||||
// Already linked — use the existing local user
|
||||
Some(row) => {
|
||||
// Reject banned accounts
|
||||
if row.2 == "banned" {
|
||||
return err_redirect(StatusCode::FORBIDDEN);
|
||||
}
|
||||
// Keep provider fields up to date
|
||||
sqlx::query(
|
||||
"UPDATE user_connections \
|
||||
@@ -327,7 +331,7 @@ async fn do_oauth_callback(
|
||||
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||
})?;
|
||||
|
||||
row
|
||||
(row.0, row.1)
|
||||
}
|
||||
|
||||
// First login — create a local user + connection
|
||||
|
||||
@@ -85,6 +85,10 @@ pub struct UserInfo {
|
||||
/// OAuth and can safely disconnect OAuth providers).
|
||||
pub has_password: bool,
|
||||
pub connections: Vec<ConnectionInfo>,
|
||||
/// Site-level role: `"admin"` or `"user"`.
|
||||
pub role: String,
|
||||
/// Account status: `"active"` or `"banned"`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -95,6 +99,8 @@ struct DbUser {
|
||||
last_name: Option<String>,
|
||||
email: Option<String>,
|
||||
password_hash: Option<String>,
|
||||
role: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -176,7 +182,8 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
||||
let pool = data::pool();
|
||||
|
||||
let user: DbUser = sqlx::query_as(
|
||||
"SELECT id, username, first_name, last_name, email, password_hash FROM users WHERE id = $1",
|
||||
"SELECT id, username, first_name, last_name, email, password_hash, role, status \
|
||||
FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
@@ -197,6 +204,8 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
||||
last_name: user.last_name,
|
||||
email: user.email,
|
||||
has_password: user.password_hash.is_some(),
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
connections: connections
|
||||
.into_iter()
|
||||
.map(|c| ConnectionInfo {
|
||||
@@ -260,13 +269,13 @@ async fn login(
|
||||
) -> Result<impl IntoResponse> {
|
||||
let pool = data::pool();
|
||||
|
||||
let row: Option<(Uuid, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, username, password_hash FROM users WHERE username = $1")
|
||||
let row: Option<(Uuid, String, Option<String>, String)> =
|
||||
sqlx::query_as("SELECT id, username, password_hash, status FROM users WHERE username = $1")
|
||||
.bind(&payload.username)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let (user_id, username, password_hash) =
|
||||
let (user_id, username, password_hash, status) =
|
||||
row.ok_or_else(|| Error::new(401, "Invalid username or password".into()))?;
|
||||
|
||||
let hash =
|
||||
@@ -276,6 +285,10 @@ async fn login(
|
||||
return Err(Error::new(401, "Invalid username or password".into()));
|
||||
}
|
||||
|
||||
if status == "banned" {
|
||||
return Err(Error::new(403, "This account has been banned".into()));
|
||||
}
|
||||
|
||||
let ip = extract_ip(&headers);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
auth::{bearer_token::BearerTokenClaims, session::Session},
|
||||
error::Result,
|
||||
error::{Error, Result},
|
||||
};
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
@@ -10,6 +10,7 @@ use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{DecodingKey, Validation, decode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use siren_core::data;
|
||||
|
||||
pub const COOKIE_NAME: &str = "siren_session";
|
||||
|
||||
@@ -130,3 +131,46 @@ pub async fn check_cookie_from_header_str(
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extractor that requires the caller to be an authenticated site admin.
|
||||
///
|
||||
/// Returns `401 Unauthorized` if there is no valid session, or
|
||||
/// `403 Forbidden` if the user's role is not `"admin"`.
|
||||
/// On success, the inner `Session` is available to the handler.
|
||||
pub struct AdminAuthorization(pub Session);
|
||||
|
||||
impl<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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ pub use local::UserInfo;
|
||||
pub use session::Session;
|
||||
|
||||
pub mod middleware;
|
||||
pub use middleware::SessionAuthorization;
|
||||
pub use middleware::{AdminAuthorization, SessionAuthorization};
|
||||
|
||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod app;
|
||||
mod app_state;
|
||||
pub mod audio;
|
||||
@@ -13,8 +14,10 @@ use std::sync::Arc;
|
||||
|
||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.nest("/admin", admin::get_routes())
|
||||
.nest("/auth", auth::get_routes())
|
||||
.nest("/audio/{guild_id}", audio::get_routes())
|
||||
.nest("/audio", audio::get_routes())
|
||||
.nest("/audio/{guild_id}", audio::get_guild_routes())
|
||||
.nest("/dice", dice::get_routes())
|
||||
.nest("/grid", grid::get_routes())
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ chrono = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
lazy_static = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
||||
pub mod mute;
|
||||
pub mod pause;
|
||||
pub mod play;
|
||||
pub mod queue;
|
||||
pub mod resume;
|
||||
pub mod skip;
|
||||
pub mod stop;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use super::{is_valid_url, join_voice_channel, leave_voice_channel};
|
||||
use super::{
|
||||
is_valid_url,
|
||||
join_voice_channel,
|
||||
leave_voice_channel,
|
||||
queue::{TrackInfo, enqueue_tracks, pop_front},
|
||||
};
|
||||
use crate::{
|
||||
chat::{create_message_response, edit_response, process_message},
|
||||
error::{Error, Result},
|
||||
@@ -113,6 +118,15 @@ pub async fn enqueue_track(
|
||||
|
||||
playlist_items = get_ytdlp_items(track_url)?;
|
||||
|
||||
// Collect TrackInfo for the queue store before borrowing `item` in the loop
|
||||
let track_infos: Vec<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
|
||||
for item in &playlist_items {
|
||||
let volume = guild.volume as f32 / 100.0;
|
||||
@@ -137,6 +151,10 @@ pub async fn enqueue_track(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Store track metadata so the REST API can expose queue info
|
||||
enqueue_tracks(guild_id.get(), track_infos);
|
||||
|
||||
if handler.queue().is_empty() {
|
||||
let _ = handler.queue().resume();
|
||||
}
|
||||
@@ -204,6 +222,9 @@ struct TrackEndNotifier {
|
||||
impl EventHandler for TrackEndNotifier {
|
||||
async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option<songbird::events::Event> {
|
||||
if let songbird::EventContext::Track(_track_list) = ctx {
|
||||
// Remove the finished track from our metadata store
|
||||
pop_front(self.guild_id.get());
|
||||
|
||||
if let Some(call) = self.call.get(self.guild_id) {
|
||||
let mut handler = call.lock().await;
|
||||
if handler.queue().is_empty() {
|
||||
|
||||
88
crates/siren-bot/src/commands/audio/queue.rs
Normal file
88
crates/siren-bot/src/commands/audio/queue.rs
Normal 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
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::{
|
||||
chat::{edit_response, process_message},
|
||||
commands::audio::queue::pop_front,
|
||||
handler::get_songbird,
|
||||
};
|
||||
use serenity::{
|
||||
all::{CommandInteraction, CreateCommand},
|
||||
all::{CommandInteraction, CreateCommand, GuildId},
|
||||
prelude::*,
|
||||
};
|
||||
use songbird::Songbird;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||
// Create the initial response
|
||||
@@ -29,17 +32,27 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||
};
|
||||
|
||||
// Skip the track
|
||||
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
|
||||
let handler = handler_lock.lock().await;
|
||||
match handler.queue().skip() {
|
||||
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;
|
||||
}
|
||||
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()) {
|
||||
let handler = handler_lock.lock().await;
|
||||
handler
|
||||
.queue()
|
||||
.skip()
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Pop the current track from our metadata store; the next track (if any) moves to front
|
||||
pop_front(guild_id.get());
|
||||
Ok(())
|
||||
} else {
|
||||
Err("No active audio session in this guild".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::{
|
||||
chat::{edit_response, process_message},
|
||||
commands::audio::queue::clear_queue,
|
||||
handler::get_songbird,
|
||||
};
|
||||
use serenity::{
|
||||
all::{CommandInteraction, CreateCommand},
|
||||
all::{CommandInteraction, CreateCommand, GuildId},
|
||||
prelude::*,
|
||||
};
|
||||
use songbird::Songbird;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||
// Create the initial response
|
||||
@@ -29,12 +32,24 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||
};
|
||||
|
||||
// Stop the track and clear the queue
|
||||
if let Some(handler_lock) = manager.get(guild_id) {
|
||||
let handler = handler_lock.lock().await;
|
||||
handler.queue().stop();
|
||||
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;
|
||||
handler.queue().stop();
|
||||
clear_queue(guild_id.get());
|
||||
Ok(())
|
||||
} else {
|
||||
Err("No active audio session in this guild".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register() -> CreateCommand {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{chat::create_modal_response, commands};
|
||||
use crate::{
|
||||
HttpKey,
|
||||
commands::fun::roll::{format_roll, roll_dice, send_roll_message},
|
||||
commands::{audio::queue::init_track_queues, fun::roll::{format_roll, roll_dice, send_roll_message}},
|
||||
};
|
||||
use serenity::{
|
||||
all::{
|
||||
@@ -64,6 +64,9 @@ impl EventHandler for BotHandler {
|
||||
log::warn!("No ready guilds found");
|
||||
}
|
||||
|
||||
// Initialise the track-queue metadata store (idempotent)
|
||||
init_track_queues();
|
||||
|
||||
if SONGBIRD.get().is_none() {
|
||||
let songbird = songbird::get(&ctx).await.unwrap();
|
||||
SONGBIRD
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
# =========
|
||||
# Builder
|
||||
# =========
|
||||
FROM rust:1.94-slim-bookworm AS builder
|
||||
FROM rust:1.87-slim-bookworm AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,target=/target,sharing=locked \
|
||||
--mount=type=cache,target=/app/target,sharing=locked \
|
||||
cargo build --release --bin siren && \
|
||||
cp /target/release/siren /siren
|
||||
cp /app/target/release/siren /siren
|
||||
|
||||
# ==========
|
||||
# Packages
|
||||
@@ -41,8 +42,16 @@ WORKDIR /siren
|
||||
USER root
|
||||
|
||||
COPY --from=builder /siren /usr/local/bin/siren
|
||||
COPY --from=packages /packages /usr/bin
|
||||
COPY --from=packages /packages/yt-dlp /usr/bin/yt-dlp
|
||||
COPY --from=packages /packages/ffmpeg /usr/bin/ffmpeg
|
||||
|
||||
RUN apt-get update && apt-get install -y libc6 libc6-dev libopus-dev libpq5 libpq-dev python3-pip ffmpeg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libc6 \
|
||||
libc6-dev \
|
||||
libopus-dev \
|
||||
libpq5 \
|
||||
libpq-dev \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
CMD ["siren"]
|
||||
30
docker/Dockerfile.ui
Normal file
30
docker/Dockerfile.ui
Normal 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;"]
|
||||
@@ -1,5 +1,5 @@
|
||||
x-env_file: &env
|
||||
- path: .env
|
||||
- path: ../.env
|
||||
required: true
|
||||
|
||||
x-restart: &default_restart
|
||||
@@ -8,6 +8,9 @@ x-restart: &default_restart
|
||||
name: siren
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: siren:${SIREN_VERSION:-latest}
|
||||
container_name: siren-app
|
||||
env_file: *env
|
||||
@@ -18,13 +21,27 @@ services:
|
||||
VALKEY_PORT: 6379
|
||||
DATA_DIR_PATH: /data
|
||||
volumes:
|
||||
- ${DATA_DIR_PATH:-./data}:/data
|
||||
- ${DATA_DIR_PATH:-../data}:/data
|
||||
depends_on:
|
||||
- postgres
|
||||
profiles:
|
||||
- app
|
||||
<<: *default_restart
|
||||
|
||||
ui:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.ui
|
||||
image: siren-ui:${SIREN_VERSION:-latest}
|
||||
container_name: siren-ui
|
||||
ports:
|
||||
- ${UI_PORT:-80}:80
|
||||
depends_on:
|
||||
- app
|
||||
profiles:
|
||||
- ui
|
||||
<<: *default_restart
|
||||
|
||||
postgres:
|
||||
image: postgres:18.0
|
||||
container_name: siren-postgres
|
||||
32
docker/nginx.conf
Normal file
32
docker/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email TEXT UNIQUE,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'banned')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0"
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^7.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
391
ui/src/App.tsx
391
ui/src/App.tsx
@@ -1,371 +1,50 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types";
|
||||
import type { GridHandle } from "./components/Grid";
|
||||
import { api, auth } from "./api";
|
||||
import { useState } from "react";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./context/AuthContext";
|
||||
import Header from "./components/Header";
|
||||
import ControlPanel from "./components/ControlPanel";
|
||||
import ColorPanel from "./components/ColorPanel";
|
||||
import Grid from "./components/Grid";
|
||||
import LoginModal from "./components/LoginModal";
|
||||
import AccountPanel from "./components/AccountPanel";
|
||||
import FloatingMapControls from "./components/FloatingMapControls";
|
||||
import NewMapModal from "./components/NewMapModal";
|
||||
import EditMapModal from "./components/EditMapModal";
|
||||
import MapListModal from "./components/MapListModal";
|
||||
import "./components/Modal.css";
|
||||
import MapPage from "./pages/MapPage";
|
||||
import AccountPage from "./pages/AccountPage";
|
||||
import AdminPage from "./pages/AdminPage";
|
||||
import "./App.css";
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
"#6b7280",
|
||||
"#92400e",
|
||||
"#15803d",
|
||||
"#1d4ed8",
|
||||
"#7c3aed",
|
||||
"#dc2626",
|
||||
"#ca8a04",
|
||||
"#0f172a",
|
||||
"#f9fafb",
|
||||
];
|
||||
|
||||
function getMapIdFromUrl(): string | null {
|
||||
const match = window.location.pathname.match(/^\/map\/([^/]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function getQueryParam(key: string): string | null {
|
||||
return new URLSearchParams(window.location.search).get(key);
|
||||
}
|
||||
|
||||
function removeQueryParam(key: string) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete(key);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
url.pathname + (url.search !== "?" ? url.search : ""),
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// ── Auth state ──
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const { user, authLoading } = useAuth();
|
||||
const [mapTitle, setMapTitle] = useState<string | null>(null);
|
||||
|
||||
// ── 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 (
|
||||
<div className="app">
|
||||
<Header
|
||||
user={user}
|
||||
authLoading={authLoading}
|
||||
selectedMapName={selectedMapInfo?.name ?? null}
|
||||
onLoginClick={() => setShowLoginModal(true)}
|
||||
onAccountClick={() => setShowAccountPanel(true)}
|
||||
/>
|
||||
|
||||
<Header mapTitle={mapTitle} />
|
||||
<div className="app-body">
|
||||
<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}
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/map" replace />} />
|
||||
<Route path="/map" element={<MapPage setMapTitle={setMapTitle} />} />
|
||||
<Route
|
||||
path="/map/:mapId"
|
||||
element={<MapPage setMapTitle={setMapTitle} />}
|
||||
/>
|
||||
|
||||
{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>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
authLoading ? null : user ? (
|
||||
<AccountPage />
|
||||
) : (
|
||||
<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>
|
||||
<Navigate to="/map" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
authLoading ? null : user?.role === "admin" ? (
|
||||
<AdminPage />
|
||||
) : (
|
||||
<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>
|
||||
<Navigate to="/map" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/map" replace />} />
|
||||
</Routes>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type {
|
||||
AdminUser,
|
||||
AudioStatus,
|
||||
DiscordGuild,
|
||||
GridMap,
|
||||
ListedMap,
|
||||
MapAccessRequest,
|
||||
@@ -230,3 +233,67 @@ export const auth = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const ADMIN_BASE = "/api/admin";
|
||||
|
||||
export const adminApi = {
|
||||
/** List all user accounts (admin only). */
|
||||
listUsers: (): Promise<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" }),
|
||||
};
|
||||
|
||||
@@ -290,6 +290,24 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Page mode ── */
|
||||
.account-panel-page {
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
max-height: none;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2e3348;
|
||||
}
|
||||
|
||||
.account-back-btn {
|
||||
font-size: 0.82rem;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.account-back-btn:hover {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.account-footer {
|
||||
border-top: 1px solid #2e3348;
|
||||
|
||||
@@ -8,9 +8,17 @@ interface Props {
|
||||
user: UserInfo;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
/** "modal" (default) renders with a full-screen backdrop overlay.
|
||||
* "page" renders inline for use as a standalone page. */
|
||||
mode?: "modal" | "page";
|
||||
}
|
||||
|
||||
export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
export default function AccountPanel({
|
||||
user,
|
||||
onClose,
|
||||
onRefresh,
|
||||
mode = "modal",
|
||||
}: Props) {
|
||||
const discordConnection = user.connections.find(
|
||||
(c) => c.provider === "discord",
|
||||
);
|
||||
@@ -114,15 +122,14 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-backdrop" onClick={onClose}>
|
||||
<div className="account-panel" onClick={(e) => e.stopPropagation()}>
|
||||
const panelContent = (
|
||||
<div
|
||||
className={`account-panel${mode === "page" ? " account-panel-page" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="account-header">
|
||||
<h2>Account</h2>
|
||||
{mode != "page" && (
|
||||
<button
|
||||
className="account-close"
|
||||
onClick={onClose}
|
||||
@@ -130,6 +137,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Profile ── */}
|
||||
@@ -171,9 +179,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
)}
|
||||
|
||||
{profileError && <p className="account-error">{profileError}</p>}
|
||||
{profileSuccess && (
|
||||
<p className="account-success">Profile saved!</p>
|
||||
)}
|
||||
{profileSuccess && <p className="account-success">Profile saved!</p>}
|
||||
|
||||
{profileDirty && (
|
||||
<div className="profile-actions">
|
||||
@@ -337,13 +343,24 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
{/* ── Footer (modal-only logout) ── */}
|
||||
{mode === "modal" && (
|
||||
<div className="account-footer">
|
||||
<button className="btn-logout" onClick={handleLogout}>
|
||||
<button className="btn-logout" onClick={() => auth.logout()}>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (mode === "page") {
|
||||
return panelContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-backdrop" onClick={onClose}>
|
||||
{panelContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
273
ui/src/components/AdminPanel.css
Normal file
273
ui/src/components/AdminPanel.css
Normal 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;
|
||||
}
|
||||
243
ui/src/components/AdminPanel.tsx
Normal file
243
ui/src/components/AdminPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
297
ui/src/components/DiscordPanel.css
Normal file
297
ui/src/components/DiscordPanel.css
Normal 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;
|
||||
}
|
||||
295
ui/src/components/DiscordPanel.tsx
Normal file
295
ui/src/components/DiscordPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -130,10 +130,14 @@ export default function EditMapModal({ map, onClose, onUpdated }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
|
||||
const nonOwnerPerms = permissions.filter((p) => p.role !== "owner");
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className="modal edit-map-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -46,3 +46,52 @@
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Account dropdown ── */
|
||||
.account-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.account-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.account-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.account-dropdown-item:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.account-dropdown-divider {
|
||||
height: 1px;
|
||||
background: #374151;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.account-dropdown-logout {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.account-dropdown-logout:hover {
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
@@ -1,51 +1,127 @@
|
||||
import type { UserInfo } from "../types";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { auth } from "../api";
|
||||
import LoginButton from "./LoginButton";
|
||||
import LoginModal from "./LoginModal";
|
||||
import "./Header.css";
|
||||
|
||||
interface Props {
|
||||
user: UserInfo | null;
|
||||
authLoading: boolean;
|
||||
selectedMapName: string | null;
|
||||
onLoginClick: () => void;
|
||||
onAccountClick: () => void;
|
||||
mapTitle: string | null;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
user,
|
||||
authLoading,
|
||||
selectedMapName,
|
||||
onLoginClick,
|
||||
onAccountClick,
|
||||
}: Props) {
|
||||
export default function Header({ mapTitle }: Props) {
|
||||
const { user, authLoading, setUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/** Display name: first name if set, otherwise username */
|
||||
const displayName = user ? user.first_name?.trim() || user.username : null;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!dropdownOpen) return;
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
async function handleLogout() {
|
||||
setDropdownOpen(false);
|
||||
await auth.logout();
|
||||
setUser(null);
|
||||
navigate("/map");
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="app-header">
|
||||
<div className="app-brand">
|
||||
<div
|
||||
className="app-brand"
|
||||
onClick={() => navigate("/map")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span>SIREN</span>
|
||||
</div>
|
||||
|
||||
<div className="app-header-center">
|
||||
{selectedMapName && (
|
||||
<span className="header-map-name">{selectedMapName}</span>
|
||||
)}
|
||||
{mapTitle && <span className="header-map-name">{mapTitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className="app-auth">
|
||||
{!authLoading &&
|
||||
(user ? (
|
||||
<div className="account-dropdown-wrapper" ref={dropdownRef}>
|
||||
<button
|
||||
className="header-btn"
|
||||
onClick={onAccountClick}
|
||||
title="Account settings"
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={dropdownOpen}
|
||||
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>
|
||||
|
||||
{showLoginModal && (
|
||||
<LoginModal
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
onLogin={(u) => {
|
||||
setUser(u);
|
||||
setShowLoginModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,8 +91,12 @@ export default function MapListModal({
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className="modal map-list-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -106,3 +106,14 @@
|
||||
.header-btn:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* Admin variant — subtle purple accent */
|
||||
.header-btn-admin {
|
||||
border-color: rgba(139, 92, 246, 0.45);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
.header-btn-admin:hover {
|
||||
background: #4b5563;
|
||||
border-color: #7c3aed;
|
||||
color: #ddd6fe;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,12 @@ export default function NewMapModal({ onClose, onCreate }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
|
||||
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-header">
|
||||
<h2>New Map</h2>
|
||||
@@ -51,7 +55,7 @@ export default function NewMapModal({ onClose, onCreate }: Props) {
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
placeholder="My awesome map…"
|
||||
placeholder="e.g. Ravenloft"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={60}
|
||||
|
||||
47
ui/src/context/AuthContext.tsx
Normal file
47
ui/src/context/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.tsx";
|
||||
import { AuthProvider } from "./context/AuthContext.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
26
ui/src/pages/AccountPage.tsx
Normal file
26
ui/src/pages/AccountPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
ui/src/pages/AdminPage.tsx
Normal file
21
ui/src/pages/AdminPage.tsx
Normal 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
351
ui/src/pages/MapPage.tsx
Normal 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
25
ui/src/pages/Pages.css
Normal 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;
|
||||
}
|
||||
@@ -13,6 +13,20 @@ export interface UserInfo {
|
||||
/** True when the account has a local password (can log in without OAuth). */
|
||||
has_password: boolean;
|
||||
connections: ConnectionInfo[];
|
||||
/** Site-level role: "admin" | "user" */
|
||||
role: "admin" | "user";
|
||||
/** Account status: "active" | "banned" */
|
||||
status: "active" | "banned";
|
||||
}
|
||||
|
||||
/** User record returned by the admin user list endpoint. */
|
||||
export interface AdminUser {
|
||||
id: string; // UUID
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: "admin" | "user";
|
||||
status: "active" | "banned";
|
||||
created_at: string; // ISO datetime
|
||||
}
|
||||
|
||||
export type MapRole = "owner" | "editor" | "viewer";
|
||||
@@ -82,6 +96,25 @@ export interface MapAccessRequest {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ── Discord / Audio ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface DiscordGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TrackInfo {
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AudioStatus {
|
||||
voice_channel: string | null;
|
||||
is_paused: boolean;
|
||||
current_track: TrackInfo | null;
|
||||
queue: TrackInfo[];
|
||||
}
|
||||
|
||||
export type Tool = "pan" | "zoom" | "draw" | "token";
|
||||
|
||||
export type ClientMessage =
|
||||
|
||||
Reference in New Issue
Block a user