Formatting and cleanup

This commit is contained in:
2026-04-04 14:33:07 -04:00
parent f17e5061cd
commit 070337577c
20 changed files with 237 additions and 421 deletions

View File

@@ -1,40 +1,67 @@
# -----------------------------------------------------------
# Logging
# -----------------------------------------------------------
# Rust log filter directive (e.g. warn,siren=info)
RUST_LOG=warn,siren=info
# -----------------------------------------------------------
# Discord
# -----------------------------------------------------------
# Bot token from the Discord Developer Portal → Bot tab → Reset Token
DISCORD_BOT_TOKEN=
# OAuth2 client secret from the Discord Developer Portal → OAuth2 tab
DISCORD_CLIENT_SECRET=
# -----------------------------------------------------------
# Security
# -----------------------------------------------------------
# Secret used to sign JWT tokens — change this before deploying
JWT_SECRET=changeme
# -----------------------------------------------------------
# Database
# -----------------------------------------------------------
POSTGRES_USER=siren
POSTGRES_PASSWORD=changeme
POSTGRES_DB=siren_db
# Use "siren-postgres" when running inside Docker Compose
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
API_BASE_URL=http://localhost:3000
API_PORT=3000
API_SESSION_TTL=86400
# Set to a specific origin (e.g. https://yourapp.com) when deploying to
# production with a separate frontend origin. Use "*" (the default) in
# development with the Vite proxy, where CORS is not an issue.
CORS_ORIGIN=*
UI_PORT=8080
# -----------------------------------------------------------
# Cache (Valkey)
# -----------------------------------------------------------
# Use "siren-valkey" when running inside Docker Compose
VALKEY_HOST=localhost
VALKEY_PORT=6379
MINIO_ROOT_USER=siren
MINIO_ROOT_PASSWORD=changeme
MINIO_HOST=localhost
MINIO_PORT=9000
MINIO_PORT_INTERNAL=9001
# -----------------------------------------------------------
# API
# -----------------------------------------------------------
# Base URL of the REST API (used to build OAuth2 redirect URIs, etc.)
API_BASE_URL=http://localhost:3000
API_PORT=3000
# OAuth2 session TTL in seconds
API_SESSION_TTL=86400
# Siren Data integration (Optional)
# -----------------------------------------------------------
# UI
# -----------------------------------------------------------
# Set to a specific origin (e.g. https://yourapp.com) when deploying to
# production with a separate frontend origin. Use "*" (the default) in
# development with the Vite proxy, where CORS is not an issue.
CORS_ORIGIN=*
# Port the UI dev server (Vite) listens on
UI_PORT=5173
# -----------------------------------------------------------
# Bot
# -----------------------------------------------------------
# Re-register slash commands with Discord on every startup
FORCE_COMMAND_REGISTER=false
# -----------------------------------------------------------
# Data (Optional)
# -----------------------------------------------------------
# Path to a local directory for optional Siren Data integration
DATA_DIR_PATH=./data
FORCE_REGISTER=false
DEFAULT_API_KEY=test_api_key
DEFAULT_SERVER=
DEFAULT_USER=

View File

