Updates to pages
This commit is contained in:
40
Taskfile.yml
40
Taskfile.yml
@@ -90,34 +90,40 @@ tasks:
|
|||||||
# Docker
|
# Docker
|
||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
docker:build:
|
docker:build:
|
||||||
desc: "Build the Docker image (use v=x.x.x to set version, default is \"latest\")"
|
desc: "Build the Rust app Docker image (use v=x.x.x to set version, default is \"latest\")"
|
||||||
cmds:
|
cmds:
|
||||||
- docker build -f Dockerfile -t siren:{{.VERSION}} .
|
- docker build -f docker/Dockerfile -t siren:{{.VERSION}} .
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
docker:build:ui:
|
||||||
|
desc: "Build the UI Docker image (use v=x.x.x to set version, default is \"latest\")"
|
||||||
|
cmds:
|
||||||
|
- docker build -f docker/Dockerfile.ui -t siren-ui:{{.VERSION}} .
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
docker:up:
|
docker:up:
|
||||||
desc: "Start backend containers"
|
desc: "Start backend containers (postgres + valkey)"
|
||||||
cmds:
|
cmds:
|
||||||
- docker compose up -d
|
- docker compose -f docker/docker-compose.yml up -d
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
docker:up:all:
|
docker:up:all:
|
||||||
desc: "Start all containers"
|
desc: "Start all containers (app + ui + postgres + valkey)"
|
||||||
cmds:
|
cmds:
|
||||||
- docker compose --profile app up -d
|
- docker compose -f docker/docker-compose.yml --profile app --profile ui up -d
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
docker:down:
|
docker:down:
|
||||||
desc: "Stop all containers"
|
desc: "Stop all containers"
|
||||||
cmds:
|
cmds:
|
||||||
- docker compose --profile app down
|
- docker compose -f docker/docker-compose.yml --profile app --profile ui down
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
docker:clean:
|
docker:clean:
|
||||||
desc: "Stop all containers and remove volumes"
|
desc: "Stop all containers and remove volumes"
|
||||||
prompt: "This will remove all docker containers, networks, volumes, and images. Are you sure?"
|
prompt: "This will remove all docker containers, networks, volumes, and images. Are you sure?"
|
||||||
cmds:
|
cmds:
|
||||||
- docker compose --profile app down -v
|
- docker compose -f docker/docker-compose.yml --profile app --profile ui down -v
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
docker:refresh:
|
docker:refresh:
|
||||||
@@ -169,13 +175,25 @@ tasks:
|
|||||||
# Utilities
|
# Utilities
|
||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
psql:
|
psql:
|
||||||
desc: Connect to the database
|
desc: "Connect to the docker database"
|
||||||
|
vars:
|
||||||
|
POSTGRES_USER: '{{.POSTGRES_USER | default "postgres"}}'
|
||||||
|
POSTGRES_DB: '{{.POSTGRES_DB | default "siren"}}'
|
||||||
|
deps: [ docker:up ]
|
||||||
cmds:
|
cmds:
|
||||||
- docker exec -it siren-postgres psql -U $DATABASE_USER -P pager=off
|
- docker exec -it siren-postgres psql -U {{.POSTGRES_USER}} -d {{.POSTGRES_DB}} -P pager=off
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
psql:admin:
|
||||||
|
desc: "Grant admin role to a user"
|
||||||
|
cmds:
|
||||||
|
- docker exec -it siren-postgres psql -U {{.POSTGRES_USER}} -d {{.POSTGRES_DB}} -c "UPDATE users SET role = 'admin' WHERE username = '{{.user}}'"
|
||||||
|
requires:
|
||||||
|
vars: [ user ]
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
ngrok:
|
ngrok:
|
||||||
desc: Start ngrok tunnel
|
desc: "Start ngrok tunnel"
|
||||||
vars:
|
vars:
|
||||||
UI_PORT: '{{.UI_PORT | default "5173"}}'
|
UI_PORT: '{{.UI_PORT | default "5173"}}'
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
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,
|
Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::post,
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use siren_bot::{
|
use siren_bot::{
|
||||||
commands::audio::{
|
commands::audio::{
|
||||||
join_voice_channel,
|
join_voice_channel,
|
||||||
pause::pause_track,
|
pause::pause_track,
|
||||||
play::enqueue_track,
|
play::enqueue_track,
|
||||||
|
queue::{TrackInfo, get_is_paused, get_queue},
|
||||||
resume::resume_track,
|
resume::resume_track,
|
||||||
|
skip::skip_track,
|
||||||
|
stop::stop_track,
|
||||||
},
|
},
|
||||||
handler::get_songbird,
|
handler::get_songbird,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Routes that don't require a guild_id (nested at /api/audio)
|
||||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||||
|
Router::new().route("/guilds", get(list_guilds))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes that operate on a specific guild (nested at /api/audio/{guild_id})
|
||||||
|
pub fn get_guild_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/play", post(play_audio))
|
.route("/play", post(play_audio))
|
||||||
.route("/pause", post(pause_audio))
|
.route("/pause", post(pause_audio))
|
||||||
.route("/resume", post(resume_audio))
|
.route("/resume", post(resume_audio))
|
||||||
|
.route("/stop", post(stop_audio))
|
||||||
|
.route("/skip", post(skip_audio))
|
||||||
|
.route("/status", get(audio_status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PlayTrackRequest {
|
struct PlayTrackRequest {
|
||||||
url: String,
|
url: String,
|
||||||
@@ -52,6 +66,38 @@ async fn get_discord_snowflake(local_user_id: Uuid) -> Result<u64> {
|
|||||||
.ok_or_else(|| Error::not_found("Discord account not connected".to_string()))
|
.ok_or_else(|| Error::not_found("Discord account not connected".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /api/audio/guilds ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GuildInfo {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all guilds the bot is currently in (from its Discord cache).
|
||||||
|
async fn list_guilds(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<GuildInfo>>> {
|
||||||
|
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
|
||||||
|
let guilds: Vec<GuildInfo> = state
|
||||||
|
.cache
|
||||||
|
.guilds()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|guild_id| {
|
||||||
|
state.cache.guild(guild_id).map(|g| GuildInfo {
|
||||||
|
id: guild_id.get().to_string(),
|
||||||
|
name: g.name.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(guilds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/audio/{guild_id}/play ──────────────────────────────────────────
|
||||||
|
|
||||||
async fn play_audio(
|
async fn play_audio(
|
||||||
SessionAuthorization(session): SessionAuthorization,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -88,6 +134,8 @@ async fn play_audio(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST /api/audio/{guild_id}/pause ─────────────────────────────────────────
|
||||||
|
|
||||||
async fn pause_audio(
|
async fn pause_audio(
|
||||||
SessionAuthorization(session): SessionAuthorization,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -96,18 +144,18 @@ async fn pause_audio(
|
|||||||
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
log::debug!("Pausing audio in guild: {}", guild_id);
|
log::debug!("Pausing audio in guild: {}", guild_id);
|
||||||
|
|
||||||
// Validate if the guild exists in the cache
|
|
||||||
let guild_id = match state.cache.guild(guild_id) {
|
let guild_id = match state.cache.guild(guild_id) {
|
||||||
Some(guild) => guild.id,
|
Some(guild) => guild.id,
|
||||||
None => return Err(Error::not_found("Guild not found".to_string())),
|
None => return Err(Error::not_found("Guild not found".to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pause the track
|
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
pause_track(manager, &guild_id).await?;
|
pause_track(manager, &guild_id).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST /api/audio/{guild_id}/resume ────────────────────────────────────────
|
||||||
|
|
||||||
async fn resume_audio(
|
async fn resume_audio(
|
||||||
SessionAuthorization(session): SessionAuthorization,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -116,14 +164,106 @@ async fn resume_audio(
|
|||||||
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
log::debug!("Resuming audio in guild: {}", guild_id);
|
log::debug!("Resuming audio in guild: {}", guild_id);
|
||||||
|
|
||||||
// Validate if the guild exists in the cache
|
|
||||||
let guild_id = match state.cache.guild(guild_id) {
|
let guild_id = match state.cache.guild(guild_id) {
|
||||||
Some(guild) => guild.id,
|
Some(guild) => guild.id,
|
||||||
None => return Err(Error::not_found("Guild not found".to_string())),
|
None => return Err(Error::not_found("Guild not found".to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resume the track
|
|
||||||
let manager = get_songbird();
|
let manager = get_songbird();
|
||||||
resume_track(manager, &guild_id).await?;
|
resume_track(manager, &guild_id).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST /api/audio/{guild_id}/stop ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn stop_audio(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(guild_id): Path<u64>,
|
||||||
|
) -> Result<()> {
|
||||||
|
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
log::debug!("Stopping audio in guild: {}", guild_id);
|
||||||
|
|
||||||
|
let guild_id = match state.cache.guild(guild_id) {
|
||||||
|
Some(guild) => guild.id,
|
||||||
|
None => return Err(Error::not_found("Guild not found".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let manager = get_songbird();
|
||||||
|
stop_track(manager, &guild_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(500, e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_audio(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(guild_id): Path<u64>,
|
||||||
|
) -> Result<()> {
|
||||||
|
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
log::debug!("<{}> Skipping audio", guild_id);
|
||||||
|
|
||||||
|
let guild_id = match state.cache.guild(guild_id) {
|
||||||
|
Some(guild) => guild.id,
|
||||||
|
None => return Err(Error::not_found("Guild not found".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let manager = get_songbird();
|
||||||
|
skip_track(manager, &guild_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::new(500, e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AudioStatus {
|
||||||
|
voice_channel: Option<String>,
|
||||||
|
is_paused: bool,
|
||||||
|
current_track: Option<TrackInfo>,
|
||||||
|
queue: Vec<TrackInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn audio_status(
|
||||||
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(guild_id): Path<u64>,
|
||||||
|
) -> Result<Json<AudioStatus>> {
|
||||||
|
session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
|
||||||
|
let guild_id_snowflake = match state.cache.guild(guild_id) {
|
||||||
|
Some(guild) => guild.id,
|
||||||
|
None => return Err(Error::not_found("Guild not found".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Voice channel: look up the bot's own voice state + channel name from cache ──
|
||||||
|
let bot_user_id = state.cache.current_user().id;
|
||||||
|
let voice_channel = state
|
||||||
|
.cache
|
||||||
|
.guild(guild_id_snowflake)
|
||||||
|
.and_then(|guild| {
|
||||||
|
let ch_id = guild
|
||||||
|
.voice_states
|
||||||
|
.get(&bot_user_id)
|
||||||
|
.and_then(|vs| vs.channel_id)?;
|
||||||
|
guild.channels.get(&ch_id).map(|ch| ch.name.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Playback paused state (delegated to siren-bot to keep songbird internal) ──
|
||||||
|
let is_paused = get_is_paused(guild_id).await;
|
||||||
|
|
||||||
|
// ── Queue metadata from our store (index 0 = currently playing) ──
|
||||||
|
let mut full_queue = get_queue(guild_id);
|
||||||
|
let current_track = if !full_queue.is_empty() {
|
||||||
|
Some(full_queue.remove(0))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(AudioStatus {
|
||||||
|
voice_channel,
|
||||||
|
is_paused,
|
||||||
|
current_track,
|
||||||
|
queue: full_queue,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -294,8 +294,8 @@ async fn do_oauth_callback(
|
|||||||
|
|
||||||
None => {
|
None => {
|
||||||
// Find existing connection → local user_id
|
// Find existing connection → local user_id
|
||||||
let local_user_id: Option<(Uuid, String)> = sqlx::query_as(
|
let local_user_id: Option<(Uuid, String, String)> = sqlx::query_as(
|
||||||
"SELECT u.id, u.username \
|
"SELECT u.id, u.username, u.status \
|
||||||
FROM user_connections uc \
|
FROM user_connections uc \
|
||||||
JOIN users u ON u.id = uc.user_id \
|
JOIN users u ON u.id = uc.user_id \
|
||||||
WHERE uc.provider = 'discord' AND uc.provider_user_id = $1",
|
WHERE uc.provider = 'discord' AND uc.provider_user_id = $1",
|
||||||
@@ -311,6 +311,10 @@ async fn do_oauth_callback(
|
|||||||
let (user_id, username) = match local_user_id {
|
let (user_id, username) = match local_user_id {
|
||||||
// Already linked — use the existing local user
|
// Already linked — use the existing local user
|
||||||
Some(row) => {
|
Some(row) => {
|
||||||
|
// Reject banned accounts
|
||||||
|
if row.2 == "banned" {
|
||||||
|
return err_redirect(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
// Keep provider fields up to date
|
// Keep provider fields up to date
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE user_connections \
|
"UPDATE user_connections \
|
||||||
@@ -327,7 +331,7 @@ async fn do_oauth_callback(
|
|||||||
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
err_redirect(StatusCode::INTERNAL_SERVER_ERROR).unwrap_err()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
row
|
(row.0, row.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// First login — create a local user + connection
|
// First login — create a local user + connection
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ pub struct UserInfo {
|
|||||||
/// OAuth and can safely disconnect OAuth providers).
|
/// OAuth and can safely disconnect OAuth providers).
|
||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
pub connections: Vec<ConnectionInfo>,
|
pub connections: Vec<ConnectionInfo>,
|
||||||
|
/// Site-level role: `"admin"` or `"user"`.
|
||||||
|
pub role: String,
|
||||||
|
/// Account status: `"active"` or `"banned"`.
|
||||||
|
pub status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -95,6 +99,8 @@ struct DbUser {
|
|||||||
last_name: Option<String>,
|
last_name: Option<String>,
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
|
role: String,
|
||||||
|
status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -176,7 +182,8 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
|||||||
let pool = data::pool();
|
let pool = data::pool();
|
||||||
|
|
||||||
let user: DbUser = sqlx::query_as(
|
let user: DbUser = sqlx::query_as(
|
||||||
"SELECT id, username, first_name, last_name, email, password_hash FROM users WHERE id = $1",
|
"SELECT id, username, first_name, last_name, email, password_hash, role, status \
|
||||||
|
FROM users WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
@@ -197,6 +204,8 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
|||||||
last_name: user.last_name,
|
last_name: user.last_name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
has_password: user.password_hash.is_some(),
|
has_password: user.password_hash.is_some(),
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
connections: connections
|
connections: connections
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| ConnectionInfo {
|
.map(|c| ConnectionInfo {
|
||||||
@@ -260,13 +269,13 @@ async fn login(
|
|||||||
) -> Result<impl IntoResponse> {
|
) -> Result<impl IntoResponse> {
|
||||||
let pool = data::pool();
|
let pool = data::pool();
|
||||||
|
|
||||||
let row: Option<(Uuid, String, Option<String>)> =
|
let row: Option<(Uuid, String, Option<String>, String)> =
|
||||||
sqlx::query_as("SELECT id, username, password_hash FROM users WHERE username = $1")
|
sqlx::query_as("SELECT id, username, password_hash, status FROM users WHERE username = $1")
|
||||||
.bind(&payload.username)
|
.bind(&payload.username)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (user_id, username, password_hash) =
|
let (user_id, username, password_hash, status) =
|
||||||
row.ok_or_else(|| Error::new(401, "Invalid username or password".into()))?;
|
row.ok_or_else(|| Error::new(401, "Invalid username or password".into()))?;
|
||||||
|
|
||||||
let hash =
|
let hash =
|
||||||
@@ -276,6 +285,10 @@ async fn login(
|
|||||||
return Err(Error::new(401, "Invalid username or password".into()));
|
return Err(Error::new(401, "Invalid username or password".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status == "banned" {
|
||||||
|
return Err(Error::new(403, "This account has been banned".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let ip = extract_ip(&headers);
|
let ip = extract_ip(&headers);
|
||||||
let user_agent = headers
|
let user_agent = headers
|
||||||
.get("user-agent")
|
.get("user-agent")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::{bearer_token::BearerTokenClaims, session::Session},
|
auth::{bearer_token::BearerTokenClaims, session::Session},
|
||||||
error::Result,
|
error::{Error, Result},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
@@ -10,6 +10,7 @@ use axum_extra::extract::CookieJar;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use jsonwebtoken::{DecodingKey, Validation, decode};
|
use jsonwebtoken::{DecodingKey, Validation, decode};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use siren_core::data;
|
||||||
|
|
||||||
pub const COOKIE_NAME: &str = "siren_session";
|
pub const COOKIE_NAME: &str = "siren_session";
|
||||||
|
|
||||||
@@ -130,3 +131,46 @@ pub async fn check_cookie_from_header_str(
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extractor that requires the caller to be an authenticated site admin.
|
||||||
|
///
|
||||||
|
/// Returns `401 Unauthorized` if there is no valid session, or
|
||||||
|
/// `403 Forbidden` if the user's role is not `"admin"`.
|
||||||
|
/// On success, the inner `Session` is available to the handler.
|
||||||
|
pub struct AdminAuthorization(pub Session);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AdminAuthorization
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = Error;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &S,
|
||||||
|
) -> std::result::Result<Self, Self::Rejection> {
|
||||||
|
let SessionAuthorization(maybe_session) =
|
||||||
|
SessionAuthorization::from_request_parts(parts, state)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let session = maybe_session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
|
||||||
|
// Verify admin role in the database
|
||||||
|
let pool = data::pool();
|
||||||
|
let role: Option<String> = sqlx::query_scalar("SELECT role FROM users WHERE id = $1")
|
||||||
|
.bind(session.user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("DB error checking admin role: {e}");
|
||||||
|
Error::from(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match role.as_deref() {
|
||||||
|
Some("admin") => Ok(AdminAuthorization(session)),
|
||||||
|
Some(_) => Err(Error::from(StatusCode::FORBIDDEN)),
|
||||||
|
None => Err(Error::from(StatusCode::UNAUTHORIZED)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub use local::UserInfo;
|
|||||||
pub use session::Session;
|
pub use session::Session;
|
||||||
|
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub use middleware::SessionAuthorization;
|
pub use middleware::{AdminAuthorization, SessionAuthorization};
|
||||||
|
|
||||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod admin;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
@@ -13,8 +14,10 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
pub fn get_routes() -> Router<Arc<AppState>> {
|
pub fn get_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.nest("/admin", admin::get_routes())
|
||||||
.nest("/auth", auth::get_routes())
|
.nest("/auth", auth::get_routes())
|
||||||
.nest("/audio/{guild_id}", audio::get_routes())
|
.nest("/audio", audio::get_routes())
|
||||||
|
.nest("/audio/{guild_id}", audio::get_guild_routes())
|
||||||
.nest("/dice", dice::get_routes())
|
.nest("/dice", dice::get_routes())
|
||||||
.nest("/grid", grid::get_routes())
|
.nest("/grid", grid::get_routes())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ chrono = { workspace = true }
|
|||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
lazy_static = { workspace = true }
|
lazy_static = { workspace = true }
|
||||||
|
dashmap = { workspace = true }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
|||||||
pub mod mute;
|
pub mod mute;
|
||||||
pub mod pause;
|
pub mod pause;
|
||||||
pub mod play;
|
pub mod play;
|
||||||
|
pub mod queue;
|
||||||
pub mod resume;
|
pub mod resume;
|
||||||
pub mod skip;
|
pub mod skip;
|
||||||
pub mod stop;
|
pub mod stop;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
use super::{is_valid_url, join_voice_channel, leave_voice_channel};
|
use super::{
|
||||||
|
is_valid_url,
|
||||||
|
join_voice_channel,
|
||||||
|
leave_voice_channel,
|
||||||
|
queue::{TrackInfo, enqueue_tracks, pop_front},
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
chat::{create_message_response, edit_response, process_message},
|
chat::{create_message_response, edit_response, process_message},
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
@@ -113,6 +118,15 @@ pub async fn enqueue_track(
|
|||||||
|
|
||||||
playlist_items = get_ytdlp_items(track_url)?;
|
playlist_items = get_ytdlp_items(track_url)?;
|
||||||
|
|
||||||
|
// Collect TrackInfo for the queue store before borrowing `item` in the loop
|
||||||
|
let track_infos: Vec<TrackInfo> = playlist_items
|
||||||
|
.iter()
|
||||||
|
.map(|item| TrackInfo {
|
||||||
|
title: item.get_title().to_owned(),
|
||||||
|
url: item.get_url().to_owned(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Add each track to the queue
|
// Add each track to the queue
|
||||||
for item in &playlist_items {
|
for item in &playlist_items {
|
||||||
let volume = guild.volume as f32 / 100.0;
|
let volume = guild.volume as f32 / 100.0;
|
||||||
@@ -137,6 +151,10 @@ pub async fn enqueue_track(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store track metadata so the REST API can expose queue info
|
||||||
|
enqueue_tracks(guild_id.get(), track_infos);
|
||||||
|
|
||||||
if handler.queue().is_empty() {
|
if handler.queue().is_empty() {
|
||||||
let _ = handler.queue().resume();
|
let _ = handler.queue().resume();
|
||||||
}
|
}
|
||||||
@@ -204,6 +222,9 @@ struct TrackEndNotifier {
|
|||||||
impl EventHandler for TrackEndNotifier {
|
impl EventHandler for TrackEndNotifier {
|
||||||
async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option<songbird::events::Event> {
|
async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option<songbird::events::Event> {
|
||||||
if let songbird::EventContext::Track(_track_list) = ctx {
|
if let songbird::EventContext::Track(_track_list) = ctx {
|
||||||
|
// Remove the finished track from our metadata store
|
||||||
|
pop_front(self.guild_id.get());
|
||||||
|
|
||||||
if let Some(call) = self.call.get(self.guild_id) {
|
if let Some(call) = self.call.get(self.guild_id) {
|
||||||
let mut handler = call.lock().await;
|
let mut handler = call.lock().await;
|
||||||
if handler.queue().is_empty() {
|
if handler.queue().is_empty() {
|
||||||
|
|||||||
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::{
|
use crate::{
|
||||||
chat::{edit_response, process_message},
|
chat::{edit_response, process_message},
|
||||||
|
commands::audio::queue::pop_front,
|
||||||
handler::get_songbird,
|
handler::get_songbird,
|
||||||
};
|
};
|
||||||
use serenity::{
|
use serenity::{
|
||||||
all::{CommandInteraction, CreateCommand},
|
all::{CommandInteraction, CreateCommand, GuildId},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
use songbird::Songbird;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
@@ -29,17 +32,27 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Skip the track
|
// Skip the track
|
||||||
|
match skip_track(manager, guild_id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
log::debug!("<{guild_id}> Skipped the track");
|
||||||
|
edit_response(ctx, command, "Skipping the track".to_string()).await;
|
||||||
|
}
|
||||||
|
Err(err) => edit_response(ctx, command, format!("Failed to skip: {}", err)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn skip_track(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<(), String> {
|
||||||
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
|
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
|
||||||
let handler = handler_lock.lock().await;
|
let handler = handler_lock.lock().await;
|
||||||
match handler.queue().skip() {
|
handler
|
||||||
Ok(_) => {
|
.queue()
|
||||||
log::debug!("<{guild_id}> Skipped the track");
|
.skip()
|
||||||
edit_response(ctx, command, "Skipping the track".to_string()).await;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
// Pop the current track from our metadata store; the next track (if any) moves to front
|
||||||
Err(err) => {
|
pop_front(guild_id.get());
|
||||||
edit_response(ctx, command, format!("Failed to skip: {}", err)).await;
|
Ok(())
|
||||||
}
|
} else {
|
||||||
}
|
Err("No active audio session in this guild".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
chat::{edit_response, process_message},
|
chat::{edit_response, process_message},
|
||||||
|
commands::audio::queue::clear_queue,
|
||||||
handler::get_songbird,
|
handler::get_songbird,
|
||||||
};
|
};
|
||||||
use serenity::{
|
use serenity::{
|
||||||
all::{CommandInteraction, CreateCommand},
|
all::{CommandInteraction, CreateCommand, GuildId},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
use songbird::Songbird;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
||||||
// Create the initial response
|
// Create the initial response
|
||||||
@@ -29,11 +32,23 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Stop the track and clear the queue
|
// Stop the track and clear the queue
|
||||||
if let Some(handler_lock) = manager.get(guild_id) {
|
match stop_track(manager, &guild_id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
log::debug!("<{guild_id}> Stopped the track");
|
||||||
|
edit_response(ctx, command, "Stopping the tracks".to_string()).await;
|
||||||
|
}
|
||||||
|
Err(err) => edit_response(ctx, command, format!("Failed to stop: {}", err)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_track(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<(), String> {
|
||||||
|
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
|
||||||
let handler = handler_lock.lock().await;
|
let handler = handler_lock.lock().await;
|
||||||
handler.queue().stop();
|
handler.queue().stop();
|
||||||
log::debug!("<{guild_id}> Stopped the track");
|
clear_queue(guild_id.get());
|
||||||
edit_response(ctx, command, "Stopping the tracks".to_string()).await;
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("No active audio session in this guild".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::{chat::create_modal_response, commands};
|
use super::{chat::create_modal_response, commands};
|
||||||
use crate::{
|
use crate::{
|
||||||
HttpKey,
|
HttpKey,
|
||||||
commands::fun::roll::{format_roll, roll_dice, send_roll_message},
|
commands::{audio::queue::init_track_queues, fun::roll::{format_roll, roll_dice, send_roll_message}},
|
||||||
};
|
};
|
||||||
use serenity::{
|
use serenity::{
|
||||||
all::{
|
all::{
|
||||||
@@ -64,6 +64,9 @@ impl EventHandler for BotHandler {
|
|||||||
log::warn!("No ready guilds found");
|
log::warn!("No ready guilds found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialise the track-queue metadata store (idempotent)
|
||||||
|
init_track_queues();
|
||||||
|
|
||||||
if SONGBIRD.get().is_none() {
|
if SONGBIRD.get().is_none() {
|
||||||
let songbird = songbird::get(&ctx).await.unwrap();
|
let songbird = songbird::get(&ctx).await.unwrap();
|
||||||
SONGBIRD
|
SONGBIRD
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
# =========
|
# =========
|
||||||
# Builder
|
# Builder
|
||||||
# =========
|
# =========
|
||||||
FROM rust:1.94-slim-bookworm AS builder
|
FROM rust:1.87-slim-bookworm AS builder
|
||||||
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
|
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
|
||||||
--mount=type=cache,target=/target,sharing=locked \
|
--mount=type=cache,target=/app/target,sharing=locked \
|
||||||
cargo build --release --bin siren && \
|
cargo build --release --bin siren && \
|
||||||
cp /target/release/siren /siren
|
cp /app/target/release/siren /siren
|
||||||
|
|
||||||
# ==========
|
# ==========
|
||||||
# Packages
|
# Packages
|
||||||
@@ -41,8 +42,16 @@ WORKDIR /siren
|
|||||||
USER root
|
USER root
|
||||||
|
|
||||||
COPY --from=builder /siren /usr/local/bin/siren
|
COPY --from=builder /siren /usr/local/bin/siren
|
||||||
COPY --from=packages /packages /usr/bin
|
COPY --from=packages /packages/yt-dlp /usr/bin/yt-dlp
|
||||||
|
COPY --from=packages /packages/ffmpeg /usr/bin/ffmpeg
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y libc6 libc6-dev libopus-dev libpq5 libpq-dev python3-pip ffmpeg
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libc6 \
|
||||||
|
libc6-dev \
|
||||||
|
libopus-dev \
|
||||||
|
libpq5 \
|
||||||
|
libpq-dev \
|
||||||
|
python3-pip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
CMD ["siren"]
|
CMD ["siren"]
|
||||||
30
docker/Dockerfile.ui
Normal file
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
|
x-env_file: &env
|
||||||
- path: .env
|
- path: ../.env
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
x-restart: &default_restart
|
x-restart: &default_restart
|
||||||
@@ -8,6 +8,9 @@ x-restart: &default_restart
|
|||||||
name: siren
|
name: siren
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
image: siren:${SIREN_VERSION:-latest}
|
image: siren:${SIREN_VERSION:-latest}
|
||||||
container_name: siren-app
|
container_name: siren-app
|
||||||
env_file: *env
|
env_file: *env
|
||||||
@@ -18,13 +21,27 @@ services:
|
|||||||
VALKEY_PORT: 6379
|
VALKEY_PORT: 6379
|
||||||
DATA_DIR_PATH: /data
|
DATA_DIR_PATH: /data
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_DIR_PATH:-./data}:/data
|
- ${DATA_DIR_PATH:-../data}:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
profiles:
|
profiles:
|
||||||
- app
|
- app
|
||||||
<<: *default_restart
|
<<: *default_restart
|
||||||
|
|
||||||
|
ui:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.ui
|
||||||
|
image: siren-ui:${SIREN_VERSION:-latest}
|
||||||
|
container_name: siren-ui
|
||||||
|
ports:
|
||||||
|
- ${UI_PORT:-80}:80
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
profiles:
|
||||||
|
- ui
|
||||||
|
<<: *default_restart
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18.0
|
image: postgres:18.0
|
||||||
container_name: siren-postgres
|
container_name: siren-postgres
|
||||||
@@ -35,8 +52,8 @@ services:
|
|||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
PGDATA: /var/lib/postgresql/data
|
PGDATA: /var/lib/postgresql/data
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
- postgres_logs:/var/log
|
- postgres_logs:/var/log
|
||||||
ports:
|
ports:
|
||||||
- ${DATABASE_PORT:-5432}:5432
|
- ${DATABASE_PORT:-5432}:5432
|
||||||
<<: *default_restart
|
<<: *default_restart
|
||||||
32
docker/nginx.conf
Normal file
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,
|
email TEXT UNIQUE,
|
||||||
first_name TEXT,
|
first_name TEXT,
|
||||||
last_name TEXT,
|
last_name TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'banned')),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-icons": "^5.6.0"
|
"react-icons": "^5.6.0",
|
||||||
|
"react-router-dom": "^7.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
393
ui/src/App.tsx
393
ui/src/App.tsx
@@ -1,371 +1,50 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState } from "react";
|
||||||
import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import type { GridHandle } from "./components/Grid";
|
import { useAuth } from "./context/AuthContext";
|
||||||
import { api, auth } from "./api";
|
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import ControlPanel from "./components/ControlPanel";
|
import MapPage from "./pages/MapPage";
|
||||||
import ColorPanel from "./components/ColorPanel";
|
import AccountPage from "./pages/AccountPage";
|
||||||
import Grid from "./components/Grid";
|
import AdminPage from "./pages/AdminPage";
|
||||||
import LoginModal from "./components/LoginModal";
|
|
||||||
import AccountPanel from "./components/AccountPanel";
|
|
||||||
import FloatingMapControls from "./components/FloatingMapControls";
|
|
||||||
import NewMapModal from "./components/NewMapModal";
|
|
||||||
import EditMapModal from "./components/EditMapModal";
|
|
||||||
import MapListModal from "./components/MapListModal";
|
|
||||||
import "./components/Modal.css";
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
const DEFAULT_COLORS = [
|
|
||||||
"#6b7280",
|
|
||||||
"#92400e",
|
|
||||||
"#15803d",
|
|
||||||
"#1d4ed8",
|
|
||||||
"#7c3aed",
|
|
||||||
"#dc2626",
|
|
||||||
"#ca8a04",
|
|
||||||
"#0f172a",
|
|
||||||
"#f9fafb",
|
|
||||||
];
|
|
||||||
|
|
||||||
function getMapIdFromUrl(): string | null {
|
|
||||||
const match = window.location.pathname.match(/^\/map\/([^/]+)/);
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQueryParam(key: string): string | null {
|
|
||||||
return new URLSearchParams(window.location.search).get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeQueryParam(key: string) {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete(key);
|
|
||||||
window.history.replaceState(
|
|
||||||
null,
|
|
||||||
"",
|
|
||||||
url.pathname + (url.search !== "?" ? url.search : ""),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// ── Auth state ──
|
const { user, authLoading } = useAuth();
|
||||||
const [user, setUser] = useState<UserInfo | null>(null);
|
const [mapTitle, setMapTitle] = useState<string | null>(null);
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
|
||||||
|
|
||||||
// ── Map state ──
|
|
||||||
const [maps, setMaps] = useState<ListedMap[]>([]);
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
|
|
||||||
/** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */
|
|
||||||
const [directMapInfo, setDirectMapInfo] = useState<GridMap | null>(null);
|
|
||||||
/** True when the current selectedId returned 403 (no access). */
|
|
||||||
const [accessDenied, setAccessDenied] = useState(false);
|
|
||||||
const [accessRequestSent, setAccessRequestSent] = useState(false);
|
|
||||||
|
|
||||||
// ── Tool + color ──
|
|
||||||
const [tool, setTool] = useState<Tool>("pan");
|
|
||||||
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
|
|
||||||
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
|
|
||||||
const gridRef = useRef<GridHandle>(null);
|
|
||||||
|
|
||||||
// ── Modal visibility ──
|
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
||||||
const [showAccountPanel, setShowAccountPanel] = useState(false);
|
|
||||||
const [showNewMap, setShowNewMap] = useState(false);
|
|
||||||
const [showEditMap, setShowEditMap] = useState(false);
|
|
||||||
const [showMapList, setShowMapList] = useState(false);
|
|
||||||
|
|
||||||
// ── Derived ──
|
|
||||||
const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null;
|
|
||||||
const selectedMapInfo: GridMap | ListedMap | null =
|
|
||||||
selectedMapFromList ?? directMapInfo;
|
|
||||||
const isOwner =
|
|
||||||
user !== null &&
|
|
||||||
selectedMapInfo !== null &&
|
|
||||||
selectedMapInfo.owner_id === user.id;
|
|
||||||
|
|
||||||
// ── On mount: load session + handle OAuth errors ──
|
|
||||||
useEffect(() => {
|
|
||||||
auth.me().then((u) => {
|
|
||||||
setUser(u);
|
|
||||||
setAuthLoading(false);
|
|
||||||
});
|
|
||||||
const error = getQueryParam("error");
|
|
||||||
if (error) {
|
|
||||||
console.error("OAuth error:", error);
|
|
||||||
removeQueryParam("error");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Load map list after auth resolves ──
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authLoading) {
|
|
||||||
api.listMaps().then(setMaps).catch(console.error);
|
|
||||||
}
|
|
||||||
}, [user, authLoading]);
|
|
||||||
|
|
||||||
// ── Direct fetch for URL-accessed maps not in the user's list ──
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedId || authLoading) {
|
|
||||||
setDirectMapInfo(null);
|
|
||||||
setAccessDenied(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const inList = maps.some((m) => m.id === selectedId);
|
|
||||||
if (inList) {
|
|
||||||
setDirectMapInfo(null);
|
|
||||||
setAccessDenied(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDirectMapInfo(null);
|
|
||||||
setAccessDenied(false);
|
|
||||||
setAccessRequestSent(false);
|
|
||||||
|
|
||||||
api
|
|
||||||
.getMap(selectedId)
|
|
||||||
.then((state) => {
|
|
||||||
setDirectMapInfo(state.map);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
if (msg.startsWith("403")) {
|
|
||||||
setAccessDenied(true);
|
|
||||||
} else {
|
|
||||||
// 404 or unknown — clear invalid URL
|
|
||||||
setSelectedId(null);
|
|
||||||
window.history.replaceState(null, "", "/map");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// ── Keep URL in sync ──
|
|
||||||
useEffect(() => {
|
|
||||||
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map";
|
|
||||||
window.history.replaceState(null, "", path);
|
|
||||||
}, [selectedId]);
|
|
||||||
|
|
||||||
// ── Reset palette + access state when map deselected ──
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedId) {
|
|
||||||
setMapColors(DEFAULT_COLORS);
|
|
||||||
setActiveColor(DEFAULT_COLORS[0]);
|
|
||||||
setAccessRequestSent(false);
|
|
||||||
}
|
|
||||||
}, [selectedId]);
|
|
||||||
|
|
||||||
// ── Handlers ──
|
|
||||||
|
|
||||||
async function handleCreate(name: string, publicAccess: PublicAccess) {
|
|
||||||
const m = await api.createMap(name, publicAccess);
|
|
||||||
// Optimistically add to list as an owner entry
|
|
||||||
const listed: ListedMap = {
|
|
||||||
...m,
|
|
||||||
owner_username: user!.username,
|
|
||||||
user_role: "owner",
|
|
||||||
is_favorited: false,
|
|
||||||
};
|
|
||||||
setMaps((prev) => [listed, ...prev]);
|
|
||||||
setSelectedId(m.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!selectedId) return;
|
|
||||||
if (!confirm("Delete this map? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await api.deleteMap(selectedId);
|
|
||||||
setMaps((prev) => prev.filter((m) => m.id !== selectedId));
|
|
||||||
setSelectedId(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete map", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMapUpdated(updated: GridMap) {
|
|
||||||
setMaps((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === updated.id
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
name: updated.name,
|
|
||||||
public_access: updated.public_access,
|
|
||||||
updated_at: updated.updated_at,
|
|
||||||
}
|
|
||||||
: m,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (directMapInfo?.id === updated.id) {
|
|
||||||
setDirectMapInfo(updated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleColorsLoaded(colors: string[]) {
|
|
||||||
setMapColors(colors);
|
|
||||||
setActiveColor((prev) => (colors.includes(prev) ? prev : colors[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleColorsChange(colors: string[]) {
|
|
||||||
setMapColors(colors);
|
|
||||||
gridRef.current?.sendColorUpdate(colors);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUserRefresh() {
|
|
||||||
const u = await auth.me();
|
|
||||||
setUser(u);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRequestAccess(role: "viewer" | "editor") {
|
|
||||||
if (!selectedId) return;
|
|
||||||
try {
|
|
||||||
await api.requestAccess(selectedId, role);
|
|
||||||
setAccessRequestSent(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to request access", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ──
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Header
|
<Header mapTitle={mapTitle} />
|
||||||
user={user}
|
|
||||||
authLoading={authLoading}
|
|
||||||
selectedMapName={selectedMapInfo?.name ?? null}
|
|
||||||
onLoginClick={() => setShowLoginModal(true)}
|
|
||||||
onAccountClick={() => setShowAccountPanel(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
<div className="app-grid-area">
|
<Routes>
|
||||||
{/* Top-left floating map controls */}
|
<Route index element={<Navigate to="/map" replace />} />
|
||||||
<FloatingMapControls
|
<Route path="/map" element={<MapPage setMapTitle={setMapTitle} />} />
|
||||||
isLoggedIn={!!user}
|
<Route
|
||||||
hasSelectedMap={!!selectedId}
|
path="/map/:mapId"
|
||||||
isOwner={isOwner}
|
element={<MapPage setMapTitle={setMapTitle} />}
|
||||||
onNewMap={() => setShowNewMap(true)}
|
|
||||||
onViewMaps={() => setShowMapList(true)}
|
|
||||||
onEditMap={() => setShowEditMap(true)}
|
|
||||||
onDeleteMap={handleDelete}
|
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
{selectedId && !accessDenied ? (
|
path="/account"
|
||||||
<>
|
element={
|
||||||
<Grid
|
authLoading ? null : user ? (
|
||||||
key={selectedId}
|
<AccountPage />
|
||||||
ref={gridRef}
|
|
||||||
mapId={selectedId}
|
|
||||||
tool={tool}
|
|
||||||
paintColor={activeColor}
|
|
||||||
tokenColor={activeColor}
|
|
||||||
onColorsLoaded={handleColorsLoaded}
|
|
||||||
/>
|
|
||||||
<div className="floating-panels-container">
|
|
||||||
<ControlPanel tool={tool} onToolChange={setTool} />
|
|
||||||
<ColorPanel
|
|
||||||
colors={mapColors}
|
|
||||||
activeColor={activeColor}
|
|
||||||
onColorChange={setActiveColor}
|
|
||||||
onColorsChange={handleColorsChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : accessDenied ? (
|
|
||||||
<div className="access-denied-state">
|
|
||||||
<p className="access-denied-title">
|
|
||||||
You don't have access to this map
|
|
||||||
</p>
|
|
||||||
{!user ? (
|
|
||||||
<p className="access-denied-hint">
|
|
||||||
<button
|
|
||||||
className="link-btn"
|
|
||||||
onClick={() => setShowLoginModal(true)}
|
|
||||||
>
|
|
||||||
Log in
|
|
||||||
</button>{" "}
|
|
||||||
to request access or view your permissions.
|
|
||||||
</p>
|
|
||||||
) : accessRequestSent ? (
|
|
||||||
<p className="access-denied-hint access-request-sent">
|
|
||||||
✓ Access request sent! The map owner will be notified.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="access-request-actions">
|
<Navigate to="/map" replace />
|
||||||
<p className="access-denied-hint">
|
)
|
||||||
Request access from the map owner:
|
}
|
||||||
</p>
|
/>
|
||||||
<div className="access-request-btns">
|
<Route
|
||||||
<button
|
path="/admin"
|
||||||
className="btn-request-access"
|
element={
|
||||||
onClick={() => handleRequestAccess("viewer")}
|
authLoading ? null : user?.role === "admin" ? (
|
||||||
>
|
<AdminPage />
|
||||||
Request Viewer Access
|
) : (
|
||||||
</button>
|
<Navigate to="/map" replace />
|
||||||
<button
|
)
|
||||||
className="btn-request-access"
|
}
|
||||||
onClick={() => handleRequestAccess("editor")}
|
/>
|
||||||
>
|
<Route path="*" element={<Navigate to="/map" replace />} />
|
||||||
Request Editor Access
|
</Routes>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">
|
|
||||||
<p>Select or create a map to begin</p>
|
|
||||||
<p className="empty-hint">
|
|
||||||
{!user
|
|
||||||
? "Log in to create maps and access private maps"
|
|
||||||
: maps.length === 0
|
|
||||||
? 'Click "+ New Map" in the top-left to get started'
|
|
||||||
: 'Click "Maps" in the top-left to choose a map'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Global modals (always available regardless of page) ── */}
|
|
||||||
{showLoginModal && (
|
|
||||||
<LoginModal
|
|
||||||
onClose={() => setShowLoginModal(false)}
|
|
||||||
onLogin={async (u) => {
|
|
||||||
setUser(u);
|
|
||||||
api.listMaps().then(setMaps).catch(console.error);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAccountPanel && user && (
|
|
||||||
<AccountPanel
|
|
||||||
user={user}
|
|
||||||
onClose={() => setShowAccountPanel(false)}
|
|
||||||
onRefresh={handleUserRefresh}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNewMap && (
|
|
||||||
<NewMapModal
|
|
||||||
onClose={() => setShowNewMap(false)}
|
|
||||||
onCreate={handleCreate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showEditMap && selectedMapInfo && (
|
|
||||||
<EditMapModal
|
|
||||||
map={selectedMapInfo}
|
|
||||||
onClose={() => setShowEditMap(false)}
|
|
||||||
onUpdated={handleMapUpdated}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showMapList && (
|
|
||||||
<MapListModal
|
|
||||||
maps={maps}
|
|
||||||
selectedMapId={selectedId}
|
|
||||||
onSelect={(id) => setSelectedId(id)}
|
|
||||||
onClose={() => setShowMapList(false)}
|
|
||||||
onMapsChange={setMaps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AdminUser,
|
||||||
|
AudioStatus,
|
||||||
|
DiscordGuild,
|
||||||
GridMap,
|
GridMap,
|
||||||
ListedMap,
|
ListedMap,
|
||||||
MapAccessRequest,
|
MapAccessRequest,
|
||||||
@@ -230,3 +233,67 @@ export const auth = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ADMIN_BASE = "/api/admin";
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
/** List all user accounts (admin only). */
|
||||||
|
listUsers: (): Promise<AdminUser[]> =>
|
||||||
|
request<AdminUser[]>(`${ADMIN_BASE}/users`),
|
||||||
|
|
||||||
|
/** Change a user's site role (admin only). */
|
||||||
|
setUserRole: (id: string, role: "admin" | "user"): Promise<void> =>
|
||||||
|
request<void>(`${ADMIN_BASE}/users/${id}/role`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Ban a user account (admin only). */
|
||||||
|
banUser: (id: string): Promise<void> =>
|
||||||
|
request<void>(`${ADMIN_BASE}/users/${id}/ban`, { method: "PUT" }),
|
||||||
|
|
||||||
|
/** Unban a user account (admin only). */
|
||||||
|
unbanUser: (id: string): Promise<void> =>
|
||||||
|
request<void>(`${ADMIN_BASE}/users/${id}/unban`, { method: "PUT" }),
|
||||||
|
|
||||||
|
/** Permanently delete a user account (admin only). */
|
||||||
|
deleteUser: (id: string): Promise<void> =>
|
||||||
|
request<void>(`${ADMIN_BASE}/users/${id}`, { method: "DELETE" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIO_BASE = "/api/audio";
|
||||||
|
|
||||||
|
export const audioApi = {
|
||||||
|
/** List all Discord guilds the bot is currently in. */
|
||||||
|
listGuilds: (): Promise<DiscordGuild[]> =>
|
||||||
|
request<DiscordGuild[]>(`${AUDIO_BASE}/guilds`),
|
||||||
|
|
||||||
|
/** Get the current audio status for a guild (voice channel, track, queue). */
|
||||||
|
getStatus: (guildId: string): Promise<AudioStatus> =>
|
||||||
|
request<AudioStatus>(`${AUDIO_BASE}/${guildId}/status`),
|
||||||
|
|
||||||
|
/** Enqueue a track URL for playback (bot joins the caller's voice channel). */
|
||||||
|
play: (guildId: string, url: string): Promise<void> =>
|
||||||
|
request<void>(`${AUDIO_BASE}/${guildId}/play`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Pause the currently playing track. */
|
||||||
|
pause: (guildId: string): Promise<void> =>
|
||||||
|
request<void>(`${AUDIO_BASE}/${guildId}/pause`, { method: "POST" }),
|
||||||
|
|
||||||
|
/** Resume a paused track. */
|
||||||
|
resume: (guildId: string): Promise<void> =>
|
||||||
|
request<void>(`${AUDIO_BASE}/${guildId}/resume`, { method: "POST" }),
|
||||||
|
|
||||||
|
/** Stop playback and clear the queue. */
|
||||||
|
stop: (guildId: string): Promise<void> =>
|
||||||
|
request<void>(`${AUDIO_BASE}/${guildId}/stop`, { method: "POST" }),
|
||||||
|
|
||||||
|
/** Skip the current track. */
|
||||||
|
skip: (guildId: string): Promise<void> =>
|
||||||
|
request<void>(`${AUDIO_BASE}/${guildId}/skip`, { method: "POST" }),
|
||||||
|
};
|
||||||
|
|||||||
@@ -290,6 +290,24 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Page mode ── */
|
||||||
|
.account-panel-page {
|
||||||
|
width: 480px;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #2e3348;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-back-btn {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-back-btn:hover {
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Footer ── */
|
/* ── Footer ── */
|
||||||
.account-footer {
|
.account-footer {
|
||||||
border-top: 1px solid #2e3348;
|
border-top: 1px solid #2e3348;
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ interface Props {
|
|||||||
user: UserInfo;
|
user: UserInfo;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
/** "modal" (default) renders with a full-screen backdrop overlay.
|
||||||
|
* "page" renders inline for use as a standalone page. */
|
||||||
|
mode?: "modal" | "page";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
export default function AccountPanel({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onRefresh,
|
||||||
|
mode = "modal",
|
||||||
|
}: Props) {
|
||||||
const discordConnection = user.connections.find(
|
const discordConnection = user.connections.find(
|
||||||
(c) => c.provider === "discord",
|
(c) => c.provider === "discord",
|
||||||
);
|
);
|
||||||
@@ -114,15 +122,14 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
const panelContent = (
|
||||||
await auth.logout();
|
<div
|
||||||
}
|
className={`account-panel${mode === "page" ? " account-panel-page" : ""}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
return (
|
>
|
||||||
<div className="account-backdrop" onClick={onClose}>
|
<div className="account-header">
|
||||||
<div className="account-panel" onClick={(e) => e.stopPropagation()}>
|
<h2>Account</h2>
|
||||||
<div className="account-header">
|
{mode != "page" && (
|
||||||
<h2>Account</h2>
|
|
||||||
<button
|
<button
|
||||||
className="account-close"
|
className="account-close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -130,220 +137,230 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
|||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Profile ── */}
|
{/* ── Profile ── */}
|
||||||
<section className="account-section">
|
<section className="account-section">
|
||||||
<h3>Profile</h3>
|
<h3>Profile</h3>
|
||||||
<form onSubmit={handleSaveProfile} className="profile-form">
|
<form onSubmit={handleSaveProfile} className="profile-form">
|
||||||
|
<div className="account-field readonly-field">
|
||||||
|
<span className="account-label">Username</span>
|
||||||
|
<span className="account-value">{user.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className="profile-name-row">
|
||||||
|
<label className="account-field-label">
|
||||||
|
First Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => handleFirstNameChange(e.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="account-field-label">
|
||||||
|
Last Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => handleLastNameChange(e.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.email && (
|
||||||
<div className="account-field readonly-field">
|
<div className="account-field readonly-field">
|
||||||
<span className="account-label">Username</span>
|
<span className="account-label">Email</span>
|
||||||
<span className="account-value">{user.username}</span>
|
<span className="account-value">{user.email}</span>
|
||||||
</div>
|
|
||||||
<div className="profile-name-row">
|
|
||||||
<label className="account-field-label">
|
|
||||||
First Name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={firstName}
|
|
||||||
onChange={(e) => handleFirstNameChange(e.target.value)}
|
|
||||||
placeholder="Optional"
|
|
||||||
maxLength={64}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="account-field-label">
|
|
||||||
Last Name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={lastName}
|
|
||||||
onChange={(e) => handleLastNameChange(e.target.value)}
|
|
||||||
placeholder="Optional"
|
|
||||||
maxLength={64}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{user.email && (
|
{profileError && <p className="account-error">{profileError}</p>}
|
||||||
<div className="account-field readonly-field">
|
{profileSuccess && <p className="account-success">Profile saved!</p>}
|
||||||
<span className="account-label">Email</span>
|
|
||||||
<span className="account-value">{user.email}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{profileError && <p className="account-error">{profileError}</p>}
|
{profileDirty && (
|
||||||
{profileSuccess && (
|
<div className="profile-actions">
|
||||||
<p className="account-success">Profile saved!</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{profileDirty && (
|
|
||||||
<div className="profile-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-text"
|
|
||||||
onClick={() => {
|
|
||||||
setFirstName(user.first_name ?? "");
|
|
||||||
setLastName(user.last_name ?? "");
|
|
||||||
setProfileDirty(false);
|
|
||||||
setProfileError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-save"
|
|
||||||
disabled={profileSaving}
|
|
||||||
>
|
|
||||||
{profileSaving ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Password ── */}
|
|
||||||
<section className="account-section">
|
|
||||||
<div className="section-header-row">
|
|
||||||
<h3>{user.has_password ? "Password" : "Set Password"}</h3>
|
|
||||||
{!showPasswordSection && (
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="btn-text"
|
className="btn-text"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowPasswordSection(true);
|
setFirstName(user.first_name ?? "");
|
||||||
setPwError(null);
|
setLastName(user.last_name ?? "");
|
||||||
setPwSuccess(false);
|
setProfileDirty(false);
|
||||||
|
setProfileError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.has_password ? "Change" : "Set Password"}
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-save"
|
||||||
|
disabled={profileSaving}
|
||||||
|
>
|
||||||
|
{profileSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pwSuccess && !showPasswordSection && (
|
|
||||||
<p className="account-success">Password updated successfully!</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showPasswordSection && (
|
|
||||||
<form onSubmit={handleChangePassword} className="password-form">
|
|
||||||
{user.has_password && (
|
|
||||||
<label className="account-field-label">
|
|
||||||
Current Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pwCurrent}
|
|
||||||
onChange={(e) => setPwCurrent(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="account-field-label">
|
|
||||||
New Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pwNew}
|
|
||||||
onChange={(e) => setPwNew(e.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="account-field-label">
|
|
||||||
Confirm New Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pwConfirm}
|
|
||||||
onChange={(e) => setPwConfirm(e.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{pwError && <p className="account-error">{pwError}</p>}
|
|
||||||
|
|
||||||
<div className="profile-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-text"
|
|
||||||
onClick={() => {
|
|
||||||
setShowPasswordSection(false);
|
|
||||||
setPwCurrent("");
|
|
||||||
setPwNew("");
|
|
||||||
setPwConfirm("");
|
|
||||||
setPwError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-save" disabled={pwSaving}>
|
|
||||||
{pwSaving
|
|
||||||
? "Saving…"
|
|
||||||
: user.has_password
|
|
||||||
? "Update Password"
|
|
||||||
: "Set Password"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Connected services ── */}
|
|
||||||
<section className="account-section">
|
|
||||||
<h3>Connected Accounts</h3>
|
|
||||||
|
|
||||||
<div className="account-connection">
|
|
||||||
<FaDiscord />
|
|
||||||
|
|
||||||
<div className="connection-info">
|
|
||||||
<span className="connection-name">Discord</span>
|
|
||||||
{discordConnection ? (
|
|
||||||
<span className="connection-linked">
|
|
||||||
{discordConnection.provider_username ?? "Connected"}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="connection-unlinked">Not connected</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Password ── */}
|
||||||
|
<section className="account-section">
|
||||||
|
<div className="section-header-row">
|
||||||
|
<h3>{user.has_password ? "Password" : "Set Password"}</h3>
|
||||||
|
{!showPasswordSection && (
|
||||||
|
<button
|
||||||
|
className="btn-text"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasswordSection(true);
|
||||||
|
setPwError(null);
|
||||||
|
setPwSuccess(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.has_password ? "Change" : "Set Password"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pwSuccess && !showPasswordSection && (
|
||||||
|
<p className="account-success">Password updated successfully!</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPasswordSection && (
|
||||||
|
<form onSubmit={handleChangePassword} className="password-form">
|
||||||
|
{user.has_password && (
|
||||||
|
<label className="account-field-label">
|
||||||
|
Current Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pwCurrent}
|
||||||
|
onChange={(e) => setPwCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="account-field-label">
|
||||||
|
New Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pwNew}
|
||||||
|
onChange={(e) => setPwNew(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="account-field-label">
|
||||||
|
Confirm New Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pwConfirm}
|
||||||
|
onChange={(e) => setPwConfirm(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{pwError && <p className="account-error">{pwError}</p>}
|
||||||
|
|
||||||
|
<div className="profile-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-text"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasswordSection(false);
|
||||||
|
setPwCurrent("");
|
||||||
|
setPwNew("");
|
||||||
|
setPwConfirm("");
|
||||||
|
setPwError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-save" disabled={pwSaving}>
|
||||||
|
{pwSaving
|
||||||
|
? "Saving…"
|
||||||
|
: user.has_password
|
||||||
|
? "Update Password"
|
||||||
|
: "Set Password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Connected services ── */}
|
||||||
|
<section className="account-section">
|
||||||
|
<h3>Connected Accounts</h3>
|
||||||
|
|
||||||
|
<div className="account-connection">
|
||||||
|
<FaDiscord />
|
||||||
|
|
||||||
|
<div className="connection-info">
|
||||||
|
<span className="connection-name">Discord</span>
|
||||||
{discordConnection ? (
|
{discordConnection ? (
|
||||||
<button
|
<span className="connection-linked">
|
||||||
className="btn-disconnect"
|
{discordConnection.provider_username ?? "Connected"}
|
||||||
onClick={handleDisconnectDiscord}
|
</span>
|
||||||
disabled={!user.has_password}
|
|
||||||
title={
|
|
||||||
!user.has_password
|
|
||||||
? "Set a password first before disconnecting Discord"
|
|
||||||
: "Disconnect Discord"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<span className="connection-unlinked">Not connected</span>
|
||||||
className="btn-connect-discord"
|
|
||||||
onClick={handleConnectDiscord}
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{discordConnection && !user.has_password && (
|
{discordConnection ? (
|
||||||
<p className="connection-hint">
|
<button
|
||||||
Set a password above before disconnecting Discord to avoid being
|
className="btn-disconnect"
|
||||||
locked out.
|
onClick={handleDisconnectDiscord}
|
||||||
</p>
|
disabled={!user.has_password}
|
||||||
|
title={
|
||||||
|
!user.has_password
|
||||||
|
? "Set a password first before disconnecting Discord"
|
||||||
|
: "Disconnect Discord"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn-connect-discord"
|
||||||
|
onClick={handleConnectDiscord}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
{discordConnection && !user.has_password && (
|
||||||
|
<p className="connection-hint">
|
||||||
|
Set a password above before disconnecting Discord to avoid being
|
||||||
|
locked out.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Footer (modal-only logout) ── */}
|
||||||
|
{mode === "modal" && (
|
||||||
<div className="account-footer">
|
<div className="account-footer">
|
||||||
<button className="btn-logout" onClick={handleLogout}>
|
<button className="btn-logout" onClick={() => auth.logout()}>
|
||||||
Log Out
|
Log Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mode === "page") {
|
||||||
|
return panelContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="account-backdrop" onClick={onClose}>
|
||||||
|
{panelContent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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");
|
const nonOwnerPerms = permissions.filter((p) => p.role !== "owner");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
<div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
|
||||||
<div
|
<div
|
||||||
className="modal edit-map-modal"
|
className="modal edit-map-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -46,3 +46,52 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Account dropdown ── */
|
||||||
|
.account-dropdown-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
background: #1f2937;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 200;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-dropdown-item:hover {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #374151;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-dropdown-logout {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-dropdown-logout:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,127 @@
|
|||||||
import type { UserInfo } from "../types";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { auth } from "../api";
|
||||||
import LoginButton from "./LoginButton";
|
import LoginButton from "./LoginButton";
|
||||||
|
import LoginModal from "./LoginModal";
|
||||||
import "./Header.css";
|
import "./Header.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: UserInfo | null;
|
mapTitle: string | null;
|
||||||
authLoading: boolean;
|
|
||||||
selectedMapName: string | null;
|
|
||||||
onLoginClick: () => void;
|
|
||||||
onAccountClick: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({ mapTitle }: Props) {
|
||||||
user,
|
const { user, authLoading, setUser } = useAuth();
|
||||||
authLoading,
|
const navigate = useNavigate();
|
||||||
selectedMapName,
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
onLoginClick,
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
onAccountClick,
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
}: Props) {
|
|
||||||
/** Display name: first name if set, otherwise username */
|
/** Display name: first name if set, otherwise username */
|
||||||
const displayName = user ? user.first_name?.trim() || user.username : null;
|
const displayName = user ? user.first_name?.trim() || user.username : null;
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dropdownOpen) return;
|
||||||
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleOutsideClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
|
}, [dropdownOpen]);
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
await auth.logout();
|
||||||
|
setUser(null);
|
||||||
|
navigate("/map");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="app-brand">
|
<div
|
||||||
|
className="app-brand"
|
||||||
|
onClick={() => navigate("/map")}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
<span>SIREN</span>
|
<span>SIREN</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="app-header-center">
|
<div className="app-header-center">
|
||||||
{selectedMapName && (
|
{mapTitle && <span className="header-map-name">{mapTitle}</span>}
|
||||||
<span className="header-map-name">{selectedMapName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="app-auth">
|
<div className="app-auth">
|
||||||
{!authLoading &&
|
{!authLoading &&
|
||||||
(user ? (
|
(user ? (
|
||||||
<button
|
<div className="account-dropdown-wrapper" ref={dropdownRef}>
|
||||||
className="header-btn"
|
<button
|
||||||
onClick={onAccountClick}
|
className="header-btn"
|
||||||
title="Account settings"
|
onClick={() => setDropdownOpen((o) => !o)}
|
||||||
>
|
aria-haspopup="true"
|
||||||
{displayName}
|
aria-expanded={dropdownOpen}
|
||||||
</button>
|
title="Account"
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div className="account-dropdown">
|
||||||
|
<button
|
||||||
|
className="account-dropdown-item"
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/account");
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{user.role === "admin" && (
|
||||||
|
<button
|
||||||
|
className="account-dropdown-item"
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/admin");
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Admin Dashboard
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="account-dropdown-divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="account-dropdown-item account-dropdown-logout"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<LoginButton className="header-btn" onClick={onLoginClick} />
|
<LoginButton
|
||||||
|
className="header-btn"
|
||||||
|
onClick={() => setShowLoginModal(true)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showLoginModal && (
|
||||||
|
<LoginModal
|
||||||
|
onClose={() => setShowLoginModal(false)}
|
||||||
|
onLogin={(u) => {
|
||||||
|
setUser(u);
|
||||||
|
setShowLoginModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,8 +91,12 @@ export default function MapListModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
<div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
|
||||||
<div
|
<div
|
||||||
className="modal map-list-modal"
|
className="modal map-list-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -106,3 +106,14 @@
|
|||||||
.header-btn:hover {
|
.header-btn:hover {
|
||||||
background: #4b5563;
|
background: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admin variant — subtle purple accent */
|
||||||
|
.header-btn-admin {
|
||||||
|
border-color: rgba(139, 92, 246, 0.45);
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
.header-btn-admin:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
border-color: #7c3aed;
|
||||||
|
color: #ddd6fe;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ export default function NewMapModal({ onClose, onCreate }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
<div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
|
||||||
<div className="modal new-map-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal new-map-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>New Map</h2>
|
<h2>New Map</h2>
|
||||||
@@ -51,7 +55,7 @@ export default function NewMapModal({ onClose, onCreate }: Props) {
|
|||||||
<input
|
<input
|
||||||
ref={nameRef}
|
ref={nameRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="My awesome map…"
|
placeholder="e.g. Ravenloft"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
|
|||||||
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 React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
import { AuthProvider } from "./context/AuthContext.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
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). */
|
/** True when the account has a local password (can log in without OAuth). */
|
||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
connections: ConnectionInfo[];
|
connections: ConnectionInfo[];
|
||||||
|
/** Site-level role: "admin" | "user" */
|
||||||
|
role: "admin" | "user";
|
||||||
|
/** Account status: "active" | "banned" */
|
||||||
|
status: "active" | "banned";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User record returned by the admin user list endpoint. */
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string; // UUID
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
role: "admin" | "user";
|
||||||
|
status: "active" | "banned";
|
||||||
|
created_at: string; // ISO datetime
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MapRole = "owner" | "editor" | "viewer";
|
export type MapRole = "owner" | "editor" | "viewer";
|
||||||
@@ -82,6 +96,25 @@ export interface MapAccessRequest {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Discord / Audio ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DiscordGuild {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackInfo {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioStatus {
|
||||||
|
voice_channel: string | null;
|
||||||
|
is_paused: boolean;
|
||||||
|
current_track: TrackInfo | null;
|
||||||
|
queue: TrackInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
export type Tool = "pan" | "zoom" | "draw" | "token";
|
export type Tool = "pan" | "zoom" | "draw" | "token";
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
|
|||||||
Reference in New Issue
Block a user