Formatting and cleanup
This commit is contained in:
59
.env.example
59
.env.example
@@ -1,40 +1,67 @@
|
|||||||
|
# -----------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Rust log filter directive (e.g. warn,siren=info)
|
||||||
RUST_LOG=warn,siren=info
|
RUST_LOG=warn,siren=info
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Discord
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Bot token from the Discord Developer Portal → Bot tab → Reset Token
|
||||||
DISCORD_BOT_TOKEN=
|
DISCORD_BOT_TOKEN=
|
||||||
|
# OAuth2 client secret from the Discord Developer Portal → OAuth2 tab
|
||||||
DISCORD_CLIENT_SECRET=
|
DISCORD_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Security
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Secret used to sign JWT tokens — change this before deploying
|
||||||
JWT_SECRET=changeme
|
JWT_SECRET=changeme
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Database
|
||||||
|
# -----------------------------------------------------------
|
||||||
POSTGRES_USER=siren
|
POSTGRES_USER=siren
|
||||||
POSTGRES_PASSWORD=changeme
|
POSTGRES_PASSWORD=changeme
|
||||||
POSTGRES_DB=siren_db
|
POSTGRES_DB=siren_db
|
||||||
|
# Use "siren-postgres" when running inside Docker Compose
|
||||||
POSTGRES_HOST=localhost
|
POSTGRES_HOST=localhost
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Cache (Valkey)
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Use "siren-valkey" when running inside Docker Compose
|
||||||
|
VALKEY_HOST=localhost
|
||||||
|
VALKEY_PORT=6379
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# API
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Base URL of the REST API (used to build OAuth2 redirect URIs, etc.)
|
||||||
API_BASE_URL=http://localhost:3000
|
API_BASE_URL=http://localhost:3000
|
||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
|
# OAuth2 session TTL in seconds
|
||||||
API_SESSION_TTL=86400
|
API_SESSION_TTL=86400
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# UI
|
||||||
|
# -----------------------------------------------------------
|
||||||
# Set to a specific origin (e.g. https://yourapp.com) when deploying to
|
# Set to a specific origin (e.g. https://yourapp.com) when deploying to
|
||||||
# production with a separate frontend origin. Use "*" (the default) in
|
# production with a separate frontend origin. Use "*" (the default) in
|
||||||
# development with the Vite proxy, where CORS is not an issue.
|
# development with the Vite proxy, where CORS is not an issue.
|
||||||
CORS_ORIGIN=*
|
CORS_ORIGIN=*
|
||||||
|
# Port the UI dev server (Vite) listens on
|
||||||
|
UI_PORT=5173
|
||||||
|
|
||||||
UI_PORT=8080
|
# -----------------------------------------------------------
|
||||||
|
# Bot
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Re-register slash commands with Discord on every startup
|
||||||
|
FORCE_COMMAND_REGISTER=false
|
||||||
|
|
||||||
VALKEY_HOST=localhost
|
# -----------------------------------------------------------
|
||||||
VALKEY_PORT=6379
|
# Data (Optional)
|
||||||
|
# -----------------------------------------------------------
|
||||||
MINIO_ROOT_USER=siren
|
# Path to a local directory for optional Siren Data integration
|
||||||
MINIO_ROOT_PASSWORD=changeme
|
|
||||||
MINIO_HOST=localhost
|
|
||||||
MINIO_PORT=9000
|
|
||||||
MINIO_PORT_INTERNAL=9001
|
|
||||||
|
|
||||||
# Siren Data integration (Optional)
|
|
||||||
DATA_DIR_PATH=./data
|
DATA_DIR_PATH=./data
|
||||||
|
|
||||||
FORCE_REGISTER=false
|
|
||||||
DEFAULT_API_KEY=test_api_key
|
|
||||||
DEFAULT_SERVER=
|
|
||||||
DEFAULT_USER=
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -52,7 +52,7 @@ task setup
|
|||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|-------------------------|----------|-------------------------------------------------------------------------|
|
|--------------------------|----------|---------------------------------------------------------------------------|
|
||||||
| `DISCORD_BOT_TOKEN` | Yes | Bot token from the Discord Developer Portal |
|
| `DISCORD_BOT_TOKEN` | Yes | Bot token from the Discord Developer Portal |
|
||||||
| `DISCORD_CLIENT_SECRET` | Yes | OAuth2 client secret |
|
| `DISCORD_CLIENT_SECRET` | Yes | OAuth2 client secret |
|
||||||
| `JWT_SECRET` | Yes | Secret used to sign JWT tokens — change from default |
|
| `JWT_SECRET` | Yes | Secret used to sign JWT tokens — change from default |
|
||||||
@@ -60,18 +60,20 @@ task setup
|
|||||||
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password — change from default |
|
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password — change from default |
|
||||||
| `POSTGRES_DB` | Yes | PostgreSQL database name |
|
| `POSTGRES_DB` | Yes | PostgreSQL database name |
|
||||||
| `POSTGRES_HOST` | Yes | PostgreSQL host (`localhost` for local dev, `siren-postgres` in Docker) |
|
| `POSTGRES_HOST` | Yes | PostgreSQL host (`localhost` for local dev, `siren-postgres` in Docker) |
|
||||||
| `POSTGRES_PORT` | Yes | PostgreSQL port (default `5432`) |
|
| `POSTGRES_PORT` | | PostgreSQL port (default `5432`) |
|
||||||
| `VALKEY_HOST` | Yes | Valkey host (`localhost` for local dev, `siren-valkey` in Docker) |
|
| `VALKEY_HOST` | | Valkey host (`localhost` for local dev, `siren-valkey` in Docker) |
|
||||||
| `VALKEY_PORT` | Yes | Valkey port (default `6379`) |
|
| `VALKEY_PORT` | | Valkey port (default `6379`) |
|
||||||
| `API_PORT` | Yes | Port the REST API listens on (default `3000`) |
|
| `API_BASE_URL` | Yes | Base URL of the API (e.g. `http://localhost:3000`) |
|
||||||
| `API_CALLBACK_URI` | Yes | OAuth2 redirect URI (e.g. `http://localhost:3000/api/oauth/callback`) |
|
| `API_PORT` | | Port the REST API listens on (default `3000`) |
|
||||||
| `API_SESSION_TTL` | | OAuth2 session TTL in seconds (default `86400`) |
|
| `API_SESSION_TTL` | | OAuth2 session TTL in seconds (default `86400`) |
|
||||||
|
| `CORS_ORIGIN` | | Allowed CORS origin (`*` for dev, specific URL for production) |
|
||||||
|
| `UI_PORT` | | Port the UI dev server listens on (default `5173`) |
|
||||||
| `RUST_LOG` | | Log filter (e.g. `warn,siren=info`) |
|
| `RUST_LOG` | | Log filter (e.g. `warn,siren=info`) |
|
||||||
| `FORCE_REGISTER` | | Re-register slash commands on every startup (`true`/`false`) |
|
| `FORCE_COMMAND_REGISTER` | | Re-register slash commands with Discord on every startup (`true`/`false`) |
|
||||||
| `DATA_DIR_PATH` | | Path to optional local data directory |
|
| `DATA_DIR_PATH` | | Path to optional local data directory |
|
||||||
| `DEFAULT_API_KEY` | | Seed API key created on startup |
|
| `DEFAULT_API_KEY` | | Seed API key created on startup |
|
||||||
| `DEFAULT_SERVER` | | Seed guild ID |
|
| `DEFAULT_GUILD_ID` | | Seed Discord guild (server) ID |
|
||||||
| `DEFAULT_USER` | | Seed user ID |
|
| `DEFAULT_USER_ID` | | Seed Discord user ID |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ tasks:
|
|||||||
ngrok:
|
ngrok:
|
||||||
desc: Start ngrok tunnel
|
desc: Start ngrok tunnel
|
||||||
vars:
|
vars:
|
||||||
UI_PORT: '{{.UI_PORT | default "8080"}}'
|
UI_PORT: '{{.UI_PORT | default "5173"}}'
|
||||||
cmds:
|
cmds:
|
||||||
- ngrok http {{.UI_PORT}}
|
- ngrok http {{.UI_PORT}}
|
||||||
silent: true
|
silent: true
|
||||||
|
|||||||
@@ -292,9 +292,6 @@ async fn do_oauth_callback(
|
|||||||
Ok(Redirect::temporary(&ui_redirect_uri).into_response())
|
Ok(Redirect::temporary(&ui_redirect_uri).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
// LOGIN MODE: look up (or create) the local user for this Discord account
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
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)> = sqlx::query_as(
|
||||||
@@ -403,10 +400,6 @@ async fn do_oauth_callback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Return a username derived from `base` that does not yet exist in `users`.
|
/// Return a username derived from `base` that does not yet exist in `users`.
|
||||||
async fn generate_unique_username(pool: &sqlx::PgPool, base: &str) -> crate::error::Result<String> {
|
async fn generate_unique_username(pool: &sqlx::PgPool, base: &str) -> crate::error::Result<String> {
|
||||||
// Truncate to 28 chars to leave room for the `_XXXX` suffix
|
// Truncate to 28 chars to leave room for the `_XXXX` suffix
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ pub fn get_routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/connections/{provider}", delete(disconnect_provider))
|
.route("/connections/{provider}", delete(disconnect_provider))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Payloads
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RegisterPayload {
|
struct RegisterPayload {
|
||||||
username: String,
|
username: String,
|
||||||
@@ -71,10 +67,6 @@ struct ChangePasswordPayload {
|
|||||||
new_password: String,
|
new_password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ConnectionInfo {
|
pub struct ConnectionInfo {
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
@@ -95,10 +87,6 @@ pub struct UserInfo {
|
|||||||
pub connections: Vec<ConnectionInfo>,
|
pub connections: Vec<ConnectionInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DB row types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct DbUser {
|
struct DbUser {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -116,10 +104,6 @@ struct DbConnection {
|
|||||||
provider_avatar: Option<String>,
|
provider_avatar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Password helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Hash and salt a plaintext password with Argon2.
|
/// Hash and salt a plaintext password with Argon2.
|
||||||
pub fn hash_password(password: &str) -> Result<String> {
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
@@ -139,10 +123,6 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cookie / session helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Build the `siren_session` HttpOnly Secure cookie.
|
/// Build the `siren_session` HttpOnly Secure cookie.
|
||||||
pub fn build_session_cookie(token: String) -> Cookie<'static> {
|
pub fn build_session_cookie(token: String) -> Cookie<'static> {
|
||||||
Cookie::build(("siren_session", token))
|
Cookie::build(("siren_session", token))
|
||||||
@@ -192,10 +172,6 @@ pub async fn create_session_and_cookie(
|
|||||||
Ok((jar, ()))
|
Ok((jar, ()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: load full UserInfo for a given user_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
||||||
let pool = data::pool();
|
let pool = data::pool();
|
||||||
|
|
||||||
@@ -232,10 +208,6 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async fn register(
|
async fn register(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
@@ -245,12 +217,6 @@ async fn register(
|
|||||||
if username.is_empty() || username.len() > 32 {
|
if username.is_empty() || username.len() > 32 {
|
||||||
return Err(Error::new(422, "Username must be 1–32 characters".into()));
|
return Err(Error::new(422, "Username must be 1–32 characters".into()));
|
||||||
}
|
}
|
||||||
if payload.password.len() < 8 {
|
|
||||||
return Err(Error::new(
|
|
||||||
422,
|
|
||||||
"Password must be at least 8 characters".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = data::pool();
|
let pool = data::pool();
|
||||||
|
|
||||||
@@ -402,13 +368,6 @@ async fn change_password(
|
|||||||
) -> Result<StatusCode> {
|
) -> Result<StatusCode> {
|
||||||
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||||
|
|
||||||
if payload.new_password.len() < 8 {
|
|
||||||
return Err(Error::new(
|
|
||||||
422,
|
|
||||||
"New password must be at least 8 characters".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = data::pool();
|
let pool = data::pool();
|
||||||
|
|
||||||
let existing_hash: Option<String> =
|
let existing_hash: Option<String> =
|
||||||
|
|||||||
@@ -61,10 +61,6 @@ pub fn get_routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/maps/{id}/ws", get(ws_handler))
|
.route("/maps/{id}/ws", get(ws_handler))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Access helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Fetch the role of `user_id` on `map_id`, or `None` if no record exists.
|
/// Fetch the role of `user_id` on `map_id`, or `None` if no record exists.
|
||||||
async fn get_user_role(map_id: &str, user_id: Uuid) -> Result<Option<MapRole>> {
|
async fn get_user_role(map_id: &str, user_id: Uuid) -> Result<Option<MapRole>> {
|
||||||
let pool = siren_core::data::pool();
|
let pool = siren_core::data::pool();
|
||||||
@@ -120,10 +116,6 @@ async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Map CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn list_maps(
|
pub async fn list_maps(
|
||||||
SessionAuthorization(session): SessionAuthorization,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
) -> Result<Json<Vec<ListedMap>>> {
|
) -> Result<Json<Vec<ListedMap>>> {
|
||||||
@@ -287,10 +279,6 @@ pub async fn delete_map(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Permission management
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn list_permissions(
|
pub async fn list_permissions(
|
||||||
SessionAuthorization(session): SessionAuthorization,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
@@ -429,10 +417,6 @@ pub async fn unfavorite_map(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Access Requests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn create_access_request(
|
pub async fn create_access_request(
|
||||||
SessionAuthorization(session): SessionAuthorization,
|
SessionAuthorization(session): SessionAuthorization,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
@@ -572,10 +556,6 @@ pub async fn resolve_access_request(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WebSocket handler
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub async fn ws_handler(
|
pub async fn ws_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Map Role / Permission
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, sqlx::Type, Clone, Debug, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, sqlx::Type, Clone, Debug, PartialEq, Eq)]
|
||||||
#[sqlx(type_name = "text", rename_all = "lowercase")]
|
#[sqlx(type_name = "text", rename_all = "lowercase")]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -36,10 +32,6 @@ pub struct PermissionWithUser {
|
|||||||
pub role: MapRole,
|
pub role: MapRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Grid Map
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Core map record as stored/returned by create, get, and update endpoints.
|
/// Core map record as stored/returned by create, get, and update endpoints.
|
||||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
||||||
pub struct GridMap {
|
pub struct GridMap {
|
||||||
@@ -49,8 +41,8 @@ pub struct GridMap {
|
|||||||
pub public_access: String,
|
pub public_access: String,
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
pub colors: Vec<String>,
|
pub colors: Vec<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extended map record returned by the list endpoint.
|
/// Extended map record returned by the list endpoint.
|
||||||
@@ -64,8 +56,8 @@ pub struct ListedMap {
|
|||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
pub owner_username: String,
|
pub owner_username: String,
|
||||||
pub colors: Vec<String>,
|
pub colors: Vec<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: DateTime<Utc>,
|
||||||
/// The authenticated caller's role on this map, or NULL if they only have it
|
/// The authenticated caller's role on this map, or NULL if they only have it
|
||||||
/// via a favorite (no explicit permission).
|
/// via a favorite (no explicit permission).
|
||||||
pub user_role: Option<MapRole>,
|
pub user_role: Option<MapRole>,
|
||||||
@@ -98,10 +90,6 @@ pub struct UpdatePermissionPayload {
|
|||||||
pub role: Option<MapRole>,
|
pub role: Option<MapRole>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Map Access Requests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// An access-request row joined with the requesting user's username.
|
/// An access-request row joined with the requesting user's username.
|
||||||
#[derive(Serialize, sqlx::FromRow, Clone, Debug)]
|
#[derive(Serialize, sqlx::FromRow, Clone, Debug)]
|
||||||
pub struct AccessRequestWithUser {
|
pub struct AccessRequestWithUser {
|
||||||
@@ -111,8 +99,8 @@ pub struct AccessRequestWithUser {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub requested_role: MapRole,
|
pub requested_role: MapRole,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
@@ -126,10 +114,6 @@ pub struct ResolveAccessRequestPayload {
|
|||||||
pub action: String,
|
pub action: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Grid Cell (no id column — composite PK in DB)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
||||||
pub struct GridCell {
|
pub struct GridCell {
|
||||||
pub map_id: String,
|
pub map_id: String,
|
||||||
@@ -146,10 +130,6 @@ pub struct CellPatch {
|
|||||||
pub color: String,
|
pub color: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Grid Token
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
||||||
pub struct GridToken {
|
pub struct GridToken {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -160,10 +140,6 @@ pub struct GridToken {
|
|||||||
pub color: String,
|
pub color: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Full map state (used on initial WS connect and REST GET)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct MapState {
|
pub struct MapState {
|
||||||
pub map: GridMap,
|
pub map: GridMap,
|
||||||
@@ -171,10 +147,6 @@ pub struct MapState {
|
|||||||
pub tokens: Vec<GridToken>,
|
pub tokens: Vec<GridToken>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WebSocket message types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ClientMessage {
|
pub enum ClientMessage {
|
||||||
|
|||||||
@@ -16,16 +16,8 @@ pub struct EnvironmentConfiguration {
|
|||||||
pub api_session_ttl: u64,
|
pub api_session_ttl: u64,
|
||||||
pub valkey_host: String,
|
pub valkey_host: String,
|
||||||
pub valkey_port: u16,
|
pub valkey_port: u16,
|
||||||
pub minio_root_user: String,
|
|
||||||
pub minio_root_password: String,
|
|
||||||
pub minio_host: String,
|
|
||||||
pub minio_port: u16,
|
|
||||||
pub minio_port_internal: u16,
|
|
||||||
pub data_dir_path: Option<String>,
|
pub data_dir_path: Option<String>,
|
||||||
pub force_register: bool,
|
pub force_command_register: bool,
|
||||||
pub default_api_key: String,
|
|
||||||
pub default_server: Option<String>,
|
|
||||||
pub default_user: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnvironmentConfiguration {
|
impl EnvironmentConfiguration {
|
||||||
@@ -57,25 +49,11 @@ impl EnvironmentConfiguration {
|
|||||||
.unwrap_or_else(|_| "6379".to_string())
|
.unwrap_or_else(|_| "6379".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(6379),
|
.unwrap_or(6379),
|
||||||
minio_root_user: env::var("MINIO_ROOT_USER")?,
|
|
||||||
minio_root_password: env::var("MINIO_ROOT_PASSWORD")?,
|
|
||||||
minio_host: env::var("MINIO_HOST").unwrap_or_else(|_| "localhost".to_string()),
|
|
||||||
minio_port: env::var("MINIO_PORT")
|
|
||||||
.unwrap_or_else(|_| "9000".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(9000),
|
|
||||||
minio_port_internal: env::var("MINIO_PORT_INTERNAL")
|
|
||||||
.unwrap_or_else(|_| "9001".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(9001),
|
|
||||||
data_dir_path: env::var("DATA_DIR_PATH").ok().filter(|s| !s.is_empty()),
|
data_dir_path: env::var("DATA_DIR_PATH").ok().filter(|s| !s.is_empty()),
|
||||||
force_register: env::var("FORCE_REGISTER")
|
force_command_register: env::var("FORCE_COMMAND_REGISTER")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|v| v.to_lowercase() == "true")
|
.map(|v| v.to_lowercase() == "true")
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
default_api_key: env::var("DEFAULT_API_KEY").unwrap_or_default(),
|
|
||||||
default_server: env::var("DEFAULT_SERVER").ok().filter(|s| !s.is_empty()),
|
|
||||||
default_user: env::var("DEFAULT_USER").ok().filter(|s| !s.is_empty()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
mod model;
|
|
||||||
|
|
||||||
pub use model::*;
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
use crate::error::Result;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
const TABLE_NAME: &str = "messages";
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageCache {
|
|
||||||
pub id: String,
|
|
||||||
pub guild_id: i64,
|
|
||||||
pub channel_id: i64,
|
|
||||||
pub author_id: i64,
|
|
||||||
pub created: i64,
|
|
||||||
pub model: String,
|
|
||||||
pub request: String,
|
|
||||||
pub response: String,
|
|
||||||
pub request_tags: Vec<String>,
|
|
||||||
pub response_tags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MessageCache {
|
|
||||||
pub async fn insert(&self) -> Result<()> {
|
|
||||||
let pool = crate::data::pool();
|
|
||||||
sqlx::query(&format!(
|
|
||||||
"INSERT INTO {} (
|
|
||||||
id,
|
|
||||||
guild_id,
|
|
||||||
channel_id,
|
|
||||||
author_id,
|
|
||||||
created,
|
|
||||||
model,
|
|
||||||
request,
|
|
||||||
response,
|
|
||||||
request_tags,
|
|
||||||
response_tags
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
||||||
)",
|
|
||||||
TABLE_NAME
|
|
||||||
))
|
|
||||||
.bind(&self.id)
|
|
||||||
.bind(self.guild_id)
|
|
||||||
.bind(self.channel_id)
|
|
||||||
.bind(self.author_id)
|
|
||||||
.bind(self.created)
|
|
||||||
.bind(&self.model)
|
|
||||||
.bind(&self.request)
|
|
||||||
.bind(&self.response)
|
|
||||||
.bind(&self.request_tags)
|
|
||||||
.bind(&self.response_tags)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn find(
|
|
||||||
guild_id: i64,
|
|
||||||
channel_id: i64,
|
|
||||||
author_id: i64,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<MessageCache>> {
|
|
||||||
let pool = crate::data::pool();
|
|
||||||
let messages = sqlx::query_as::<_, MessageCache>(&format!(
|
|
||||||
"SELECT * FROM {} WHERE guild_id = $1 AND channel_id = $2 AND author_id = $3 ORDER BY created ASC LIMIT $4",
|
|
||||||
TABLE_NAME
|
|
||||||
))
|
|
||||||
.bind(guild_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(author_id)
|
|
||||||
.bind(limit)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(messages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ pub mod events;
|
|||||||
mod executable_query;
|
mod executable_query;
|
||||||
pub mod guilds;
|
pub mod guilds;
|
||||||
pub mod insert;
|
pub mod insert;
|
||||||
pub mod messages;
|
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
use crate::config::EnvironmentConfiguration;
|
use crate::config::EnvironmentConfiguration;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
|||||||
let config = EnvironmentConfiguration::load()?;
|
let config = EnvironmentConfiguration::load()?;
|
||||||
siren_core::data::initialize(&config).await?;
|
siren_core::data::initialize(&config).await?;
|
||||||
|
|
||||||
let handler = BotHandler::new(config.force_register);
|
let handler = BotHandler::new(config.force_command_register);
|
||||||
let songbird = Songbird::serenity();
|
let songbird = Songbird::serenity();
|
||||||
let intents: GatewayIntents = GatewayIntents::all();
|
let intents: GatewayIntents = GatewayIntents::all();
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS events (
|
|||||||
guild_id BIGINT NOT NULL,
|
guild_id BIGINT NOT NULL,
|
||||||
author_id BIGINT NOT NULL,
|
author_id BIGINT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
date_time TIMESTAMP NOT NULL,
|
date_time TIMESTAMPTZ NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
rsvp BIGINT[] NOT NULL
|
rsvp BIGINT[] NOT NULL
|
||||||
);
|
);
|
||||||
@@ -78,8 +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,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- External OAuth provider connections (Discord, etc.)
|
-- External OAuth provider connections (Discord, etc.)
|
||||||
@@ -93,14 +93,6 @@ CREATE TABLE IF NOT EXISTS user_connections (
|
|||||||
UNIQUE (provider, provider_user_id)
|
UNIQUE (provider, provider_user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- Grid maps: unbounded canvas, CSPRNG TEXT ids, auth-aware
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- public_access: 'private' | 'public_view' | 'public_edit'
|
|
||||||
-- private – only users with explicit map_permissions can see/edit
|
|
||||||
-- public_view – anyone with the link can view; only permissioned users can edit
|
|
||||||
-- public_edit – anyone with the link can view AND edit
|
|
||||||
CREATE TABLE IF NOT EXISTS grid_maps (
|
CREATE TABLE IF NOT EXISTS grid_maps (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -118,8 +110,8 @@ CREATE TABLE IF NOT EXISTS grid_maps (
|
|||||||
'#0f172a',
|
'#0f172a',
|
||||||
'#f9fafb'
|
'#f9fafb'
|
||||||
],
|
],
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Per-map role assignments; owner is auto-inserted on map creation
|
-- Per-map role assignments; owner is auto-inserted on map creation
|
||||||
@@ -135,7 +127,7 @@ CREATE TABLE IF NOT EXISTS map_permissions (
|
|||||||
CREATE TABLE IF NOT EXISTS map_favorites (
|
CREATE TABLE IF NOT EXISTS map_favorites (
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
PRIMARY KEY (user_id, map_id)
|
PRIMARY KEY (user_id, map_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -146,8 +138,8 @@ CREATE TABLE IF NOT EXISTS map_access_requests (
|
|||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')),
|
requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')),
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (map_id, user_id)
|
UNIQUE (map_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { auth } from "../api";
|
import { auth } from "../api";
|
||||||
import type { UserInfo } from "../types";
|
import type { UserInfo } from "../types";
|
||||||
import "./AccountPanel.css";
|
import "./AccountPanel.css";
|
||||||
|
import { FaDiscord } from "react-icons/fa6";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: UserInfo;
|
user: UserInfo;
|
||||||
@@ -63,7 +64,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleChangePassword(e: React.FormEvent) {
|
async function handleChangePassword(e: React.SubmitEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPwError(null);
|
setPwError(null);
|
||||||
setPwSuccess(false);
|
setPwSuccess(false);
|
||||||
@@ -72,10 +73,6 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
|||||||
setPwError("Passwords do not match");
|
setPwError("Passwords do not match");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pwNew.length < 8) {
|
|
||||||
setPwError("Password must be at least 8 characters");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPwSaving(true);
|
setPwSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -296,13 +293,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
|||||||
<h3>Connected Accounts</h3>
|
<h3>Connected Accounts</h3>
|
||||||
|
|
||||||
<div className="account-connection">
|
<div className="account-connection">
|
||||||
<svg
|
<FaDiscord />
|
||||||
className="connection-icon discord-icon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="connection-info">
|
<div className="connection-info">
|
||||||
<span className="connection-name">Discord</span>
|
<span className="connection-name">Discord</span>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function FloatingMapControls({
|
|||||||
return (
|
return (
|
||||||
<div className="floating-map-controls">
|
<div className="floating-map-controls">
|
||||||
{/* Always visible for logged-in users */}
|
{/* Always visible for logged-in users */}
|
||||||
<button className="fmc-btn" onClick={onViewMaps} title="View my maps">
|
<button className="fmc-btn" onClick={onViewMaps} title="View maps">
|
||||||
<svg
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
height="14"
|
height="14"
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ import { useWebSocket } from "../hooks/useWebSocket";
|
|||||||
import TokenDialog from "./TokenDialog";
|
import TokenDialog from "./TokenDialog";
|
||||||
import "./Grid.css";
|
import "./Grid.css";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const DEFAULT_ZOOM = 40;
|
const DEFAULT_ZOOM = 40;
|
||||||
const MIN_ZOOM = 8;
|
const MIN_ZOOM = 8;
|
||||||
const MAX_ZOOM = 160;
|
const MAX_ZOOM = 160;
|
||||||
@@ -36,10 +32,6 @@ const MAX_FLOOD_CELLS = 2500;
|
|||||||
/** World units per second for WASD keyboard panning. */
|
/** World units per second for WASD keyboard panning. */
|
||||||
const WASD_PAN_SPEED = 12;
|
const WASD_PAN_SPEED = 12;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface Camera {
|
interface Camera {
|
||||||
offsetX: number;
|
offsetX: number;
|
||||||
offsetY: number;
|
offsetY: number;
|
||||||
@@ -58,10 +50,6 @@ export interface GridHandle {
|
|||||||
sendColorUpdate: (colors: string[]) => void;
|
sendColorUpdate: (colors: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pure helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function cellKey(x: number, y: number): string {
|
function cellKey(x: number, y: number): string {
|
||||||
return `${x},${y}`;
|
return `${x},${y}`;
|
||||||
}
|
}
|
||||||
@@ -265,10 +253,6 @@ function clampCameraToContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Grid component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const Grid = forwardRef<GridHandle, Props>(function Grid(
|
const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||||
{ mapId, tool, paintColor, tokenColor, onColorsLoaded },
|
{ mapId, tool, paintColor, tokenColor, onColorsLoaded },
|
||||||
ref,
|
ref,
|
||||||
@@ -316,18 +300,12 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Imperative handle — lets App.tsx trigger a color WS update
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
sendColorUpdate(colors: string[]) {
|
sendColorUpdate(colors: string[]) {
|
||||||
sendRef.current({ type: "update_colors", colors });
|
sendRef.current({ type: "update_colors", colors });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Resize canvas to fill container
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -345,9 +323,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [redraw]);
|
}, [redraw]);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// WebSocket
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Keep a stable ref to the callback so handleMessage doesn't re-create
|
// Keep a stable ref to the callback so handleMessage doesn't re-create
|
||||||
const onColorsLoadedRef = useRef(onColorsLoaded);
|
const onColorsLoadedRef = useRef(onColorsLoaded);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -437,9 +412,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
sendRef.current = send;
|
sendRef.current = send;
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Canvas draw
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -518,9 +490,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
}
|
}
|
||||||
}, [tick]);
|
}, [tick]);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Camera helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
function applyClampAndRedraw() {
|
function applyClampAndRedraw() {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
@@ -546,9 +515,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
applyClampAndRedraw();
|
applyClampAndRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Wheel → zoom
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -564,9 +530,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
return () => canvas.removeEventListener("wheel", onWheel);
|
return () => canvas.removeEventListener("wheel", onWheel);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// WASD panning — requestAnimationFrame loop
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function rafTick(timestamp: number) {
|
function rafTick(timestamp: number) {
|
||||||
const keys = keysHeld.current;
|
const keys = keysHeld.current;
|
||||||
@@ -640,9 +603,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
};
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Mouse helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
function getCanvasPoint(e: React.MouseEvent) {
|
function getCanvasPoint(e: React.MouseEvent) {
|
||||||
const rect = canvasRef.current!.getBoundingClientRect();
|
const rect = canvasRef.current!.getBoundingClientRect();
|
||||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||||
@@ -655,9 +615,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Mouse handlers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
function handleMouseDown(e: React.MouseEvent) {
|
function handleMouseDown(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { x: mx, y: my } = getCanvasPoint(e);
|
const { x: mx, y: my } = getCanvasPoint(e);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { auth } from "../api";
|
import { auth } from "../api";
|
||||||
import type { UserInfo } from "../types";
|
import type { UserInfo } from "../types";
|
||||||
import "./LoginModal.css";
|
import "./LoginModal.css";
|
||||||
|
import { FaDiscord } from "react-icons/fa6";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -103,7 +104,7 @@ export default function LoginModal({ onClose, onLogin }: Props) {
|
|||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
required
|
required
|
||||||
minLength={1}
|
minLength={3}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -117,7 +118,6 @@ export default function LoginModal({ onClose, onLogin }: Props) {
|
|||||||
tab === "login" ? "current-password" : "new-password"
|
tab === "login" ? "current-password" : "new-password"
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
minLength={8}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{tab === "register" && (
|
{tab === "register" && (
|
||||||
@@ -129,7 +129,6 @@ export default function LoginModal({ onClose, onLogin }: Props) {
|
|||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
minLength={8}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
@@ -151,9 +150,7 @@ export default function LoginModal({ onClose, onLogin }: Props) {
|
|||||||
|
|
||||||
{/* Discord OAuth */}
|
{/* Discord OAuth */}
|
||||||
<button className="btn-discord" onClick={handleDiscordLogin}>
|
<button className="btn-discord" onClick={handleDiscordLogin}>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<FaDiscord />
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
|
|
||||||
</svg>
|
|
||||||
Log In with Discord
|
Log In with Discord
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,6 +108,12 @@
|
|||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-list-updated {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #4b5563;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Role badge reused from EditMapModal */
|
/* Role badge reused from EditMapModal */
|
||||||
.perm-role-badge {
|
.perm-role-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import { useState } from "react";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { ListedMap } from "../types";
|
import type { ListedMap } from "../types";
|
||||||
import "./MapListModal.css";
|
import "./MapListModal.css";
|
||||||
|
import { FaTrash } from "react-icons/fa6";
|
||||||
|
|
||||||
|
function timeAgo(date: Date): string {
|
||||||
|
const diff = Date.now() - new Date(date).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60_000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12) return `${months}mo ago`;
|
||||||
|
return `${Math.floor(months / 12)}y ago`;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
maps: ListedMap[];
|
maps: ListedMap[];
|
||||||
@@ -83,7 +98,7 @@ export default function MapListModal({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>My Maps</h2>
|
<h2>Maps</h2>
|
||||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -95,7 +110,13 @@ export default function MapListModal({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="map-list-scroll">
|
<div className="map-list-scroll">
|
||||||
{maps.map((map) => (
|
{[...maps]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updated_at).getTime() -
|
||||||
|
new Date(a.updated_at).getTime(),
|
||||||
|
)
|
||||||
|
.map((map) => (
|
||||||
<div
|
<div
|
||||||
key={map.id}
|
key={map.id}
|
||||||
className={`map-list-row ${map.id === selectedMapId ? "active" : ""}`}
|
className={`map-list-row ${map.id === selectedMapId ? "active" : ""}`}
|
||||||
@@ -116,13 +137,21 @@ export default function MapListModal({
|
|||||||
{accessLabel(map.public_access)}
|
{accessLabel(map.public_access)}
|
||||||
</span>
|
</span>
|
||||||
{map.user_role && (
|
{map.user_role && (
|
||||||
<span className={`perm-role-badge role-${map.user_role}`}>
|
<span
|
||||||
|
className={`perm-role-badge role-${map.user_role}`}
|
||||||
|
>
|
||||||
{map.user_role}
|
{map.user_role}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{map.is_favorited && !map.user_role && (
|
{map.is_favorited && !map.user_role && (
|
||||||
<span className="map-fav-badge">★ Favorited</span>
|
<span className="map-fav-badge">★ Favorited</span>
|
||||||
)}
|
)}
|
||||||
|
<span
|
||||||
|
className="map-list-updated"
|
||||||
|
title={new Date(map.updated_at).toLocaleString()}
|
||||||
|
>
|
||||||
|
{timeAgo(map.updated_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,6 +201,29 @@ export default function MapListModal({
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Delete map button */}
|
||||||
|
<button
|
||||||
|
className="map-action-btn delete-btn"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
"Are you sure you want to delete this map? This action cannot be undone.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await api.deleteMap(map.id);
|
||||||
|
onMapsChange(maps.filter((m) => m.id !== map.id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete map", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Delete map"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// User / Auth
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface ConnectionInfo {
|
export interface ConnectionInfo {
|
||||||
provider: string;
|
provider: string;
|
||||||
provider_username: string | null;
|
provider_username: string | null;
|
||||||
@@ -19,10 +15,6 @@ export interface UserInfo {
|
|||||||
connections: ConnectionInfo[];
|
connections: ConnectionInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Maps
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type MapRole = "owner" | "editor" | "viewer";
|
export type MapRole = "owner" | "editor" | "viewer";
|
||||||
|
|
||||||
/** Map visibility / editability level. */
|
/** Map visibility / editability level. */
|
||||||
@@ -42,8 +34,8 @@ export interface GridMap {
|
|||||||
public_access: PublicAccess;
|
public_access: PublicAccess;
|
||||||
owner_id: string; // UUID
|
owner_id: string; // UUID
|
||||||
colors: string[];
|
colors: string[];
|
||||||
created_at: string;
|
created_at: Date;
|
||||||
updated_at: string;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,10 +84,6 @@ export interface MapAccessRequest {
|
|||||||
|
|
||||||
export type Tool = "pan" | "zoom" | "draw" | "token";
|
export type Tool = "pan" | "zoom" | "draw" | "token";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WebSocket message types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: "paint_cell"; x: number; y: number; color: string }
|
| { type: "paint_cell"; x: number; y: number; color: string }
|
||||||
| {
|
| {
|
||||||
|
|||||||
Reference in New Issue
Block a user