@@ -51,27 +51,29 @@ task setup
### Environment variables
| Variable | Required | Description |
|-------------------------|----------|-------------------------------------------------------------------------|
| `DISCORD_BOT_TOKEN` | Yes | Bot token from the Discord Developer Portal |
| `DISCORD_CLIENT_SECRET` | Yes | OAuth2 client secret |
| `JWT_SECRET` | Yes | Secret used to sign JWT tokens — change from default |
| `POSTGRES_USER` | Yes | PostgreSQL username |
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password — change from default |
| `POSTGRES_DB` | Yes | PostgreSQL database name |
| `POSTGRES_HOST` | Yes | PostgreSQL host (`localhost` for local dev, `siren-postgres` in Docker) |
| `POSTGRES_PORT` | Yes | PostgreSQL port (default `5432`) |
| `VALKEY_HOST` | Yes | Valkey host (`localhost` for local dev, `siren-valkey` in Docker) |
| `VALKEY_PORT` | Yes | Valkey port (default `6379`) |
| `API_PORT` | Yes | Port the REST API listens on (default `3000`) |
| `API_CALLBACK_URI` | Yes | OAuth2 redirect URI (e.g. `http://localhost:3000/api/oauth/callback`) |
| `API_SESSION_TTL` | | OAuth2 session TTL in seconds (default `86400`) |
| `RUST_LOG` | | Log filter (e.g. `warn,siren=info`) |
| `FORCE_REGISTER` | | Re-register slash commands on every startup (`true`/`false`) |
| `DATA_DIR_PATH` | | Path to optional local data directory |
| `DEFAULT_API_KEY` | | Seed API key created on startup |
| `DEFAULT_SERVER` | | Seed guild ID |
| `DEFAULT_USER` | | Seed user ID |
| Variable | Required | Description |
|--------------------------|----------|---------------------------------------------------------------------------|
| `DISCORD_BOT_TOKEN` | Yes | Bot token from the Discord Developer Portal |
| `DISCORD_CLIENT_SECRET` | Yes | OAuth2 client secret |
| `JWT_SECRET` | Yes | Secret used to sign JWT tokens — change from default |
| `POSTGRES_USER` | Yes | PostgreSQL username |
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password — change from default |
| `POSTGRES_DB` | Yes | PostgreSQL database name |
| `POSTGRES_HOST` | Yes | PostgreSQL host (`localhost` for local dev, `siren-postgres` in Docker) |
| `POSTGRES_PORT` | | PostgreSQL port (default `5432`) |
| `VALKEY_HOST` | | Valkey host (`localhost` for local dev, `siren-valkey` in Docker) |
| `VALKEY_PORT` | | Valkey port (default `6379`) |
| `API_BASE_URL` | Yes | Base URL of the API (e.g. `http://localhost:3000`) |
| `API_PORT` | | Port the REST API listens on (default `3000`) |
| `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`) |
| `FORCE_COMMAND_REGISTER` | | Re-register slash commands with Discord on every startup (`true`/`false`) |
| `DATA_DIR_PATH` | | Path to optional local data directory |
| `DEFAULT_API_KEY` | | Seed API key created on startup |
| `DEFAULT_GUILD_ID` | | Seed Discord guild (server) ID |
| `DEFAULT_USER_ID` | | Seed Discord user ID |
---

View File

@@ -177,7 +177,7 @@ tasks:
ngrok:
desc: Start ngrok tunnel
vars:
UI_PORT: '{{.UI_PORT | default "8080"}}'
UI_PORT: '{{.UI_PORT | default "5173"}}'
cmds:
- ngrok http {{.UI_PORT}}
silent: true

View File

@@ -292,9 +292,6 @@ async fn do_oauth_callback(
Ok(Redirect::temporary(&ui_redirect_uri).into_response())
}
// ------------------------------------------------------------------ //
// LOGIN MODE: look up (or create) the local user for this Discord account
// ------------------------------------------------------------------ //
None => {
// Find existing connection → local user_id
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`.
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

View File

@@ -41,10 +41,6 @@ pub fn get_routes() -> Router<Arc<AppState>> {
.route("/connections/{provider}", delete(disconnect_provider))
}
// ---------------------------------------------------------------------------
// Payloads
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct RegisterPayload {
username: String,
@@ -71,10 +67,6 @@ struct ChangePasswordPayload {
new_password: String,
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct ConnectionInfo {
pub provider: String,
@@ -95,10 +87,6 @@ pub struct UserInfo {
pub connections: Vec<ConnectionInfo>,
}
// ---------------------------------------------------------------------------
// DB row types
// ---------------------------------------------------------------------------
#[derive(sqlx::FromRow)]
struct DbUser {
id: Uuid,
@@ -116,10 +104,6 @@ struct DbConnection {
provider_avatar: Option<String>,
}
// ---------------------------------------------------------------------------
// Password helpers
// ---------------------------------------------------------------------------
/// Hash and salt a plaintext password with Argon2.
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
@@ -139,10 +123,6 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
.is_ok()
}
// ---------------------------------------------------------------------------
// Cookie / session helpers
// ---------------------------------------------------------------------------
/// Build the `siren_session` HttpOnly Secure cookie.
pub fn build_session_cookie(token: String) -> Cookie<'static> {
Cookie::build(("siren_session", token))
@@ -192,10 +172,6 @@ pub async fn create_session_and_cookie(
Ok((jar, ()))
}
// ---------------------------------------------------------------------------
// Helper: load full UserInfo for a given user_id
// ---------------------------------------------------------------------------
async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
let pool = data::pool();
@@ -232,10 +208,6 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
})
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn register(
headers: HeaderMap,
jar: CookieJar,
@@ -245,12 +217,6 @@ async fn register(
if username.is_empty() || username.len() > 32 {
return Err(Error::new(422, "Username must be 132 characters".into()));
}
if payload.password.len() < 8 {
return Err(Error::new(
422,
"Password must be at least 8 characters".into(),
));
}
let pool = data::pool();
@@ -402,13 +368,6 @@ async fn change_password(
) -> Result<StatusCode> {
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 existing_hash: Option<String> =

View File

@@ -61,10 +61,6 @@ pub fn get_routes() -> Router<Arc<AppState>> {
.route("/maps/{id}/ws", get(ws_handler))
}
// ---------------------------------------------------------------------------
// Access helpers
// ---------------------------------------------------------------------------
/// 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>> {
let pool = siren_core::data::pool();
@@ -120,10 +116,6 @@ async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
.unwrap_or(false)
}
// ---------------------------------------------------------------------------
// Map CRUD
// ---------------------------------------------------------------------------
pub async fn list_maps(
SessionAuthorization(session): SessionAuthorization,
) -> Result<Json<Vec<ListedMap>>> {
@@ -287,10 +279,6 @@ pub async fn delete_map(
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------------------
// Permission management
// ---------------------------------------------------------------------------
pub async fn list_permissions(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
@@ -429,10 +417,6 @@ pub async fn unfavorite_map(
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------------------
// Access Requests
// ---------------------------------------------------------------------------
pub async fn create_access_request(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
@@ -572,10 +556,6 @@ pub async fn resolve_access_request(
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------------------
// WebSocket handler
// ---------------------------------------------------------------------------
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,

View File

@@ -1,11 +1,7 @@
use chrono::NaiveDateTime;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Map Role / Permission
// ---------------------------------------------------------------------------
#[derive(Serialize, Deserialize, sqlx::Type, Clone, Debug, PartialEq, Eq)]
#[sqlx(type_name = "text", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
@@ -36,10 +32,6 @@ pub struct PermissionWithUser {
pub role: MapRole,
}
// ---------------------------------------------------------------------------
// Grid Map
// ---------------------------------------------------------------------------
/// Core map record as stored/returned by create, get, and update endpoints.
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
pub struct GridMap {
@@ -49,8 +41,8 @@ pub struct GridMap {
pub public_access: String,
pub owner_id: Uuid,
pub colors: Vec<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Extended map record returned by the list endpoint.
@@ -64,8 +56,8 @@ pub struct ListedMap {
pub owner_id: Uuid,
pub owner_username: String,
pub colors: Vec<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// The authenticated caller's role on this map, or NULL if they only have it
/// via a favorite (no explicit permission).
pub user_role: Option<MapRole>,
@@ -98,10 +90,6 @@ pub struct UpdatePermissionPayload {
pub role: Option<MapRole>,
}
// ---------------------------------------------------------------------------
// Map Access Requests
// ---------------------------------------------------------------------------
/// An access-request row joined with the requesting user's username.
#[derive(Serialize, sqlx::FromRow, Clone, Debug)]
pub struct AccessRequestWithUser {
@@ -111,8 +99,8 @@ pub struct AccessRequestWithUser {
pub username: String,
pub requested_role: MapRole,
pub status: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Deserialize, Clone, Debug)]
@@ -126,10 +114,6 @@ pub struct ResolveAccessRequestPayload {
pub action: String,
}
// ---------------------------------------------------------------------------
// Grid Cell (no id column — composite PK in DB)
// ---------------------------------------------------------------------------
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
pub struct GridCell {
pub map_id: String,
@@ -146,10 +130,6 @@ pub struct CellPatch {
pub color: String,
}
// ---------------------------------------------------------------------------
// Grid Token
// ---------------------------------------------------------------------------
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
pub struct GridToken {
pub id: String,
@@ -160,10 +140,6 @@ pub struct GridToken {
pub color: String,
}
// ---------------------------------------------------------------------------
// Full map state (used on initial WS connect and REST GET)
// ---------------------------------------------------------------------------
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct MapState {
pub map: GridMap,
@@ -171,10 +147,6 @@ pub struct MapState {
pub tokens: Vec<GridToken>,
}
// ---------------------------------------------------------------------------
// WebSocket message types
// ---------------------------------------------------------------------------
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {

View File

@@ -16,16 +16,8 @@ pub struct EnvironmentConfiguration {
pub api_session_ttl: u64,
pub valkey_host: String,
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 force_register: bool,
pub default_api_key: String,
pub default_server: Option<String>,
pub default_user: Option<String>,
pub force_command_register: bool,
}
impl EnvironmentConfiguration {
@@ -57,25 +49,11 @@ impl EnvironmentConfiguration {
.unwrap_or_else(|_| "6379".to_string())
.parse()
.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()),
force_register: env::var("FORCE_REGISTER")
force_command_register: env::var("FORCE_COMMAND_REGISTER")
.ok()
.map(|v| v.to_lowercase() == "true")
.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()),
})
}
}

View File

@@ -1,3 +0,0 @@
mod model;
pub use model::*;

View File

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

View File

@@ -9,7 +9,6 @@ pub mod events;
mod executable_query;
pub mod guilds;
pub mod insert;
pub mod messages;
pub mod query;
pub mod update;
use crate::config::EnvironmentConfiguration;

View File

@@ -21,7 +21,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
let config = EnvironmentConfiguration::load()?;
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 intents: GatewayIntents = GatewayIntents::all();

View File

@@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS events (
guild_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
title TEXT NOT NULL,
date_time TIMESTAMP NOT NULL,
date_time TIMESTAMPTZ NOT NULL,
description TEXT,
rsvp BIGINT[] NOT NULL
);
@@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT UNIQUE,
first_name TEXT,
last_name TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- External OAuth provider connections (Discord, etc.)
@@ -93,14 +93,6 @@ CREATE TABLE IF NOT EXISTS user_connections (
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 (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
@@ -118,8 +110,8 @@ CREATE TABLE IF NOT EXISTS grid_maps (
'#0f172a',
'#f9fafb'
],
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 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 (
user_id UUID NOT NULL REFERENCES users(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)
);
@@ -146,8 +138,8 @@ CREATE TABLE IF NOT EXISTS map_access_requests (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (map_id, user_id)
);

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { auth } from "../api";
import type { UserInfo } from "../types";
import "./AccountPanel.css";
import { FaDiscord } from "react-icons/fa6";
interface Props {
user: UserInfo;
@@ -43,7 +44,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
setProfileSuccess(false);
}
async function handleSaveProfile(e: React.SubmitEvent<HTMLFormElement>) {
async function handleSaveProfile(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setProfileSaving(true);
setProfileError(null);
@@ -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();
setPwError(null);
setPwSuccess(false);
@@ -72,10 +73,6 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
setPwError("Passwords do not match");
return;
}
if (pwNew.length < 8) {
setPwError("Password must be at least 8 characters");
return;
}
setPwSaving(true);
try {
@@ -296,13 +293,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
<h3>Connected Accounts</h3>
<div className="account-connection">
<svg
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>
<FaDiscord />
<div className="connection-info">
<span className="connection-name">Discord</span>

View File

@@ -24,7 +24,7 @@ export default function FloatingMapControls({
return (
<div className="floating-map-controls">
{/* 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
width="14"
height="14"

View File

@@ -17,10 +17,6 @@ import { useWebSocket } from "../hooks/useWebSocket";
import TokenDialog from "./TokenDialog";
import "./Grid.css";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_ZOOM = 40;
const MIN_ZOOM = 8;
const MAX_ZOOM = 160;
@@ -36,10 +32,6 @@ const MAX_FLOOD_CELLS = 2500;
/** World units per second for WASD keyboard panning. */
const WASD_PAN_SPEED = 12;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Camera {
offsetX: number;
offsetY: number;
@@ -58,10 +50,6 @@ export interface GridHandle {
sendColorUpdate: (colors: string[]) => void;
}
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
@@ -265,10 +253,6 @@ function clampCameraToContent(
}
}
// ---------------------------------------------------------------------------
// Grid component
// ---------------------------------------------------------------------------
const Grid = forwardRef<GridHandle, Props>(function Grid(
{ mapId, tool, paintColor, tokenColor, onColorsLoaded },
ref,
@@ -316,18 +300,12 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
null,
);
// -------------------------------------------------------------------------
// Imperative handle — lets App.tsx trigger a color WS update
// -------------------------------------------------------------------------
useImperativeHandle(ref, () => ({
sendColorUpdate(colors: string[]) {
sendRef.current({ type: "update_colors", colors });
},
}));
// -------------------------------------------------------------------------
// Resize canvas to fill container
// -------------------------------------------------------------------------
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
@@ -345,9 +323,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
return () => observer.disconnect();
}, [redraw]);
// -------------------------------------------------------------------------
// WebSocket
// -------------------------------------------------------------------------
// Keep a stable ref to the callback so handleMessage doesn't re-create
const onColorsLoadedRef = useRef(onColorsLoaded);
useEffect(() => {
@@ -437,9 +412,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
sendRef.current = send;
}, [send]);
// -------------------------------------------------------------------------
// Canvas draw
// -------------------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -518,9 +490,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
}
}, [tick]);
// -------------------------------------------------------------------------
// Camera helpers
// -------------------------------------------------------------------------
function applyClampAndRedraw() {
const canvas = canvasRef.current;
if (canvas) {
@@ -546,9 +515,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
applyClampAndRedraw();
}
// -------------------------------------------------------------------------
// Wheel → zoom
// -------------------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -564,9 +530,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
return () => canvas.removeEventListener("wheel", onWheel);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// WASD panning — requestAnimationFrame loop
// -------------------------------------------------------------------------
useEffect(() => {
function rafTick(timestamp: number) {
const keys = keysHeld.current;
@@ -640,9 +603,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// Mouse helpers
// -------------------------------------------------------------------------
function getCanvasPoint(e: React.MouseEvent) {
const rect = canvasRef.current!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
@@ -655,9 +615,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
return null;
}
// -------------------------------------------------------------------------
// Mouse handlers
// -------------------------------------------------------------------------
function handleMouseDown(e: React.MouseEvent) {
e.preventDefault();
const { x: mx, y: my } = getCanvasPoint(e);

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { auth } from "../api";
import type { UserInfo } from "../types";
import "./LoginModal.css";
import { FaDiscord } from "react-icons/fa6";
interface Props {
onClose: () => void;
@@ -103,7 +104,7 @@ export default function LoginModal({ onClose, onLogin }: Props) {
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
minLength={1}
minLength={3}
maxLength={32}
/>
</label>
@@ -117,7 +118,6 @@ export default function LoginModal({ onClose, onLogin }: Props) {
tab === "login" ? "current-password" : "new-password"
}
required
minLength={8}
/>
</label>
{tab === "register" && (
@@ -129,7 +129,6 @@ export default function LoginModal({ onClose, onLogin }: Props) {
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
)}
@@ -151,9 +150,7 @@ export default function LoginModal({ onClose, onLogin }: Props) {
{/* Discord OAuth */}
<button className="btn-discord" onClick={handleDiscordLogin}>
<svg width="20" height="20" 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>
<FaDiscord />
Log In with Discord
</button>
</div>

View File

@@ -108,6 +108,12 @@
color: #f59e0b;
}
.map-list-updated {
font-size: 0.68rem;
color: #4b5563;
margin-left: auto;
}
/* Role badge reused from EditMapModal */
.perm-role-badge {
font-size: 0.7rem;

View File

@@ -2,6 +2,21 @@ import { useState } from "react";
import { api } from "../api";
import type { ListedMap } from "../types";
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 {
maps: ListedMap[];
@@ -83,7 +98,7 @@ export default function MapListModal({
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>My Maps</h2>
<h2>Maps</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
@@ -95,86 +110,123 @@ export default function MapListModal({
</p>
) : (
<div className="map-list-scroll">
{maps.map((map) => (
<div
key={map.id}
className={`map-list-row ${map.id === selectedMapId ? "active" : ""}`}
onClick={() => handleSelect(map)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && handleSelect(map)}
>
<div className="map-list-main">
<span className="map-list-name">{map.name}</span>
<div className="map-list-meta">
<span className="map-list-owner">
by {map.owner_username}
</span>
<span
className={`map-access-badge access-${map.public_access}`}
>
{accessLabel(map.public_access)}
</span>
{map.user_role && (
<span className={`perm-role-badge role-${map.user_role}`}>
{map.user_role}
{[...maps]
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.map((map) => (
<div
key={map.id}
className={`map-list-row ${map.id === selectedMapId ? "active" : ""}`}
onClick={() => handleSelect(map)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && handleSelect(map)}
>
<div className="map-list-main">
<span className="map-list-name">{map.name}</span>
<div className="map-list-meta">
<span className="map-list-owner">
by {map.owner_username}
</span>
)}
{map.is_favorited && !map.user_role && (
<span className="map-fav-badge"> Favorited</span>
)}
<span
className={`map-access-badge access-${map.public_access}`}
>
{accessLabel(map.public_access)}
</span>
{map.user_role && (
<span
className={`perm-role-badge role-${map.user_role}`}
>
{map.user_role}
</span>
)}
{map.is_favorited && !map.user_role && (
<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 className="map-list-actions">
{/* Favorite toggle */}
<button
className={`map-action-btn fav-btn ${map.is_favorited ? "fav-active" : ""}`}
onClick={(e) => handleFavoriteToggle(e, map)}
disabled={togglingId === map.id}
title={
map.is_favorited
? "Remove from favorites"
: "Add to favorites"
}
>
{map.is_favorited ? "★" : "☆"}
</button>
{/* Copy link */}
<button
className="map-action-btn copy-btn"
onClick={(e) => handleCopyLink(e, map)}
title="Copy link"
>
{copiedId === map.id ? (
<span className="copied-text"></span>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</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 className="map-list-actions">
{/* Favorite toggle */}
<button
className={`map-action-btn fav-btn ${map.is_favorited ? "fav-active" : ""}`}
onClick={(e) => handleFavoriteToggle(e, map)}
disabled={togglingId === map.id}
title={
map.is_favorited
? "Remove from favorites"
: "Add to favorites"
}
>
{map.is_favorited ? "★" : "☆"}
</button>
{/* Copy link */}
<button
className="map-action-btn copy-btn"
onClick={(e) => handleCopyLink(e, map)}
title="Copy link"
>
{copiedId === map.id ? (
<span className="copied-text"></span>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
</div>
</div>
))}
))}
</div>
)}
</div>

View File

@@ -1,7 +1,3 @@
// ---------------------------------------------------------------------------
// User / Auth
// ---------------------------------------------------------------------------
export interface ConnectionInfo {
provider: string;
provider_username: string | null;
@@ -19,10 +15,6 @@ export interface UserInfo {
connections: ConnectionInfo[];
}
// ---------------------------------------------------------------------------
// Maps
// ---------------------------------------------------------------------------
export type MapRole = "owner" | "editor" | "viewer";
/** Map visibility / editability level. */
@@ -42,8 +34,8 @@ export interface GridMap {
public_access: PublicAccess;
owner_id: string; // UUID
colors: string[];
created_at: string;
updated_at: string;
created_at: Date;
updated_at: Date;
}
/**
@@ -92,10 +84,6 @@ export interface MapAccessRequest {
export type Tool = "pan" | "zoom" | "draw" | "token";
// ---------------------------------------------------------------------------
// WebSocket message types
// ---------------------------------------------------------------------------
export type ClientMessage =
| { type: "paint_cell"; x: number; y: number; color: string }
| {