Compare commits

..

No commits in common. "main" and "v2.2.0" have entirely different histories.
main ... v2.2.0

1639 changed files with 72307 additions and 108373 deletions

View File

@ -11,21 +11,6 @@
# - NEVER commit .env to version control
# ==============================================================================
# ==============================================================================
# MINIMUM VIABLE SETUP (required — change these before deploying)
# ==============================================================================
# 1. V2_POSTGRES_PASSWORD — database password (8+ chars)
# 2. REDIS_PASSWORD — cache password (8+ chars)
# 3. JWT_ACCESS_SECRET — openssl rand -hex 32
# 4. JWT_REFRESH_SECRET — openssl rand -hex 32 (different from above)
# 5. JWT_INVITE_SECRET — openssl rand -hex 32 (different from above)
# 6. ENCRYPTION_KEY — openssl rand -hex 32 (different from JWT secrets)
# 7. INITIAL_ADMIN_PASSWORD — 12+ chars, uppercase + lowercase + digit
# 8. DOMAIN — your deployment domain (default: cmlite.org)
#
# Everything below these 8 values works with defaults for development.
# ==============================================================================
# --- General ---
NODE_ENV=development
# Root domain serves MkDocs documentation site only
@ -46,28 +31,13 @@ JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_ACCESS_EXPIRY=15m
# Reduced from 7d → 24h on 2026-04-12 (P2-3 hardening). Combined with
# device-fingerprint binding in the JWT payload, this tightens the
# exploitation window for stolen refresh tokens.
JWT_REFRESH_EXPIRY=24h
JWT_REFRESH_EXPIRY=7d
# Encryption key for DB-stored secrets (SMTP password, etc.)
# REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
# BREAKING CHANGE (2026-04-12): both GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
# are now REQUIRED (min 32 chars). The previous fallback to JWT_ACCESS_SECRET
# has been removed — a JWT leak must not compromise SSO cookies or service
# account passwords. Both values must be distinct from each other and from
# all JWT_* secrets. Generate with: openssl rand -hex 32
# Gitea SSO cookie signing secret (required, ≥32 chars, distinct from JWT secrets)
GITEA_SSO_SECRET=GENERATE_WITH_openssl_rand_hex_32
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat).
# Required, ≥32 chars, distinct from all other secrets.
SERVICE_PASSWORD_SALT=GENERATE_WITH_openssl_rand_hex_32
# --- Initial Super Admin User (auto-created during database seeding) ---
# These credentials are used to create the initial super admin account
# Change these before running the seed script in production
@ -88,32 +58,6 @@ ADMIN_URL=http://localhost:3000
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
# --- Embed Proxy Ports ---
# Dedicated nginx ports for iframe embedding without DNS/subdomain.
# Change these to avoid port conflicts when running multiple instances on one host.
NOCODB_EMBED_PORT=8881
N8N_EMBED_PORT=8882
GITEA_EMBED_PORT=8883
MAILHOG_EMBED_PORT=8884
MINI_QR_EMBED_PORT=8885
EXCALIDRAW_EMBED_PORT=8886
HOMEPAGE_EMBED_PORT=8887
VAULTWARDEN_EMBED_PORT=8890
ROCKETCHAT_EMBED_PORT=8891
GANCIO_EMBED_PORT=8892
JITSI_EMBED_PORT=8893
GRAFANA_EMBED_PORT=8894
ALERTMANAGER_EMBED_PORT=8895
# --- Docker / Container Management ---
# Docker network name (used by dashboard to auto-discover containers)
DOCKER_NETWORK_NAME=changemaker-lite
# Docker socket proxy URL (read-only container inspection)
DOCKER_PROXY_URL=http://docker-socket-proxy:2375
# Newt tunnel container (for Pangolin restart/status checks)
NEWT_CONTAINER_NAME=newt-changemaker
NEWT_COMPOSE_SERVICE=newt
# --- SMTP / Email ---
SMTP_HOST=mailhog-changemaker
SMTP_PORT=1025
@ -136,8 +80,6 @@ LISTMONK_WEB_ADMIN_USER=admin
LISTMONK_WEB_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# API user (auto-created by listmonk-init container, used by V2 API for sync)
# Generate token: openssl rand -hex 16
# NOTE: LISTMONK_ADMIN_USER/PASSWORD are what the V2 API uses to connect.
# They MUST match LISTMONK_API_USER/TOKEN (same credentials, different var names).
LISTMONK_API_USER=v2-api
LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16
LISTMONK_ADMIN_USER=v2-api
@ -188,13 +130,6 @@ MEDIA_API_PORT=4100
MEDIA_API_PUBLIC_URL=http://media-api:4100
# Used during admin Docker build to set the media API endpoint for Vite
VITE_MEDIA_API_URL=http://changemaker-media-api:4100
# HLS adaptive bitrate transcoding. When 'true', uploaded videos are queued
# for FFmpeg transcoding into 360p/720p/1080p HLS variants and the player
# prefers HLS over the MP4 range-request stream. When 'false' (default),
# uploads are tagged SKIPPED and the player falls back to MP4 — no DB or
# disk impact, fully reversible. The worker is always registered so existing
# PENDING jobs from a prior run still process if you flip the flag back on.
ENABLE_HLS_TRANSCODE=false
MEDIA_ROOT=/media/library
MEDIA_UPLOADS=/media/uploads
MAX_UPLOAD_SIZE_GB=10
@ -218,29 +153,16 @@ VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
# Leave IMAGE_TAG blank/unset (defaults to 'local') to build locally from source.
GITEA_REGISTRY=gitea.bnkops.com/admin
IMAGE_TAG=
# Docker Compose profiles — set to 'monitoring' to include Prometheus/Grafana/Alertmanager
# in every 'docker compose up -d'. Leave blank to start monitoring separately.
COMPOSE_PROFILES=
# Credentials used by the registry status API endpoint (GET /api/registry/status)
# For docker push/pull, run: docker login gitea.bnkops.com
GITEA_REGISTRY_USER=admin
GITEA_REGISTRY_PASS=
# API token for the REMOTE registry (gitea.bnkops.com) — used by build-release.sh --upload
# Create at: https://gitea.bnkops.com/user/settings/applications
# This is NOT the same as GITEA_API_TOKEN (which is for the local platform Gitea below)
GITEA_REGISTRY_API_TOKEN=
# --- Gitea (Local Platform Instance) ---
# --- Gitea ---
GITEA_URL=http://gitea-changemaker:3000
GITEA_PORT=3030
GITEA_WEB_PORT=3030
GITEA_SSH_PORT=2222
# Admin user (auto-created on first boot by gitea-init.sh)
GITEA_ADMIN_USER=admin
# Leave blank to reuse INITIAL_ADMIN_PASSWORD (compose resolves the fallback).
# Set only if you want a distinct password for the Gitea admin account.
GITEA_ADMIN_PASSWORD=
GITEA_DB_TYPE=mysql
GITEA_DB_HOST=gitea-db:3306
GITEA_DB_NAME=gitea
@ -253,9 +175,7 @@ GITEA_DOMAIN=git.cmlite.org
# --- Gitea Docs Comments ---
# Enable comments on MkDocs pages (backed by Gitea Issues)
GITEA_COMMENTS_ENABLED=false
# Personal access token for the LOCAL Gitea instance (docs comments, user provisioning, SSO)
# Create at: http://localhost:3030/user/settings/applications (or https://git.DOMAIN/...)
# This is NOT the same as GITEA_REGISTRY_API_TOKEN (which is for the remote registry above)
# Personal access token with repo write scope (create in Gitea → Settings → Applications)
GITEA_API_TOKEN=
# Repository owner (Gitea username that will own the docs-comments repo)
GITEA_COMMENTS_REPO_OWNER=
@ -287,25 +207,29 @@ MKDOCS_DOCS_PATH=/mkdocs/docs
# --- Code Server ---
CODE_SERVER_PORT=8888
CODE_SERVER_URL=http://code-server-changemaker:8443
CODE_SERVER_URL=http://code-server:8080
USER_NAME=coder
# --- Homepage ---
HOMEPAGE_PORT=3010
HOMEPAGE_EMBED_PORT=8887
HOMEPAGE_VAR_BASE_URL=http://localhost
# --- Mini QR ---
MINI_QR_PORT=8089
MINI_QR_URL=http://mini-qr:8080
MINI_QR_EMBED_PORT=8885
# --- Excalidraw (Collaborative Whiteboard) ---
EXCALIDRAW_PORT=8090
EXCALIDRAW_URL=http://excalidraw-changemaker:80
EXCALIDRAW_EMBED_PORT=8886
EXCALIDRAW_WS_URL=wss://draw.cmlite.org
# --- Vaultwarden (Password Manager) ---
VAULTWARDEN_PORT=8445
VAULTWARDEN_URL=http://vaultwarden-changemaker:80
VAULTWARDEN_EMBED_PORT=8890
# Admin panel token (access at /admin) — generate with: openssl rand -hex 32
VAULTWARDEN_ADMIN_TOKEN=
# MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation
@ -378,14 +302,13 @@ ENABLE_CHAT=false
ROCKETCHAT_ADMIN_USER=rcadmin
ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
ROCKETCHAT_URL=http://rocketchat-changemaker:3000
# MongoDB credentials for Rocket.Chat (required — MongoDB runs with --auth)
MONGO_ROOT_USER=rocketchat
MONGO_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
ROCKETCHAT_EMBED_PORT=8891
# --- Gancio (Event Management) ---
# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh)
GANCIO_PORT=8092
GANCIO_URL=http://gancio-changemaker:13120
GANCIO_EMBED_PORT=8892
GANCIO_BASE_URL=https://events.cmlite.org
# Gancio admin credentials for shift-to-event sync (OAuth login)
GANCIO_ADMIN_USER=admin
@ -405,12 +328,18 @@ JITSI_APP_SECRET=GENERATE_WITH_openssl_rand_hex_32
# Generate each with: openssl rand -hex 16
JITSI_JICOFO_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
JITSI_JVB_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
# Embed port for admin iframe
JITSI_EMBED_PORT=8893
JITSI_URL=http://jitsi-web-changemaker:80
# JVB public IP (required for NAT traversal — set to server's public IP in production)
JVB_ADVERTISE_IP=
# JVB UDP port for media traffic (must be open in firewall)
JVB_PORT=10000
# --- Monitoring Embed Ports (iframe embedding) ---
GRAFANA_EMBED_PORT=8894
ALERTMANAGER_EMBED_PORT=8895
# --- SMS Campaigns (Termux Android Bridge) ---
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
# URL + API key are typically managed via admin Settings page (DB overrides env)
@ -423,26 +352,6 @@ SMS_MAX_RETRIES=3
SMS_RESPONSE_SYNC_INTERVAL_MS=120000
SMS_DEVICE_MONITOR_INTERVAL_MS=300000
# --- Social, People & Analytics ---
# ENABLE_SOCIAL is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_SOCIAL=false
# ENABLE_PEOPLE is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_PEOPLE=false
# ENABLE_ANALYTICS is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_ANALYTICS=false
# --- Control Panel Agent ---
# Set to true to enable the CCP remote management agent
ENABLE_CCP_AGENT=false
# URL of the Changemaker Control Panel
CCP_URL=
# One-time invite code for registration
CCP_INVITE_CODE=
# How the CCP can reach this agent (must be externally accessible)
CCP_AGENT_URL=
# Agent port (default 7443)
CCP_AGENT_PORT=7443
# --- Monitoring (only used with --profile monitoring) ---
PROMETHEUS_PORT=9090
GRAFANA_PORT=3005
@ -460,8 +369,3 @@ GOTIFY_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN)
BUNKER_OPS_ENABLED=false # Enable remote metrics push to central server
BUNKER_OPS_REMOTE_WRITE_URL= # VictoriaMetrics remote_write endpoint (e.g., https://ops.example.com/api/v1/write)
# --- GeoIP (MaxMind GeoLite2) ---
# Free account: https://www.maxmind.com/en/geolite2/signup
MAXMIND_ACCOUNT_ID= # MaxMind account ID
MAXMIND_LICENSE_KEY= # MaxMind license key (auto-downloads GeoLite2-City DB at startup)

29
.gitignore vendored
View File

@ -9,9 +9,6 @@ node_modules/
/configs/code-server/.config/*
!/configs/code-server/.config/.gitkeep
/configs/code-server/data/*
!/configs/code-server/data/.gitkeep
# Root assets (generated by containers)
/assets/
@ -36,8 +33,7 @@ node_modules/
# NAR data directory (large voter registry files)
/data/*
!/data/upgrade/
/data/upgrade/*
!/data/upgrade/.gitkeep
/data/upgrade/*.json
# Media files (managed by Docker volumes, not git)
/media/
@ -64,35 +60,12 @@ core.*
/backups/
.upgrade.lock
# Pre-upgrade mkdocs snapshots (created by scripts/lib/mkdocs-snapshot.sh).
# These are the tenant-content rescue archives written before every upgrade;
# discoverable in the install root via `ls`. Retention: last 5 (see helper).
/mkdocs-backup-*.tar.gz
# Release tarballs (generated by build-release.sh)
/releases/
# API compiled output (generated by tsc, baked into Docker images)
/api/dist/
# TypeScript incremental build cache (machine-specific)
*.tsbuildinfo
# Control Panel runtime data (managed deployments + backups)
/changemaker-control-panel/instances/
/changemaker-control-panel/backups/
logs/
# Playwright MCP browser automation logs
.playwright-mcp/
/docs
# MkDocs build cache (regenerated each build)
/mkdocs/.cache/
# Claude scheduler lock file
.claude/scheduled_tasks.lock
# Old release zip archive (no longer tracked, see chore: gitignore hygiene)
/archive/

View File

@ -0,0 +1,2 @@
[ 288ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:2287
[ 288ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,6 @@
[ 92ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 92ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 496039ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 496039ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 498038ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 498038ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,26 @@
[ 121ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:885
[ 121ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 497669ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2201
[ 497669ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 499981ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 499981ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 503949ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 503949ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 506409ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 506409ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 510957ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 510957ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 523501ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2304
[ 523501ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 534339ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:891
[ 534339ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 536931ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 536931ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 543415ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2312
[ 543415ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 545948ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2209
[ 545948ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 552080ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 552080ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 554689ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2313
[ 554689ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 101ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:2313
[ 101ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1 @@
[ 287ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4004/favicon.ico:0

View File

@ -0,0 +1,2 @@
[ 118ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2272
[ 118ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 49ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2101
[ 49ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 52ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/getting-started/:2582
[ 52ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 59ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2313
[ 59ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 40ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2226
[ 40ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,6 @@
[ 269ms] ReferenceError: Missing element: expected "[data-md-component=header]" to be present
at j (http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:35799)
at Ce (http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:42721)
at http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:94068
at http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:95391
[ 418ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4000/favicon.ico:0

View File

@ -0,0 +1,2 @@
[ 339ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:511
[ 339ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 36ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2212
[ 36ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 64ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2315
[ 64ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 189ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:511
[ 189ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 150ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2315
[ 151ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,14 @@
[ 64ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:893
[ 65ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 926012ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?_=1773266458361:933
[ 926012ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 1794181ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?_=1773267326487:2359
[ 1794181ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 1857070ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?v=1773267389387:2391
[ 1857070ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2018066ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?r=1773267550383:2406
[ 2018066ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2115925ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?final=1773267648297:571
[ 2115925ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2810593ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?ff=1773268342997:961
[ 2810593ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,21 @@
[ 1411ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
[ 11195ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/connectivity:0
[ 11196ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/services/status:0
[ 11197ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/weather:0
[ 11197ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/docs-analytics/summary?days=30:0
[ 11198ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/chat-summary:0
[ 11199ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/rocketchat-stats:0
[ 11199ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/upcoming-shifts:0
[ 11200ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/jitsi/meetings:0
[ 11201ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/influence/effectiveness/overview:0
[ 11201ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/top-videos:0
[ 11203ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/recent-signups:0
[ 11204ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/recent-comments:0
[ 11205ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/listmonk/stats:0
[ 11206ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/listmonk-campaigns:0
[ 11206ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/listmonk:0
[ 11207ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/observability/alerts:0
[ 11208ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/payments/admin/dashboard:0
[ 11209ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/gitea-activity:0
[ 11209ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/vaultwarden-adoption:0
[ 11210ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/map/canvass/analytics/cuts:0

View File

@ -0,0 +1,8 @@
[ 788ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/auth/me:0
[ 789ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/settings:0
[ 791ms] [ERROR] Unexpected auth error: AxiosError: Request failed with status code 500
at settle (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1281:12)
at XMLHttpRequest.onloadend (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1638:7)
at Axios.request (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:2255:41)
at async Object.fetchMe (http://localhost:3002/src/stores/auth.store.ts:101:28)
at async hydrate (http://localhost:3002/src/stores/auth.store.ts:118:11) @ http://localhost:3002/src/stores/auth.store.ts:105

View File

@ -0,0 +1,30 @@
[ 960624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1920622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2880624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 3840624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4800623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5760623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 6720616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 7680622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 8640625ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 9600615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[10560615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[11520625ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[12480623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[13440615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[14400616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[15360616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[16320615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[17280618ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[18240616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[19200622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[20160621ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21120618ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[22080623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23040622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24000616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24960616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[25920615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[26880613ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[27840614ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[28800615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -0,0 +1,2 @@
[ 92ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/admin/dashboard/:1574
[ 92ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,44 @@
[ 1044ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
[ 1045ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0
[ 957294ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1915502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2875494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 3835503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4795505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5755494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 6715495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 7675495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 8635495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 9595539ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[10555496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[11515504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[12475494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[13435504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[14395501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[15355503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[16315505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[17275496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[18235494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[19195496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[20155502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21115501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[22075494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23035502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23995496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24955494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[25915495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[26875500ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[27835504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[28795505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[29755503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[30715505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[31675500ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[32635503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[33595504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[34555501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[35515495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[36475494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[37435493ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[38395495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[39355494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[40315488ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -0,0 +1 @@
[ 915ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0

View File

@ -0,0 +1,442 @@
[ 719376ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0
[ 949197ms] [ERROR] ReferenceError: MeetingAgendaPage is not defined
at App (http://localhost:3002/src/App.tsx?t=1773363079750:663:127)
at renderWithHooks (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:3520:25)
at updateFunctionComponent (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:5151:19)
at beginWork (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:5762:18)
at performUnitOfWork (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8567:18)
at workLoopSync (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8465:41)
at renderRootSync (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8449:11)
at performWorkOnRoot (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8124:44)
at performSyncWorkOnRoot (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:9134:7)
at flushSyncWorkAcrossRoots_impl (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:9042:153) @ http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:4778
[ 953711ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/src/App.tsx?t=1773363084913:0
[ 1676461ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error during WebSocket handshake: net::ERR_CONNECTION_RESET @ http://localhost:3002/@vite/client:1034
[ 1677465ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/@vite/client:1034
[ 1678466ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/@vite/client:1034
[ 1679810ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ListmonkPage.tsx:0
[ 1679810ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/LandingPagesPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MkDocsSettingsPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/CodeEditorPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NocoDBPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/N8nPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/GiteaPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MailHogPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MiniQRPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ExcalidrawPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/VaultwardenPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/RocketChatPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/GancioPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/JitsiMeetPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/SettingsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NavigationSettingsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/PangolinPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ObservabilityPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsAnalyticsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsCommentsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PaymentsDashboardPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/SubscribersPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/ProductsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/DonationsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/DonationPagesPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PlansPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PaymentSettingsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/LibraryPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/AnalyticsDashboardPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/MediaJobsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/CommentModerationPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/GalleryAdsPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/AdAnalyticsDashboardPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/CampaignModerationPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/CampaignEffectivenessPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/LandingPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PagesIndexPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/EventsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/HomePage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CampaignsListPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CampaignPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CreateCampaignPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyCampaignsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ResponseWallPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MapPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShiftsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaGalleryPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShortsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaViewerPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistBrowsePage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistViewerPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/PlaylistManagementPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyStatsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MySettingsPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerChatPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PricingPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShopPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ProductDetailPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlanDetailPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonatePage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonationPagesListPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PaymentSuccessPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyActivityPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerShiftsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyRoutesPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerMapPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialProfilePage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/NotificationsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialFeedPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/DiscoverPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/GroupDetailPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/AchievementsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/api.ts:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/roles.ts:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/QuickJoinPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/VerifyEmailPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ResetPasswordPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsDashboardPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsContactsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsCampaignsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsConversationsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsTemplatesPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsSetupPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/PeoplePage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ContactProfilePage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialDashboardPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialGraphPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialModerationPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ReferralAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SpotlightAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ChallengesAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/ImpactStoriesPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ReferralsPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengesPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengeDetailPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/WallOfFamePage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MeetingJoinPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingPlannerPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingAgendaPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ActionItemsPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/SchedulingPollPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PollsListPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/JitsiAuthPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/SchedulingCalendarPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/AdminCalendarViewPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/TicketedEventsPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/EventDetailPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/CheckInScannerPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketedEventDetailPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketConfirmationPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyTicketsPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyCalendarPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarsPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarViewPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendCalendarPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NotFoundPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/command-palette/CommandPalette.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/api.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/VolunteerFooterNav.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/PublicNavBar.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useSSE.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useLocalStorage.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/service-url.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/nav-defaults.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/command-palette.store.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/favorites.store.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/menu-items.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/chat/RocketChatWidget.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaSidebar.tsx:0
[ 1679830ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaBottomNav.tsx:0
[ 1679830ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/ChatNotificationToast.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBarContext.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBar.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useChatNotifications.ts:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/color.ts:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/AuthModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/public/NewsletterSignup.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/CampaignEmailsDrawer.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/ExportContactsModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/QrCodeModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoPickerModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemGauges.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MiniDonutChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RequestTrafficChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/LatencyBandsChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerPopover.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerMemoryChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ActivityFeedCard.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TodayEventsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ChatNotifierCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TopVideosCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentCommentsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DocsAnalyticsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingShiftsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MyActionItemsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/CampaignEffectivenessCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentSignupsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/NewsletterStatsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DonationSummaryCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemAlertsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/GiteaActivityCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/VaultwardenAdoptionCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingMeetingsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/CutCampaignAnalyticsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/TestEmailModal.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/VersionHistoryDrawer.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/EmailTemplateEditor.tsx:0
[ 1685249ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/settings:0
[ 1685251ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/auth/me:0
[ 1685252ms] [ERROR] Unexpected auth error: AxiosError: Request failed with status code 500
at settle (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1281:12)
at XMLHttpRequest.onloadend (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1638:7)
at Axios.request (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:2255:41)
at async Object.fetchMe (http://localhost:3002/src/stores/auth.store.ts:101:28)
at async hydrate (http://localhost:3002/src/stores/auth.store.ts:118:11) @ http://localhost:3002/src/stores/auth.store.ts:105
[ 1685344ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/payments/plans:0
[758179964ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Connection closed before receiving a handshake response @ http://localhost:3002/@vite/client:1034
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyCampaignsPage.tsx:0
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ResponseWallPage.tsx:0
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MapPage.tsx:0
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShiftsPage.tsx:0
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaGalleryPage.tsx:0
[758181441ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShortsPage.tsx:0
[758181441ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaViewerPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistBrowsePage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistViewerPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/PlaylistManagementPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyStatsPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MySettingsPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerChatPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PricingPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShopPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ProductDetailPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlanDetailPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonatePage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonationPagesListPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PaymentSuccessPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyActivityPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerShiftsPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyRoutesPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerMapPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendsPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialProfilePage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/NotificationsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialFeedPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/DiscoverPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/GroupDetailPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/AchievementsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/api.ts:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/roles.ts:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/QuickJoinPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/VerifyEmailPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ResetPasswordPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsDashboardPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsContactsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsCampaignsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsConversationsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsTemplatesPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsSetupPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/PeoplePage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ContactProfilePage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialDashboardPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialGraphPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialModerationPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ReferralAdminPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SpotlightAdminPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ChallengesAdminPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/ImpactStoriesPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ReferralsPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengesPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengeDetailPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/WallOfFamePage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MeetingJoinPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingPlannerPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingAgendaPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ActionItemsPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/SchedulingPollPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PollsListPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/JitsiAuthPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/SchedulingCalendarPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/AdminCalendarViewPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/TicketedEventsPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/EventDetailPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/CheckInScannerPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketedEventDetailPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketConfirmationPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyTicketsPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyCalendarPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarsPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarViewPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendCalendarPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NotFoundPage.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/command-palette/CommandPalette.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/api.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/VolunteerFooterNav.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/PublicNavBar.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useSSE.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useLocalStorage.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/service-url.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/nav-defaults.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/command-palette.store.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/favorites.store.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/menu-items.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/chat/RocketChatWidget.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaSidebar.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaBottomNav.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/ChatNotificationToast.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBarContext.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBar.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useChatNotifications.ts:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/color.ts:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/AuthModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/public/NewsletterSignup.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/CampaignEmailsDrawer.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/ExportContactsModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/QrCodeModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoPickerModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/TestEmailModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/VersionHistoryDrawer.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/EmailTemplateEditor.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/map/CutEditorMap.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemGauges.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MiniDonutChart.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RequestTrafficChart.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/LatencyBandsChart.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerPopover.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerMemoryChart.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ActivityFeedCard.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TodayEventsCard.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ChatNotifierCard.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TopVideosCard.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentCommentsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DocsAnalyticsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingShiftsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MyActionItemsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/CampaignEffectivenessCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentSignupsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/NewsletterStatsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DonationSummaryCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemAlertsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/GiteaActivityCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/VaultwardenAdoptionCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingMeetingsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/CutCampaignAnalyticsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/shifts/EditModeModal.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/shifts/ShiftsCalendar.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/map/AdminMapView.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/map/AreaImportWizard.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/canvass.ts:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/AdminLiveMap.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/HistoricalRoutesDrawer.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/CanvassTrendsCard.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useMkDocsBuild.ts:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/landing-pages/LandingPageEditor.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useDocsEditor.ts:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/docs/MobileDocsEditor.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PhotoPickerModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PhotoInsertModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/videoCardHtml.ts:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/photoCardHtml.ts:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/DonateInsertModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/ProductInsertModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AdPickerModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/scheduling/PollInsertModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useDocsCollaboration.ts:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/docs/CollaboratorAvatars.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/wikiLinkCompletion.ts:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/docs/WikiLinkPickerModal.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/observability/ServiceStatusCard.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/observability/MetricsGrid.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/observability/AlertsTable.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/observability/IframeErrorBoundary.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/media-api.ts:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standalone-tokens.css:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/aria/aria.css:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/codeEditor/editor.css:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/scrollbar/media/scrollbars.css:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/blockDecorations/blockDecorations.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/decorations/decorations.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/glyphMargin/glyphMargin.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/indentGuides/indentGuides.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/lineNumbers/lineNumbers.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/mouseCursor/mouseCursor.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/viewLines/viewLines.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/linesDecorations/linesDecorations.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/margin/margin.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/marginDecorations/marginDecorations.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/minimap/minimap.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/overlayWidgets/overlayWidgets.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/rulers/rulers.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/selections/selections.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/viewCursors/viewCursors.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/whitespace/whitespace.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/gpu/css/media/decorationCssRuleExtractor.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/controller/editContext/native/nativeEditContext.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/gpuMark/gpuMark.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/platform/hover/browser/hover.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/hover/hoverWidget.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/contextview/contextview.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/selectBox/selectBox.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/list/list.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/dnd/dnd.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/selectBox/selectBoxCustom.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/actionbar/actionbar.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/dropdown/dropdown.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/platform/actions/browser/menuEntryActionViewItem.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/standalone/browser/quickInput/standaloneQuickInput.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/toggle/toggle.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/platform/quickinput/browser/media/quickInput.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/button/button.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/countBadge/countBadge.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/progressbar/progressbar.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/inputbox/inputBox.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/findinput/findInput.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/iconLabel/iconlabel.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/keybindingLabel/keybindingLabel.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/tree/media/tree.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/sash/sash.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/splitview/splitview.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/table/table.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/toolbar/toolbar.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/diffEditor/style.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/markdownRenderer/browser/renderedMarkdown.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/multiDiffEditor/style.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useDebounce.ts:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoCard.tsx:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PhotoCard.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AlbumCard.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/BulkActionsBar.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PublishModal.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/DeleteConfirmModal.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/UploadVideoDrawer.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/UploadPhotoDrawer.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoViewerModal.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PhotoViewerModal.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/EditPhotoModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AlbumDetailDrawer.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/CreateAlbumModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/QuickAnalyticsModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/SchedulePublishModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/ScheduleCalendarDrawer.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/EditVideoModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/FetchVideosDrawer.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AddToPlaylistModal.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/BulkAddToPlaylistModal.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/BulkAccessLevelModal.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/gallery-ads.ts:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/GalleryAdCard.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoPlayer.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AdvancedVideoPlayer.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/DonationWidget.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/PricingWidget.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/ProductWidget.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/influence/CampaignFormWidget.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/scheduling/SchedulingPollWidget.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useDocumentTitle.ts:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/usePageAds.ts:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/AdBanner.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/calendar/UnifiedCalendar.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/public/ShiftSignupModal.tsx:0

View File

@ -0,0 +1,50 @@
[ 840406ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1800416ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2760412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 3720412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4680414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5640416ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 6600415ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 7560413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 8520411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 9480406ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[10440412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[11400412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[12360413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[13320416ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[14280412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[15240418ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[16200413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[17160406ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[18120407ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[19080428ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[20040413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21000417ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21960412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[22920413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23880411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24840409ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[25800407ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[26760411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[27720411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[28680412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[29640407ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[30600412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[31560405ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[32520418ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[33480412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[34440414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[35400411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[36360450ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[37320412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[38280418ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[39240414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[40200413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[41160456ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[42120417ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[43080416ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[44040414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[45000413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[45960411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[46920413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[47880405ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

291
CLAUDE.md
View File

@ -6,23 +6,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
**Current state:** V2 rebuild substantially complete (merged to `main`). Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
**Current state:** V2 rebuild substantially complete on the `v2` branch. Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
**Status Summary:**
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
- ✅ Drizzle to Prisma Migration Complete (single-ORM, Feb 2026)
- ✅ Automated Pangolin Setup (one-command tunnel deployment)
- ✅ 3 Security Audits Complete (Feb 2025 + Mar 22/27/30 2026)
- ✅ Social Connections + Calendar (friendship, shared views, availability finder)
- ✅ Payments + Ticketed Events (Stripe integration, check-in scanner)
- ✅ Meeting Planner + Straw Polls (scheduling, voting)
- ✅ SMS Campaign Connector (Termux Android bridge)
- ✅ Docs CMS (blog authoring, access policies, collaboration, version history)
- ✅ User Provisioning Framework (Gitea, Vaultwarden, Listmonk)
- ✅ Granular Admin Roles (9 admin roles + module-specific RBAC)
- ✅ Collaborative Docs Editing (Y.js CRDT + Hocuspocus)
- ✅ Engagement Scoring + EventBus + Gitea SSO
- ✅ MCP Server (Claude Code integration, 27 core + 6 on-demand packs (~65 tools))
- ✅ Security Audit Complete (13 findings addressed, Feb 2026)
- ✅ NAR 2025 Server Import (Canadian electoral data)
- ✅ Media Manager Integration (dual API architecture)
- ✅ Email Templates System
- ✅ Data Quality Dashboard
- ✅ Observability Dashboard
- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026)
- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026)
- ✅ **Migration Drift Fixed** (Baseline catch-up migration, 14 migrations cover full schema, Feb 2026)
- 🚧 Phase 15 (Testing + Polish) - Next
---
@ -63,9 +59,10 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
changemaker.lite/
├── api/ # Dual API servers (Express + Fastify)
│ ├── prisma/
│ │ ├── schema.prisma # 192 models: User, Campaign, Location, Shift, Payment, Social, etc.
│ │ ├── migrations/ # 50 Prisma migrations (full schema history)
│ │ ├── schema.prisma # 30+ models: User, Campaign, Location, Shift, etc.
│ │ ├── migrations/ # Prisma migration history
│ │ └── seed.ts # Admin user, settings, page blocks
│ ├── drizzle/ # Media tables (Drizzle ORM)
│ ├── Dockerfile.media # Fastify media server container
│ └── src/
│ ├── server.ts # Express API entry point (port 4000)
@ -73,10 +70,10 @@ changemaker.lite/
│ ├── config/
│ │ └── env.ts # Zod-validated environment config (100+ vars)
│ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler
│ ├── modules/ # 44 modules total
│ ├── modules/
│ │ ├── auth/ # JWT login, register, refresh, logout
│ │ ├── users/ # User CRUD + pagination + search
│ │ ├── settings/ # Site settings singleton (20+ feature flags)
│ │ ├── settings/ # Site settings singleton
│ │ ├── services/ # Service health checks
│ │ ├── influence/
│ │ │ ├── campaigns/ # Campaign CRUD + public routes
@ -93,39 +90,16 @@ changemaker.lite/
│ │ │ ├── canvass/ # Canvassing sessions + visits + routes
│ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes)
│ │ │ └── settings/ # Map settings singleton
│ │ ├── pages/ # Landing page CRUD + block library + public renderer
│ │ ├── pages/
│ │ │ ├── pages-admin.routes.ts # Landing page CRUD
│ │ │ ├── pages-public.routes.ts # Public page renderer
│ │ │ └── blocks.routes.ts # Block library API
│ │ ├── email-templates/ # Email template CRUD + rendering
│ │ ├── media/ # Fastify media API (videos, reactions, jobs, analytics)
│ │ ├── social/ # Friendships, challenges, spotlights, referrals
│ │ ├── calendar/ # Calendar layers, items, shared views, availability
│ │ ├── payments/ # Stripe products, donations, subscriptions
│ │ ├── ticketed-events/ # Event ticketing, tiers, check-in
│ │ ├── sms/ # SMS campaigns via Termux Android bridge
│ │ ├── meeting-planner/ # Meeting scheduling with polls
│ │ ├── meetings/ # Meeting agendas, minutes, action items
│ │ ├── polls/ # Straw polls with comments + voting
│ │ ├── docs/ # MkDocs health checks + export routes
│ │ ├── docs-analytics/ # Docs page view tracking
│ │ ├── docs-comments/ # Gitea-backed comments on docs
│ │ ├── people/ # CRM people module
│ │ ├── events/ # Gancio event integration
│ │ ├── newsletter/ # Newsletter management
│ │ ├── media/ # Fastify media API (videos, reactions, jobs)
│ │ ├── listmonk/ # Newsletter sync admin routes
│ │ ├── pangolin/ # Tunnel management (Newt integration)
│ │ ├── rocketchat/ # Rocket.Chat integration
│ │ ├── jitsi/ # Jitsi video conferencing auth
│ │ ├── registry/ # Docker image registry management
│ │ ├── upgrade/ # Auto-upgrade checks + deployment
│ │ ├── gitea-setup/ # Gitea SSO + API token management
│ │ ├── volunteer-invite/ # Invite codes + setup workflows
│ │ ├── gallery-ads/ # Media gallery ads
│ │ ├── homepage/ # Homepage stats + dashboard
│ │ ├── search/ # Cross-module search
│ │ ├── reports/ # Analytics + reporting
│ │ ├── og/ # Open Graph metadata
│ │ ├── docs/ # MkDocs + Code Server health checks
│ │ ├── qr/ # QR code PNG generation (public)
│ │ ├── dashboard/ # Admin dashboard data
│ │ ├── activity/ # Activity feed
│ │ └── observability/ # Prometheus/Grafana/Alertmanager integration
│ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker
│ ├── types/ # express.d.ts (Request augmentation)
@ -145,50 +119,34 @@ changemaker.lite/
│ │ ├── media/ # VideoCard, BulkActions, gallery components
│ │ ├── email-templates/ # Email template components
│ │ └── observability/ # Monitoring components
│ ├── pages/ # 52 root pages + 8 subdirectories
│ │ ├── influence/ # Campaign moderation, effectiveness, impact stories, straw polls
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard
│ │ ├── media/ # Library, Playlists, Analytics, Gallery Ads, Comment Moderation
│ │ ├── payments/ # Dashboard, Products, Plans, Donations, Subscribers, Settings
│ │ ├── social/ # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges
│ │ ├── sms/ # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup
│ │ ├── events/ # Ticketed Events, Event Detail, Check-in Scanner
│ │ ├── volunteer/ # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements
│ │ ├── public/ # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet
│ │ └── (root) # Dashboard, Users, Settings, Docs*, MeetingPlanner, Observability, etc.
│ ├── stores/ # 9 Zustand stores (auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking)
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts, nav-defaults.ts, service-url.ts, y-textarea.ts
│ ├── pages/
│ │ ├── auth/ # LoginPage
│ │ ├── influence/ # CampaignsPage, ResponsesPage, RepresentativesPage, EmailQueuePage
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboardPage
│ │ ├── volunteer/ # VolunteerMapPage, VolunteerShiftsPage, MyActivityPage, MyRoutesPage
│ │ ├── public/ # CampaignsListPage, CampaignPage, ResponseWallPage, MapPage, ShiftsPage, LandingPage, MediaGalleryPage, MediaViewerPage
│ │ ├── media/ # LibraryPage, SharedMediaPage, MediaJobsPage, AnalyticsDashboardPage
│ │ ├── services/ # MiniQRPage, MailHogPage, CodeEditorPage, N8nPage, GiteaPage, NocoDBPage
│ │ └── (root) # DashboardPage, UsersPage, SettingsPage, CanvassDashboardPage, WalkSheetPage, CutExportPage, LandingPagesPage, PageEditorPage, EmailTemplatesPage, ListmonkPage, PangolinPage, ObservabilityPage
│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand)
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios)
│ ├── hooks/ # useDebounce, useLocalStorage
│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces)
├── mcp-server/ # Claude Code MCP server (27 core + 6 on-demand packs (~65 tools))
├── media-manager/ # Legacy media manager (reference)
├── nginx/ # Reverse proxy config (subdomain routing + CSP)
├── configs/ # Prometheus, Grafana, Alertmanager, Pangolin configs
├── configs/ # Prometheus, Grafana, Alertmanager configs
├── scripts/ # Deployment, backup, upgrade, registry scripts
│ ├── install.sh # Curl-friendly installer (downloads tarball + runs config.sh)
│ ├── uninstall.sh # Remove containers, volumes, and install dir
│ ├── build-and-push.sh # Build production images → push to Gitea registry
│ ├── build-release.sh # Package runtime files into release tarball
│ ├── mirror-images.sh # Mirror third-party images to Gitea
│ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode)
│ ├── upgrade-check.sh # Check for updates (git or Gitea API)
│ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades
│ ├── update-env.sh # Merge new variables from .env.example into existing .env
│ ├── backup.sh / restore.sh # PostgreSQL + Listmonk + uploads backup/restore
│ ├── validate-env.sh # Required env variable validation
│ ├── validate-compose-parity.sh # Check docker-compose.yml ↔ docker-compose.prod.yml parity
│ ├── test-deployment.sh # Post-deploy smoke tests (auth, services, health)
│ ├── register-with-ccp.sh # Register instance with a Control Panel via invite code
│ ├── ccp-deregister.sh # Deregister instance from its CCP
│ ├── pangolin-teardown.sh # Delete Pangolin resources/sites (dry-run by default)
│ ├── gitea-init.sh # Bootstrap Gitea admin user + SSO app
│ ├── nocodb-init.sh # Bootstrap NocoDB project + base connection
│ ├── mkdocs-entrypoint.sh # MkDocs container entrypoint (live + built modes)
│ ├── mkdocs-build-trigger.py # Trigger MkDocs rebuild from API hooks
│ ├── legacy/ # Archived Cloudflare tunnel configs (pre-Pangolin)
│ └── systemd/ # Systemd unit files (backup timer, upgrade watcher)
├── docker-compose.yml # V2 orchestration (40+ services)
├── docker-compose.prod.yml # Production (image-only, no source mounts)
│ └── backup.sh # PostgreSQL + Listmonk + uploads backup
├── docker-compose.yml # V2 orchestration (20+ services)
├── docker-compose.v1.yml # V1 backup (reference)
├── .env.example # All required environment variables
└── V2_PLAN.md # Full 14-phase roadmap
```
@ -202,7 +160,7 @@ changemaker.lite/
The fastest way to deploy. No source code, no compilation:
```bash
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash
```
This downloads a ~9MB release tarball, runs the config wizard, and sets `IMAGE_TAG=latest`. Then:
@ -215,10 +173,11 @@ Pre-built images are pulled from `gitea.bnkops.com/admin` (~2 min). Database mig
### Source Install (Development)
1. **Clone repository:**
1. **Clone repository and checkout v2 branch:**
```bash
git clone <repo-url> changemaker.lite
cd changemaker.lite
git checkout v2
```
2. **Create environment file:**
@ -280,34 +239,26 @@ cd api && npm run dev:media
|---------|-----|---------------------|
| Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env |
| API | http://localhost:4000 | - |
| Media API | http://localhost:4100 | - |
| NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_ADMIN_PASSWORD` in .env |
| Gitea | http://localhost:3030 | See `GITEA_ADMIN_USER`/`GITEA_ADMIN_PASSWORD` in .env |
| MailHog | http://localhost:8025 | - |
| Grafana | http://localhost:3001 | admin / admin |
| Prometheus | http://localhost:9090 | - |
| Listmonk | http://localhost:9001 | See `LISTMONK_WEB_ADMIN_USER`/`PASSWORD` in .env |
| Rocket.Chat | http://localhost:3100 | See RC env vars in .env |
| Excalidraw | http://localhost:8090 | - |
| Vaultwarden | http://localhost:8093 | See `VAULTWARDEN_ADMIN_TOKEN` in .env |
### Feature Flags
Most features are toggled via **SiteSettings** in the database (admin Settings page). Some also have `.env` overrides:
Enable optional features in `.env`:
```bash
# .env feature flags (env-level)
ENABLE_MEDIA_FEATURES=true # Media manager
ENABLE_HLS_TRANSCODE=true # HLS adaptive bitrate transcoding (off by default)
ENABLE_PAYMENTS=true # Stripe integration
ENABLE_SMS=true # SMS campaigns
ENABLE_CHAT=true # Rocket.Chat
ENABLE_MEET=true # Jitsi meetings
LISTMONK_SYNC_ENABLED=true # Newsletter sync
EMAIL_TEST_MODE=true # MailHog vs SMTP
```
# Media Manager
ENABLE_MEDIA_FEATURES=true
**Database feature flags (SiteSettings):** `enableInfluence`, `enableMap`, `enableNewsletter`, `enableLandingPages`, `enableMediaFeatures`, `enablePayments`, `enableGalleryAds`, `enableChat`, `enableEvents`, `enableDocsComments`, `enableSms`, `enablePeople`, `enableSocial`, `enableMeet`, `enableMeetingPlanner`, `enableTicketedEvents`, `enableSocialCalendar`, `enablePolls`, `enableDocsCollaboration`, `enableUserProvisioning`
# Listmonk Newsletter Sync
LISTMONK_SYNC_ENABLED=true
# Email Test Mode (sends to MailHog instead of SMTP)
EMAIL_TEST_MODE=true
```
---
@ -322,6 +273,7 @@ cd api && npm run dev:media # Fastify media dev server (port 4100)
cd api && npx tsc --noEmit # Type-check
cd api && npx prisma migrate dev # Run/create Prisma migrations
cd api && npx prisma studio # Browse database
cd api && npx drizzle-kit push # Push Drizzle schema changes (media)
```
### Admin Development
@ -344,6 +296,7 @@ docker compose logs -f media-api
# Database operations
docker compose exec api npx prisma migrate dev
docker compose exec api npx drizzle-kit push
# Stop services
docker compose down
@ -368,7 +321,7 @@ docker compose down
./scripts/upgrade.sh --use-registry --force --skip-backup
# Install from tarball (end-user one-liner)
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash
```
**Two compose files:**
@ -490,13 +443,9 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
**Files:**
- `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics)
- `api/src/modules/media/services/` — FFprobe, thumbnail, **HLS transcode** services
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload, **HLS streaming**
- `api/src/modules/media/services/` — FFprobe, video analytics service
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload
- `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing
- `api/src/services/hls-transcode-queue.service.ts` — BullMQ queue for HLS adaptive bitrate transcoding (concurrency 1, in-process worker)
- `api/src/modules/media/routes/hls.routes.ts` — Master/variant playlist + segment serving with signed URLs
- `api/scripts/backfill-hls.ts` — Backfill HLS for pre-existing videos (`npm run backfill:hls`)
- `admin/src/lib/use-hls.ts` — React hook attaching hls.js (Chrome/FF/Edge) or native (Safari/iOS)
- `admin/src/lib/media-api.ts` — Dedicated axios instance for Media API
- `admin/src/pages/media/LibraryPage.tsx` — Video library with quick actions + calendar
- `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard
@ -504,15 +453,7 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- `admin/src/pages/public/MediaGalleryPage.tsx` — Public video gallery
- `admin/src/components/media/` — VideoCard, VideoActions, modals, charts
**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts, **HLS adaptive bitrate streaming (360p/720p/1080p, MP4 fallback)**.
**HLS adaptive bitrate streaming:**
- On upload, a BullMQ `hls-transcode` job runs FFmpeg to produce a master playlist + 3 keyframe-aligned variants under `/media/local/hls/{videoId}/`. Concurrency is 1; the worker runs in-process with the media-api Fastify server.
- Player prefers HLS over MP4 when `Video.hlsStatus === 'READY'`. MP4 streaming routes stay as the always-on fallback for un-transcoded videos and for hover-preview cards (where 200ms hls.js init defeats the UX — `PublicVideoCard` stays MP4).
- `useHls()` hook lazy-imports hls.js (~75 KB gzipped, never enters main bundle), uses native HLS on Safari/iOS, gives up after 2 NETWORK_ERROR retries so the MP4 fallback can kick in.
- Manifest URLs are HMAC-signed (`?sig=&exp=&uid=`) per existing `signMediaPath()` pattern. Variant playlists rewrite their segment URIs server-side at fetch time so each segment carries a fresh signature.
- Feature flag: `ENABLE_HLS_TRANSCODE` (default `false`). When off, uploads are tagged `SKIPPED` and the player falls back to MP4 — fully reversible. The worker stays registered so existing `PENDING` jobs still process if the flag flips back on.
- Backfill: `docker compose exec api npm run backfill:hls` enqueues all `hlsStatus IS NULL` videos. Bypasses the flag (operator opt-in). At ~2 min per 1080p video, throughput is ~30/hour.
**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts
**Routes:**
- Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs`
@ -531,12 +472,10 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- `api/src/services/pangolin.client.ts` — Pangolin Integration API client
- `api/src/modules/pangolin/pangolin.routes.ts` — Tunnel management routes (includes `/setup-automated`)
- `admin/src/pages/PangolinPage.tsx` — Setup wizard + status dashboard + automated setup button
- `scripts/register-with-ccp.sh` — Register this instance with a Control Panel (CCP) using an invite code
- `scripts/pangolin-teardown.sh` — Delete all Pangolin resources/sites for an org (dry-run by default, idempotent)
- `scripts/ccp-deregister.sh` — Deregister instance from its CCP
- `scripts/pangolin-setup.sh` — CLI wrapper for automated setup
- `configs/pangolin/resources.yml` — Central resource definitions (12 services)
- Newt container integration (Cloudflare alternative)
- **Automated setup:** One-command deployment via CCP registration (creates site, updates .env, restarts Newt)
- **Automated setup:** One-command deployment (creates site, updates .env, restarts Newt)
- **Continuous sync:** Hourly resource sync via nginx cron job
**MkDocs + Code Server:**
@ -575,25 +514,20 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
| **Core Services** | | |
| 3000 | Admin GUI | Vite dev / React production |
| 4000 | Express API | Main V2 API (Prisma) |
| 4100 | Fastify Media API | Video library (Prisma) |
| 4100 | Fastify Media API | Video library (Drizzle) |
| 5433 | V2 PostgreSQL | Localhost (container: 5432) |
| 6379 | Redis | Cache, rate limit, BullMQ |
| **Supporting Services** | | |
| 3001 | Grafana | Metrics visualization |
| 3010 | Homepage | Service dashboard |
| 3030 | Gitea | Git hosting + SSO |
| 3100 | Rocket.Chat | Team chat (embed proxy) |
| 3030 | Gitea | Git hosting |
| 4001 | MkDocs Site | Served docs |
| 4003 | MkDocs Dev | Live preview |
| 5432 | Listmonk PostgreSQL | Listmonk DB |
| 5678 | n8n | Workflow automation |
| 8025 | MailHog | Email capture (dev) |
| 8089 | Mini QR | QR generator |
| 8090 | Excalidraw | Collaborative whiteboard |
| 8091 | NocoDB | Data browser |
| 8092 | Gancio | Event management |
| 8093 | Vaultwarden | Password manager |
| 8443 | Jitsi Web | Video conferencing |
| 8885 | Mini QR Proxy | Iframe-friendly |
| 8888 | Code Server | Web IDE |
| 9001 | Listmonk | Newsletter platform |
@ -618,17 +552,11 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
| `docs.cmlite.org` | MkDocs (4003) | Docs site |
| `code.cmlite.org` | Code Server (8888) | Web IDE |
| `n8n.cmlite.org` | n8n (5678) | Workflow automation |
| `git.cmlite.org` | Gitea (3030) | Git hosting + SSO |
| `git.cmlite.org` | Gitea (3030) | Git hosting |
| `home.cmlite.org` | Homepage (3010) | Dashboard |
| `grafana.cmlite.org` | Grafana (3001) | Metrics viz |
| `listmonk.cmlite.org` | Listmonk (9001) | Newsletters |
| `qr.cmlite.org` | Mini QR (8089) | QR generator |
| `chat.cmlite.org` | Rocket.Chat (3100) | Team chat |
| `meet.cmlite.org` | Jitsi (8443) | Video conferencing |
| `events.cmlite.org` | Gancio (8092) | Event management |
| `draw.cmlite.org` | Excalidraw (8090) | Collaborative whiteboard |
| `vault.cmlite.org` | Vaultwarden (8093) | Password manager |
| `mail.cmlite.org` | MailHog (8025) | Email capture (dev) |
| `cmlite.org` | MkDocs Static (4004) | **Documentation/marketing site only** |
**Clean separation:** Root domain (`${DOMAIN}`) serves MkDocs documentation site. All application functionality (admin GUI, public campaigns, map, shifts, media gallery) is accessible via `app.${DOMAIN}` subdomain. This provides clear separation between public documentation and the application.
@ -637,7 +565,7 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
## Common Patterns
**Note:** Below are the key development patterns for this project.
**Note:** See `MEMORY.md` for comprehensive development patterns, gotchas, and lessons learned. Below are V2-specific patterns only.
### API Router Structure
- Service layer (`*.service.ts`) — business logic, database queries
@ -652,57 +580,47 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- Login redirects: ADMIN_ROLES → `/app`, USER/TEMP → `/volunteer`
### Frontend Architecture
- Admin pages: `admin/src/pages/` + subdirs (AppLayout)
- Admin pages: `admin/src/pages/` (AppLayout)
- Public pages: `admin/src/pages/public/` (PublicLayout, dark theme)
- Volunteer pages: `admin/src/pages/volunteer/` (VolunteerLayout)
- Zustand stores (9): auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking
- Zustand stores: `auth.store.ts`, `canvass.store.ts`
- API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts`
### Database ORM
- **Prisma** (both APIs): 192 models in single `schema.prisma`. Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays
### Database ORMs
- **Prisma** (main API): Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays
- **Drizzle** (media API): Separate schema file, push with `npx drizzle-kit push`, no migrations generated
### Prisma Migration Workflow
- **Always use `prisma migrate dev`** for schema changes (not `prisma db push`) — `db push` applies changes directly but doesn't create migration files, causing drift
- **Migration history:** 50 migrations in `api/prisma/migrations/` fully cover the schema
- **Migration history:** 14 migrations in `api/prisma/migrations/` fully cover the schema (baseline catch-up applied Feb 2026)
- **Fixing drift:** Use `prisma migrate diff --from-migrations ... --to-schema-datamodel ... --script` with a shadow DB to generate catch-up SQL, then `prisma migrate resolve --applied`. See MEMORY.md for detailed steps
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`)
### Key Gotchas
### V2-Specific Gotchas
- **Prisma migrations:** Never use `db push` — always `migrate dev` to keep history in sync
- Nginx media API block must come BEFORE general API block
- `IMAGE_TAG=local` (default) never pulls from registry; set to SHA or `latest` for pre-built images
- **Release vs source installs:** Detected by `VERSION` file + absence of `.git/`; release uses `docker-compose.prod.yml`, source uses `docker-compose.yml`
- **`api/dist/` is gitignored** — never commit; if root-owned from container builds, fix with `chown`
- **`!` in passwords** triggers bash history expansion — use Write tool to write JSON to file, then `curl -d @file`
- **Port mappings:** API container 4000 → host 4002, Admin container 3000 → host 3002
- **BullMQ** needs its own Redis connections (pass URL string, not shared ioredis instance)
- **Public pages** use `axios` directly (no auth interceptor), admin pages use `{ api }` from lib
- **Prisma JSON fields:** typed arrays need `as unknown as Prisma.InputJsonValue` cast
- **nginx conf.d files** have `.template` counterparts used by envsubst at startup
- See MEMORY.md "Common Gotchas" for additional gotchas (ports, volumes, media upload, registry, etc.)
---
## Security & Configuration
### Security Audits
Four security audits completed. See audit reports for full details:
- **Feb 2025:** 13 findings (password policy, rate limits, token rotation, XSS prevention). `SECURITY_AUDIT_2025-02-11.md`
- **Mar 22 2026:** JWT algorithm lockdown, invite secret separation, webhook hardening, CSV injection, QR DoS
- **Mar 27 2026:** 33 findings (30 fixed) — IDOR, XSS, path traversal, MongoDB auth, SSTI, open redirect
- **Mar 30 2026:** 19 findings — IDOR action items/ticketed events, nginx rate limit, JWT secret reuse
### Security Audit
Comprehensive security audit completed 2025-02-11, addressing 13 findings. See `SECURITY_AUDIT_2025-02-11.md` for full report.
**Key security features:**
**Key improvements:**
- Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced)
- Rate limits on auth endpoints (10/min per IP) + nginx rate limiting
- Refresh token rotation (atomic Prisma transaction)
- JWT algorithm locked to HS256, separate invite secret
- Rate limits on auth endpoints (10/min per IP)
- Refresh token rotation (atomic transaction)
- User enumeration prevention (401 not 404)
- Redis authentication required
- XSS/injection prevention (HTML escaping, DOMPurify, SSTI protection)
- Path traversal protection (resolve + startsWith checks)
- Encryption key for DB secrets (`ENCRYPTION_KEY` required in all environments)
- Nginx security headers (HSTS, Permissions-Policy, CSP, X-Forwarded-For)
- MongoDB keyfile authentication
- httpOnly cookies for refresh tokens
- XSS/injection prevention (HTML escaping)
- Path traversal protection
- Encryption key for DB secrets (`ENCRYPTION_KEY` required in production)
- Nginx security headers (HSTS, Permissions-Policy, CSP)
### Required Environment Variables
See `.env.example` for all 100+ variables. Critical ones:
@ -725,8 +643,8 @@ See `.env.example` for all 100+ variables. Critical ones:
When deploying to a production domain via Pangolin tunnel, you MUST update the `.env` file to include the production domain in `CORS_ORIGINS`:
```bash
# Example for cmlite.org
CORS_ORIGINS=http://app.cmlite.org,https://app.cmlite.org,http://localhost:3000,http://localhost
# Example for betteredmonton.org
CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost
# Also set production mode
NODE_ENV=production
@ -755,16 +673,18 @@ docker compose restart api
4. Save changes
**Critical resources to fix first:**
- `api.${DOMAIN}` - Main API (all endpoints fail without this)
- `app.${DOMAIN}` - Admin GUI + public pages
- `media.${DOMAIN}` - Media API
- `api.betteredmonton.org` - Main API (all endpoints fail without this)
- `app.betteredmonton.org` - Admin GUI + public pages
- `media.betteredmonton.org` - Media API
**Verification:**
```bash
# Should return JSON, NOT a 302 redirect
curl https://api.cmlite.org/api/health
curl https://api.betteredmonton.org/api/health
```
**See Also:** `PRODUCTION_403_FIX.md` for detailed step-by-step instructions.
### CORS Errors in Production
**Symptom:** Browser console shows CORS errors when accessing production domain.
@ -783,46 +703,29 @@ Check in order:
### Database/Redis Connection Failures
Check container status (`docker compose ps`), verify credentials in `.env`, check logs (`docker compose logs <service> --tail 50`). Test DB: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`. Test Redis: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`.
### Video Stuck in HLS PROCESSING / FAILED with EACCES
**Symptom:** A video shows `hlsStatus = 'PROCESSING'` for many minutes; or `'FAILED'` with `hls_transcode_error LIKE '%EACCES%'`. Player keeps falling back to MP4.
Check in order:
1. **First-run perms.** If `hls_transcode_error` contains `EACCES: permission denied, mkdir '/media/local/hls/<id>'`, the bind-mount got created as `root:root` but the Node process runs as `node` (UID 1000). One-time fix:
```
docker compose exec -u 0 media-api chown -R 1000:1000 /media/local/hls
```
Then reset and re-enqueue:
```
docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -c "UPDATE videos SET hls_status = NULL, hls_transcode_error = NULL WHERE hls_status = 'FAILED';"
docker compose exec api npm run backfill:hls
```
2. **Worker running:** `docker compose logs media-api --tail 100 | grep -i hls` — expect `[hls]` lines for the queue worker startup and per-job progress.
3. **FFmpeg in container:** `docker compose exec media-api ffmpeg -version` — should print FFmpeg version. (Already in `Dockerfile.media`.)
4. **Queue depth:** `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD LLEN bull:hls-transcode:wait` — non-zero means jobs are queued behind a slow one.
5. **Disk space at output:** `docker compose exec media-api df -h /media/local/hls` — transcoding can consume several GB per video.
6. **Failure record:** `docker compose exec api npx prisma studio` → Video table → check `hlsTranscodeError`.
To force a re-transcode of a failed video, set `hlsStatus = NULL` in the DB and run `npm run backfill:hls`.
---
## V1 Reference (Legacy)
V1 code has been removed from the repo. History preserved as `v1-archive` git tag. `docker-compose.v1.yml` remains as reference only.
V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two independent Express apps using NocoDB REST API. See individual README files for V1 documentation:
- `influence/README.MD` — Features, config, campaign management
- `map/README.md` — Features, config, setup instructions
- Both use session-based auth, bcryptjs passwords, Bull job queues
---
## Key Configuration Files
### Infrastructure
- `docker-compose.yml` — Development orchestration (build blocks + source mounts, 40+ services)
- `docker-compose.yml` — Development orchestration (build blocks + source mounts, 20+ services)
- `docker-compose.prod.yml` — Production orchestration (image-only, no source mounts, `IMAGE_TAG:-latest`)
- `.env` / `.env.example` — Environment variables (100+ vars)
- `config.sh` — Interactive setup wizard (14 steps, release-mode aware)
### Database
- `api/prisma/schema.prisma` — Main schema (192 Prisma models)
- `api/prisma/migrations/` — 50 migration files (full schema history)
- `api/prisma/schema.prisma` — Main schema (30+ Prisma models)
- `api/prisma/migrations/` — 14 migration files (fully cover schema as of Feb 2026)
- `api/drizzle.config.ts` — Drizzle config for media tables
- `api/prisma/seed.ts` — Database seeding
### Nginx
@ -840,5 +743,5 @@ V1 code has been removed from the repo. History preserved as `v1-archive` git ta
### Documentation
- `CLAUDE.md` — Project-wide instructions (this file)
- `V2_PLAN.md` — Full 14-phase roadmap
- `SECURITY_AUDIT_2025-02-11.md`Initial security audit report
- `.mcp.json` — MCP server configuration for Claude Code
- `SECURITY_AUDIT_2025-02-11.md`Security audit report
- `MEMORY.md` — Development patterns and gotchas

View File

@ -33,9 +33,8 @@ All three methods share the same Gitea container registry at `gitea.bnkops.com/a
│ BUILD & PUBLISH │
│ │
│ Step 1: ./scripts/build-and-push.sh │
│ Builds 5 production images, pushes to Gitea registry │
│ (api, admin, media-api, nginx, ccp-agent) │
│ tagged :SHA + :latest │
│ Builds 4 production images, pushes to Gitea registry │
│ (api, admin, media-api, nginx) tagged :SHA + :latest │
│ │
│ Step 2: ./scripts/mirror-images.sh (run once/rarely) │
│ Mirrors 36 third-party images to Gitea registry │
@ -44,7 +43,7 @@ All three methods share the same Gitea container registry at `gitea.bnkops.com/a
│ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │
│ Packages runtime files into ~9MB tarball, uploads to │
│ Gitea Releases │
└──────────────────┬─────────────────100.90.78.47──────────────────────────────┘
└──────────────────┬───────────────────────────────────────────────┘
┌───────────┴───────────┐
▼ ▼
@ -99,7 +98,7 @@ After code changes are tested locally:
./scripts/build-and-push.sh
```
This builds **5 services** with multi-stage Dockerfiles (production target, no dev dependencies), tags each image with `:SHA` and `:latest`, and pushes to `gitea.bnkops.com/admin/changemaker-{service}`:
This builds **4 services** with multi-stage Dockerfiles (production target, no dev dependencies), tags each image with `:SHA` and `:latest`, and pushes to `gitea.bnkops.com/admin/changemaker-{service}`:
| Service | Dockerfile | What it produces |
|---------|-----------|-----------------|
@ -107,7 +106,6 @@ This builds **5 services** with multi-stage Dockerfiles (production target, no d
| `admin` | `admin/Dockerfile` | Nginx serving React build output |
| `media-api` | `api/Dockerfile.media` | Fastify + FFmpeg (compiled JS) |
| `nginx` | `nginx/Dockerfile` | Nginx with `envsubst` domain templating |
| `ccp-agent` | `../changemaker-control-panel/agent/Dockerfile` | Remote management agent (sibling repo) |
```bash
# Build specific services only
@ -159,17 +157,8 @@ Packages only runtime files (~9 MB) — no source code, no node_modules:
# Preview contents without creating tarball
./scripts/build-release.sh --dry-run
# --upload refuses to overwrite an existing tag. To deliberately replace
# a release (destructive — users on that tag see no upgrade signal):
./scripts/build-release.sh --tag v2.2.0 --upload --replace
```
**Version hygiene:** bump the tag when changing release contents. Overwriting
an existing release silently breaks upgrade checks for users already on that
version — they see "no update available" even though the tarball they'd
download differs.
The tarball contains:
- `docker-compose.yml` (copy of `docker-compose.prod.yml` — image-only, no build blocks)
- `.env.example`, `config.sh` (configuration wizard)
@ -185,7 +174,7 @@ The tarball contains:
```bash
# One-liner
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash
# Or manual
curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz
@ -271,10 +260,9 @@ docker compose logs -f api # Watch API logs
docker compose exec api npx prisma migrate dev # Create migration
# ── Build & Publish ──
./scripts/build-and-push.sh # Build + push 5 images
./scripts/build-and-push.sh # Build + push 4 images
./scripts/mirror-images.sh # Mirror 36 third-party images
git tag --sort=-v:refname | head -3 # Check latest version tags
./scripts/build-release.sh --tag vX.Y.Z --upload # Package + upload release
./scripts/build-release.sh --tag v2.2.0 --upload # Package + upload release
# ── Deploy ──
curl -fsSL .../install.sh | bash # New install (release)
@ -288,46 +276,13 @@ docker compose ps # Container status
---
## Gitea API Tokens
There are **two separate Gitea tokens** with different purposes. Using the wrong one is a common mistake:
| Variable | Target | Used by | Create at |
|----------|--------|---------|-----------|
| `GITEA_REGISTRY_API_TOKEN` | Remote registry (`gitea.bnkops.com`) | `build-release.sh --upload`, release API calls | `https://gitea.bnkops.com/user/settings/applications` |
| `GITEA_API_TOKEN` | Local Gitea instance | Docs comments, user provisioning, SSO | `http://localhost:3030/user/settings/applications` |
**Key:** Release uploads and the Gitea Releases API require `GITEA_REGISTRY_API_TOKEN`. If you get `"user does not exist"` from the API, you're using the wrong token.
---
## Checklist: Cutting a New Release
1. [ ] All code changes committed and pushed to `main` branch
1. [ ] All code changes committed and pushed to `v2` branch
2. [ ] `docker compose up -d` works locally (smoke test)
3. [ ] **Determine version tag:**
```bash
# Check the latest existing tag to pick the next version
git tag --sort=-v:refname | head -5
# Check commits since the last tag
git log $(git tag --sort=-v:refname | head -1)..HEAD --oneline
```
4. [ ] `./scripts/build-and-push.sh` — builds and pushes 5 production images
5. [ ] `./scripts/mirror-images.sh` — only if third-party versions changed
6. [ ] `./scripts/build-release.sh --tag vX.Y.Z --upload` — packages and uploads tarball
7. [ ] **Add release notes** (via Gitea web UI or API):
```bash
# Update release body via API (use GITEA_REGISTRY_API_TOKEN, not GITEA_API_TOKEN)
GITEA_TOKEN=$(grep -oP 'GITEA_REGISTRY_API_TOKEN=\K.*' .env)
# Find release ID
curl -s "https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases?limit=1" \
-H "Authorization: token $GITEA_TOKEN" | python3 -c "import sys,json; r=json.load(sys.stdin)[0]; print(f'ID: {r[\"id\"]}, Tag: {r[\"tag_name\"]}')"
# Update with release notes (write JSON body to /tmp/release-notes.json first)
curl -s -X PATCH "https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases/RELEASE_ID" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d @/tmp/release-notes.json
```
8. [ ] Test clean install: `tar xzf ... && cd changemaker-lite && bash config.sh && docker compose up -d`
9. [ ] Test upgrade: `./scripts/upgrade.sh` on an existing installation
10. [ ] Verify: `curl http://localhost:4000/api/health` returns `{"status":"ok"}`
3. [ ] `./scripts/build-and-push.sh` — builds and pushes 4 production images
4. [ ] `./scripts/mirror-images.sh` — only if third-party versions changed
5. [ ] `./scripts/build-release.sh --tag vX.Y.Z --upload` — packages and uploads tarball
6. [ ] Test clean install: `tar xzf ... && cd changemaker-lite && bash config.sh && docker compose up -d`
7. [ ] Test upgrade: `./scripts/upgrade.sh` on an existing installation
8. [ ] Verify: `curl http://localhost:4000/api/health` returns `{"status":"ok"}`

257
FEDERATION_PLAN.md Normal file
View File

@ -0,0 +1,257 @@
# Phase 16: Federation — Instance-to-Instance Campaign Network
## Context
Changemaker Lite instances are currently isolated islands. This feature introduces a **federated discovery network** where any instance can act as a **hub** (accepting registrations, serving a directory) and/or a **spoke** (registering with hubs, sharing campaigns). The goal is organic, admin-to-admin networking with public campaign discoverability as a secondary benefit.
**Design principles:**
- Any instance can be a hub, spoke, or both — no central authority
- Medium-depth campaign sharing: enough metadata for discovery, click-through to source
- Per-campaign federation toggle — admins choose what's shared
- Strict privacy boundary: **never** share emails, participant data, queue data, addresses, volunteer/canvass data, or credentials
- Hub admins curate their own directories — organic > control
---
## Prisma Schema Changes
**File:** `api/prisma/schema.prisma`
### New enums
```
FederationPeerStatus: PENDING | ACTIVE | REJECTED | SUSPENDED | OFFLINE
FederationRole: HUB | SPOKE
```
### New models
**FederationIdentity** (singleton — this instance's federation profile)
- `enabled`, `hubEnabled`, `hubAutoApprove`
- Instance profile: `instanceName`, `instanceDescription`, `instanceUrl`, `instanceRegion`, `instanceTags` (Json), `instanceLogoUrl`
- Ed25519 keypair: `publicKey`, `privateKey` (encrypted at rest)
- Hub description, sync interval, last sync timestamp/error
**FederationPeer** (one record per connection, in either direction)
- `role` (HUB or SPOKE), `remoteUrl` (unique per role+url)
- Remote instance profile fields (name, description, region, tags, logo, publicKey)
- Auth: `apiKey` (ours for them), `remoteApiKey` (theirs for us) — both encrypted
- Status tracking: `status`, `statusMessage`, `lastSeenAt`, `lastSyncAt`, `failureCount`
- Stats: `campaignsShared`, `responsesShared`
- Relation to `FederatedCampaign[]`
**FederatedCampaign** (cached campaign metadata from peers)
- `peerId` → FederationPeer
- Remote identifiers: `remoteCampaignId`, `remoteCampaignSlug`
- Safe metadata: title, description, emailSubject (NOT body), callToAction, coverPhoto, status, targetGovernmentLevels, featureFlags (Json), createdByName
- Aggregate stats: `emailCount`, `responseCount`
- Source instance info (denormalized): `sourceInstanceName`, `sourceInstanceUrl`, `sourceInstanceRegion`
- Staleness tracking: `lastSyncedAt`, `isStale`
- Future adoption: `adoptedAsCampaignId` (nullable FK to local Campaign)
- Unique constraint: `[peerId, remoteCampaignId]`
### Modifications to existing models
**Campaign** — add `federated Boolean @default(false)` field
**SiteSettings** — add `enableFederation Boolean @default(false)` feature toggle
---
## API Module Structure
**New directory:** `api/src/modules/federation/`
| File | Purpose |
|------|---------|
| `federation.schemas.ts` | Zod schemas: identity update, peer registration, campaign sync, directory query, list filters |
| `federation.service.ts` | Core business logic: identity CRUD, peer management, `buildSafeCampaignPayload()`, campaign sync, directory serving |
| `federation-admin.routes.ts` | SUPER_ADMIN routes: identity management, peer approve/reject/suspend, manual sync trigger |
| `federation-peer.routes.ts` | Inter-instance routes: inbound registration, campaign sync, directory, heartbeat (API-key auth) |
| `federation-public.routes.ts` | Public browsing: federated campaigns list, instance directory (no auth) |
| `federation-crypto.service.ts` | Ed25519 keypair generation, request signing/verification |
**New file:** `api/src/services/federation-sync-queue.service.ts` — BullMQ repeatable job for periodic sync
### Route table
**Admin routes** (`/api/federation/...`, SUPER_ADMIN + JWT auth):
| Method | Path | Description |
|--------|------|-------------|
| GET | `/identity` | Get federation config |
| PUT | `/identity` | Update config/profile |
| POST | `/identity/generate-keypair` | Generate Ed25519 keypair |
| GET | `/peers` | List all peers |
| POST | `/peers/register` | Register with a remote hub |
| POST | `/peers/:id/approve` | Approve incoming spoke |
| POST | `/peers/:id/reject` | Reject incoming spoke |
| POST | `/peers/:id/suspend` | Suspend peer |
| DELETE | `/peers/:id` | Remove peer |
| POST | `/sync` | Trigger manual sync |
| GET | `/sync/status` | Sync status + history |
**Peer routes** (`/api/federation/peer/...`, API-key auth via `X-Federation-Key` header):
| Method | Path | Description |
|--------|------|-------------|
| POST | `/register` | Inbound spoke registration |
| POST | `/sync` | Inbound campaign metadata push |
| GET | `/directory` | Serve campaign directory |
| GET | `/profile` | Return instance profile |
| POST | `/heartbeat` | Liveness check |
**Public routes** (`/api/federation/...`, no auth):
| Method | Path | Description |
|--------|------|-------------|
| GET | `/campaigns` | Browse federated campaigns (paginated, searchable) |
| GET | `/campaigns/:id` | Single federated campaign detail |
| GET | `/instances` | List known network instances |
### Mounting in server.ts
```
app.use('/api/federation', federationPublicRouter); // No auth — first
app.use('/api/federation', federationPeerRouter); // API-key auth
app.use('/api/federation', federationAdminRouter); // SUPER_ADMIN JWT
```
---
## Federation Protocol
### Registration handshake
1. Spoke admin enters hub URL, clicks "Register"
2. Spoke sends `POST /api/federation/peer/register` to hub with instance profile + generated API key
3. Hub creates peer record (PENDING or ACTIVE if `hubAutoApprove`)
4. Hub responds with its own API key + peer ID
5. If approved (now or later), hub calls back to spoke's `/peer/register` to complete mutual registration
6. Both instances now have each other as peers (Spoke→HUB role, Hub→SPOKE role)
### Campaign sync
- Spokes push federated campaigns to hubs on schedule (BullMQ repeatable job)
- Payload: array of safe campaign metadata + array of un-federated campaign IDs (for removal)
- Hub stores/updates `FederatedCampaign` records
- Sync includes heartbeat (updates `lastSeenAt`)
### Privacy boundary enforcement
`buildSafeCampaignPayload()` in the service layer filters campaigns to only safe fields. **Never included:** emailBody, any email addresses, user IDs, participant data, moderation internals, custom recipients, calls data.
### Offline handling
- Increment `failureCount` on sync failure; after 5 consecutive failures → status `OFFLINE`
- Mark federated campaigns as `isStale` after 24h offline
- Keep checking with exponential backoff (max 24h)
- Auto-recover when heartbeat succeeds
---
## Security
- **API-key auth:** `crypto.randomBytes(32).toString('hex')`, encrypted at rest with existing `encrypt()`/`decrypt()` utility
- **Custom middleware:** `authenticatePeer` checks `X-Federation-Key` header, verifies peer exists + is ACTIVE
- **Request signing (optional):** Ed25519 signatures on `X-Federation-Signature` header for non-repudiation (configurable, not enforced in MVP)
- **Rate limiting:** 30 req/min for peer routes, 60 req/min for public routes (separate Redis prefixes)
- **CORS:** Peer routes need permissive CORS (cross-domain by nature)
- **Input validation:** All incoming peer data Zod-validated + HTML-escaped before storage
---
## Environment Variables
Add to `api/src/config/env.ts`:
```
ENABLE_FEDERATION: z.string().default('false')
FEDERATION_SYNC_INTERVAL_MINUTES: z.coerce.number().default(60)
FEDERATION_MAX_CAMPAIGNS_PER_SYNC: z.coerce.number().default(500)
FEDERATION_PEER_TIMEOUT_MS: z.coerce.number().default(15000)
FEDERATION_MAX_PEERS: z.coerce.number().default(50)
```
---
## Admin UI
### FederationPage (`admin/src/pages/FederationPage.tsx`)
4-tab page following PangolinPage pattern:
**Tab 1 — Identity & Settings:** Toggle federation, instance profile form, keypair management, hub/spoke settings
**Tab 2 — Connected Peers:** Table of peers (name, URL, role tag, status tag, campaigns shared, last sync, actions). "Register with Hub" button opens modal. Pending incoming registrations highlighted.
**Tab 3 — Federated Campaigns:** Card grid/table of federated campaigns with search + filter (region, tags, government level). Click-through links to source instances.
**Tab 4 — Sync Status:** Last/next sync, per-peer status, manual sync button, sync history.
### Sidebar
Add to `buildMenuItems()` in `AppLayout.tsx`, gated on `settings?.enableFederation`:
```typescript
{ key: '/app/federation', icon: <GlobalOutlined />, label: 'Federation' }
```
(Using `<GlobalOutlined />` since `<GlobalOutlined />` is already imported but used for Web submenu — may use `<ClusterOutlined />` or `<DeploymentUnitOutlined />` instead)
### Route in App.tsx
```tsx
<Route path="federation" element={<ProtectedRoute requiredRoles={['SUPER_ADMIN']}><FederationPage /></ProtectedRoute>} />
```
### Campaign form integration
Add `federated` checkbox to campaign create/edit form in CampaignsPage, visible only when `settings.enableFederation` is true.
### TypeScript types
Add `FederationIdentity`, `FederationPeer`, `FederatedCampaign`, `FederationSyncStatus` interfaces to `admin/src/types/api.ts`.
### Public network page (stretch goal in MVP)
`admin/src/pages/public/FederatedCampaignsPage.tsx` at `/network` route — card grid of federated campaigns with PublicLayout dark theme.
---
## Prometheus Metrics
Add to `api/src/utils/metrics.ts`:
- `cm_federation_peers_active` (Gauge)
- `cm_federation_campaigns_shared` (Gauge)
- `cm_federation_sync_duration_seconds` (Histogram)
- `cm_federation_sync_errors_total` (Counter with `peer_id` label)
---
## Implementation Order
| Step | Description | Files Created/Modified | Depends On |
|------|-------------|----------------------|------------|
| 1 | **Prisma schema** — Add enums, 3 new models, Campaign.federated, SiteSettings.enableFederation | `api/prisma/schema.prisma` | — |
| 2 | **Migration**`npx prisma migrate dev --name add-federation` | `api/prisma/migrations/` | Step 1 |
| 3 | **Env vars** — Add federation config to env.ts + .env.example | `api/src/config/env.ts`, `.env.example` | — |
| 4 | **Crypto service** — Ed25519 keypair, sign/verify | `api/src/modules/federation/federation-crypto.service.ts` | — |
| 5 | **Schemas** — Zod validation for all federation endpoints | `api/src/modules/federation/federation.schemas.ts` | Step 1 |
| 6 | **Core service** — Identity CRUD, peer management, buildSafeCampaignPayload, campaign sync logic | `api/src/modules/federation/federation.service.ts` | Steps 2, 4, 5 |
| 7 | **Admin routes** — SUPER_ADMIN federation management | `api/src/modules/federation/federation-admin.routes.ts` | Step 6 |
| 8 | **Peer routes** — Inter-instance API with authenticatePeer middleware | `api/src/modules/federation/federation-peer.routes.ts` | Step 6 |
| 9 | **Public routes** — Browsing federated campaigns | `api/src/modules/federation/federation-public.routes.ts` | Step 6 |
| 10 | **Rate limiting** — Add federation rate limiters | `api/src/middleware/rate-limit.ts` | — |
| 11 | **Server mounting** — Import + mount routers, start sync queue | `api/src/server.ts` | Steps 7-10 |
| 12 | **Sync queue** — BullMQ repeatable job for periodic sync | `api/src/services/federation-sync-queue.service.ts` | Step 6 |
| 13 | **Metrics** — Prometheus counters/gauges | `api/src/utils/metrics.ts` | — |
| 14 | **Campaign form** — Add `federated` to schemas + service + CampaignsPage checkbox | `api/src/modules/influence/campaigns/campaigns.schemas.ts`, `campaigns.service.ts`, `admin/src/pages/CampaignsPage.tsx` | Step 2 |
| 15 | **Frontend types** — Federation TypeScript interfaces | `admin/src/types/api.ts` | — |
| 16 | **FederationPage** — 4-tab admin page | `admin/src/pages/FederationPage.tsx` | Steps 7, 15 |
| 17 | **Sidebar + routing** — Menu item + route in AppLayout/App.tsx | `admin/src/components/AppLayout.tsx`, `admin/src/App.tsx` | Step 16 |
| 18 | **Public network page** (stretch) — Federated campaigns browse | `admin/src/pages/public/FederatedCampaignsPage.tsx` | Steps 9, 15 |
---
## Future Extensions (not in MVP, but models accommodate)
- **Campaign adoption** — "Fork" a federated campaign locally (`FederatedCampaign.adoptedAsCampaignId`)
- **Cross-instance response sharing** — New `FederatedResponse` model synced alongside campaigns
- **Named networks/coalitions**`FederationNetwork` + `FederationNetworkMember` models for named alliances
- **Hub-of-hubs discovery** — Hubs share known-hub lists for transitive discovery (gossip protocol)
---
## Verification
1. **Two-instance test:** Run two API instances on different ports, enable federation on both, register one with the other
2. **Campaign sync:** Create a federated campaign on spoke, verify it appears in hub's directory
3. **Privacy boundary:** Inspect sync payloads — verify no emails, user IDs, or email bodies leak
4. **Offline handling:** Stop one instance, verify the other marks it OFFLINE after 5 failed syncs, then recovers on restart
5. **Rate limiting:** Hit peer endpoints rapidly, verify 429 responses after threshold
6. **Feature gate:** Disable federation in settings, verify all routes return 403/hidden
7. **UI:** Verify sidebar item appears/hides with feature toggle, all 4 tabs functional

192
README.md
View File

@ -1,172 +1,84 @@
<p align="center">
<img src="mkdocs/docs/assets/logo.png" alt="Changemaker Lite" width="120" />
</p>
# Changemaker Lite
<h1 align="center">Changemaker Lite</h1>
A self-hosted political campaign platform that consolidates advocacy email campaigns, geographic mapping, volunteer canvassing, media management, and administration into a single TypeScript stack. Built for organizers who want to own their data.
<p align="center">
A self-hosted campaign platform for community organizers who want to own their data.
</p>
## What Is This?
<p align="center">
<a href="https://cmlite.org/docs/getting-started/">Documentation</a> &middot;
<a href="https://cmlite.org">Website</a> &middot;
<a href="https://opensource.org/license/apache-2-0">Apache 2.0 License</a>
</p>
Changemaker Lite gives community organizers the tools they need to:
---
- **Run advocacy campaigns** — let supporters look up their elected representatives by postal code and send emails in a few clicks
- **Manage canvassing** — map locations, draw canvassing areas, schedule volunteer shifts, and track door-to-door visits with GPS
- **Host media** — upload campaign videos, share them publicly, and track engagement analytics
- **Build landing pages** — drag-and-drop page builder for campaign microsites
- **Send newsletters** — integrated with Listmonk for opt-in mailing lists
- **Monitor everything** — Prometheus + Grafana observability stack included
Changemaker Lite consolidates advocacy campaigns, geographic mapping, volunteer canvassing, media management, newsletters, and administration into a single Docker Compose stack. One `.env` file, one command to start, everything under your control.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/admin-dashboard.png" alt="Admin Dashboard" width="800" />
</p>
## Why Changemaker Lite?
Most campaign tools are SaaS platforms that lock you into monthly subscriptions, hold your data hostage, and disappear when funding dries up. Changemaker Lite is different:
- **Self-hosted** -- runs on any machine with Docker. Your server, your data.
- **All-in-one** -- replaces 5-10 separate tools with a single integrated platform.
- **Free and open source** -- Apache 2.0 licensed. Fork it, modify it, make it yours.
- **Privacy-first** -- no telemetry, no third-party analytics, no data leaving your server.
## What's Inside
### Advocacy Campaigns
Let supporters look up their elected representatives by postal code and send advocacy emails in a few clicks. Track responses, moderate a public response wall, and monitor email delivery.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/public-campaigns.png" alt="Public Campaign Page" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/influence-campaigns.png" alt="Campaign Management" width="800" />
</p>
### Interactive Map & Canvassing
Import thousands of addresses, draw canvassing areas, schedule volunteer shifts, and track door-to-door visits with GPS. Volunteers get a full-screen mobile map with real-time location tracking and visit recording.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/public-map.png" alt="Public Map" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/canvass-dashboard.png" alt="Canvass Dashboard" width="800" />
</p>
### Volunteer Portal
Volunteers get their own portal with shift sign-ups, canvassing assignments, activity tracking, a social calendar, and a friends system to stay connected with their team.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/volunteer-dashboard.png" alt="Volunteer Map" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/volunteer-calendar.png" alt="Volunteer Calendar" width="800" />
</p>
### Media Library & Public Gallery
Upload campaign videos, manage metadata, schedule publishing, and share them through a public gallery. Includes GDPR-compliant analytics.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/media-library.png" alt="Media Library" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/public-gallery.png" alt="Public Gallery" width="800" />
</p>
### Landing Pages & Email Templates
Build campaign microsites with a drag-and-drop GrapesJS editor. Design email templates for consistent campaign communications.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/landing-pages.png" alt="Landing Page Builder" width="800" />
</p>
### SMS Campaigns, Newsletters & More
Send SMS campaigns via an Android bridge, sync subscribers to Listmonk for newsletters, recognize volunteers on a Wall of Fame leaderboard, and monitor everything with built-in Prometheus + Grafana observability.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/sms-dashboard.png" alt="SMS Dashboard" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/public-wall-of-fame.png" alt="Wall of Fame" width="800" />
</p>
The entire platform runs on Docker Compose with a single `.env` file for configuration.
## Quick Start
### Production (pre-built images)
```bash
# 1. One-command install: checks host ports, downloads tarball, runs config wizard
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
# 2. Start services (first pull ~3 min + ~90s stabilization)
cd ~/changemaker.lite && docker compose up -d
# 3. Verify the install
bash scripts/test-deployment.sh --wait 60
```
The installer checks your host's port availability before extracting — no more half-started stacks from cockpit on `:9090` or other surprises. The generated admin password is printed to stdout **and** saved to `data/admin-credentials.txt` (mode 0600). See [Prerequisites](https://cmlite.org/docs/getting-started/prerequisites/) for what you need lined up first.
### Development (source)
```bash
# Clone and switch to the v2 branch
git clone <repo-url> changemaker.lite
cd changemaker.lite
git checkout v2
# Create your environment file
cp .env.example .env
# Edit .env -- set passwords, JWT secrets, admin credentials
# Edit .env — at minimum set:
# V2_POSTGRES_PASSWORD, REDIS_PASSWORD,
# JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY
# INITIAL_ADMIN_EMAIL, INITIAL_ADMIN_PASSWORD
# Start core services
docker compose up -d v2-postgres redis api admin
# Run database migrations and seed
docker compose exec api npx prisma migrate deploy
docker compose exec api npx prisma db seed
```
Then open **http://localhost:3000** and log in with the admin credentials from your `.env`.
### Useful tools
## Architecture
| Component | Technology | Port |
|-----------|-----------|------|
| **API** | Express.js + Prisma + PostgreSQL | 4000 |
| **Media API** | Fastify + Prisma (shared DB) | 4100 |
| **Admin GUI** | React + Vite + Ant Design + Zustand | 3000 |
| **Reverse Proxy** | Nginx (subdomain routing) | 80 |
| **Database** | PostgreSQL 16 | 5433 |
| **Cache / Queue** | Redis + BullMQ | 6379 |
| **Newsletter** | Listmonk | 9001 |
| **Monitoring** | Prometheus + Grafana + Alertmanager | 9090, 3001 |
See `CLAUDE.md` for comprehensive architecture documentation, module reference, and troubleshooting.
## Feature Flags
Enable optional modules in `.env`:
```bash
bash scripts/validate-env.sh # re-check .env + host ports
bash scripts/test-deployment.sh # full deployment health sweep
bash scripts/pangolin-teardown.sh # wipe tunnel org before reinstall (dry-run by default)
bash scripts/ccp-deregister.sh # deregister from Changemaker Control Panel (dry-run by default)
ENABLE_MEDIA_FEATURES=true # Video library + gallery
LISTMONK_SYNC_ENABLED=true # Newsletter subscriber sync
EMAIL_TEST_MODE=true # Route emails to MailHog (dev)
```
## Production Deployment
Changemaker Lite uses [Pangolin](https://github.com/fosrl/pangolin) tunnels for production access (Cloudflare alternative). See the Tunnel page in the admin panel (`/app/tunnel`) for setup instructions.
## Documentation
**Full documentation is available at [cmlite.org/docs/getting-started](https://cmlite.org/docs/getting-started/).**
- **`CLAUDE.md`** — Full project reference (architecture, modules, ports, patterns)
- **`V2_PLAN.md`** — Development roadmap (Phases 1-14 complete)
- **`SECURITY_AUDIT_2025-02-11.md`** — Security audit findings and remediations
- **`.env.example`** — All 100+ environment variables with descriptions
The docs site covers installation, configuration, all features, architecture details, production deployment with Pangolin tunnels, and troubleshooting. It is the authoritative and up-to-date reference for Changemaker Lite.
## Licensing
## Architecture at a Glance
| Layer | Technology |
|-------|-----------|
| API | Express.js + Prisma + PostgreSQL 16 |
| Media API | Fastify + Prisma (shared DB) |
| Frontend | React + Vite + Ant Design + Zustand |
| Reverse Proxy | Nginx (subdomain routing) |
| Cache & Queue | Redis + BullMQ |
| Newsletter | Listmonk |
| Monitoring | Prometheus + Grafana + Alertmanager |
| Tunneling | Pangolin (self-hosted Cloudflare alternative) |
The entire stack runs on Docker Compose. Enable optional modules (media, newsletters, SMS, monitoring) with feature flags in `.env`.
## License
[Apache License 2.0](https://opensource.org/license/apache-2-0)
This project is licensed under the [Apache License 2.0](https://opensource.org/license/apache-2-0).
## AI Disclaimer

View File

@ -1,6 +0,0 @@
node_modules
dist
.git
*.log
.env
.env.*

View File

@ -1,8 +0,0 @@
{
"hash": "46070c3d",
"configHash": "70922fab",
"lockfileHash": "ee36a2d0",
"browserHash": "5aa32ba6",
"optimized": {},
"chunks": {}
}

View File

@ -1,3 +0,0 @@
{
"type": "module"
}

238
admin/package-lock.json generated
View File

@ -33,11 +33,9 @@
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"minisearch": "^7.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -1156,9 +1154,9 @@
"dev": true
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"cpu": [
"arm"
],
@ -1169,9 +1167,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"cpu": [
"arm64"
],
@ -1182,9 +1180,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"cpu": [
"arm64"
],
@ -1195,9 +1193,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"cpu": [
"x64"
],
@ -1208,9 +1206,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"cpu": [
"arm64"
],
@ -1221,9 +1219,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"cpu": [
"x64"
],
@ -1234,9 +1232,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"cpu": [
"arm"
],
@ -1247,9 +1245,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"cpu": [
"arm"
],
@ -1260,9 +1258,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"cpu": [
"arm64"
],
@ -1273,9 +1271,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"cpu": [
"arm64"
],
@ -1286,9 +1284,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"cpu": [
"loong64"
],
@ -1299,9 +1297,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"cpu": [
"loong64"
],
@ -1312,9 +1310,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"cpu": [
"ppc64"
],
@ -1325,9 +1323,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"cpu": [
"ppc64"
],
@ -1338,9 +1336,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"cpu": [
"riscv64"
],
@ -1351,9 +1349,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"cpu": [
"riscv64"
],
@ -1364,9 +1362,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"cpu": [
"s390x"
],
@ -1377,9 +1375,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"cpu": [
"x64"
],
@ -1390,9 +1388,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"cpu": [
"x64"
],
@ -1403,9 +1401,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"cpu": [
"x64"
],
@ -1416,9 +1414,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"cpu": [
"arm64"
],
@ -1429,9 +1427,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"cpu": [
"arm64"
],
@ -1442,9 +1440,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"cpu": [
"ia32"
],
@ -1455,9 +1453,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"cpu": [
"x64"
],
@ -1468,9 +1466,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"cpu": [
"x64"
],
@ -2263,9 +2261,9 @@
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@ -2635,12 +2633,6 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-entities": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
@ -2730,11 +2722,6 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leaflet.heat": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
@ -2873,9 +2860,9 @@
"dev": true
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"engines": {
"node": ">=12"
@ -3664,9 +3651,9 @@
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"dev": true,
"dependencies": {
"@types/estree": "1.0.8"
@ -3679,31 +3666,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"fsevents": "~2.3.2"
}
},
@ -4006,9 +3993,10 @@
"dev": true
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},

View File

@ -34,11 +34,9 @@
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"minisearch": "^7.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -32,7 +32,6 @@ import CodeEditorPage from '@/pages/CodeEditorPage';
import NocoDBPage from '@/pages/NocoDBPage';
import N8nPage from '@/pages/N8nPage';
import GiteaPage from '@/pages/GiteaPage';
import GiteaSetupPage from '@/pages/GiteaSetupPage';
import MailHogPage from '@/pages/MailHogPage';
import MiniQRPage from '@/pages/MiniQRPage';
import ExcalidrawPage from '@/pages/ExcalidrawPage';
@ -43,15 +42,9 @@ import JitsiMeetPage from '@/pages/JitsiMeetPage';
import SettingsPage from '@/pages/SettingsPage';
import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
import PangolinPage from '@/pages/PangolinPage';
import ControlPanelPage from '@/pages/ControlPanelPage';
import ObservabilityPage from '@/pages/ObservabilityPage';
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
import AnalyticsOverviewPage from '@/pages/analytics/AnalyticsOverviewPage';
import GeoAnalyticsPage from '@/pages/analytics/GeoAnalyticsPage';
import ContentAnalyticsPage from '@/pages/analytics/ContentAnalyticsPage';
import UserAnalyticsPage from '@/pages/analytics/UserAnalyticsPage';
import DocsCommentsPage from '@/pages/DocsCommentsPage';
import DocsMetadataPage from '@/pages/DocsMetadataPage';
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
import SubscribersPage from '@/pages/payments/SubscribersPage';
import PaymentProductsPage from '@/pages/payments/ProductsPage';
@ -67,11 +60,6 @@ import GalleryAdsPage from '@/pages/media/GalleryAdsPage';
import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage';
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage';
import PetitionsPage from '@/pages/influence/PetitionsPage';
import PetitionSignaturesPage from '@/pages/influence/PetitionSignaturesPage';
import PetitionModerationPage from '@/pages/influence/PetitionModerationPage';
import PetitionsListPage from '@/pages/public/PetitionsListPage';
import PetitionPage from '@/pages/public/PetitionPage';
import PublicLandingPage from '@/pages/public/LandingPage';
import PagesIndexPage from '@/pages/public/PagesIndexPage';
import EventsPage from '@/pages/public/EventsPage';
@ -110,7 +98,6 @@ import SocialFeedPage from '@/pages/volunteer/SocialFeedPage';
import DiscoverPage from '@/pages/volunteer/DiscoverPage';
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
import AchievementsPage from '@/pages/volunteer/AchievementsPage';
import MyAnalyticsPage from '@/pages/volunteer/MyAnalyticsPage';
import {
ADMIN_ROLES,
INFLUENCE_ROLES,
@ -123,8 +110,6 @@ import {
EVENTS_ROLES,
SOCIAL_ROLES,
SYSTEM_ROLES,
POLLS_ROLES,
ANALYTICS_ROLES,
} from '@/types/api';
import { isAdmin } from '@/utils/roles';
import QuickJoinPage from '@/pages/public/QuickJoinPage';
@ -145,10 +130,6 @@ import ReferralAdminPage from '@/pages/social/ReferralAdminPage';
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
import StrawPollsPage from '@/pages/influence/StrawPollsPage';
import ActionCampaignsPage from '@/pages/influence/ActionCampaignsPage';
import ActionCampaignEditorPage from '@/pages/influence/ActionCampaignEditorPage';
import VolunteerDashboardPage from '@/pages/volunteer/VolunteerDashboardPage';
import ReferralsPage from '@/pages/volunteer/ReferralsPage';
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
@ -159,8 +140,6 @@ import MeetingAgendaPage from '@/pages/MeetingAgendaPage';
import ActionItemsPage from '@/pages/ActionItemsPage';
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage';
import StrawPollPage from '@/pages/public/StrawPollPage';
import StrawPollsListPage from '@/pages/public/StrawPollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage';
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
@ -174,7 +153,6 @@ import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
import SharedDocEditorPage from '@/pages/public/SharedDocEditorPage';
import NotFoundPage from '@/pages/NotFoundPage';
import CommandPalette from '@/components/command-palette/CommandPalette';
@ -187,7 +165,7 @@ function RoleAwareRedirect() {
function NavigateToCutMap() {
const { cutId } = useParams<{ cutId: string }>();
return <Navigate to={`/volunteer/map?cutId=${cutId}`} replace />;
return <Navigate to={`/volunteer?cutId=${cutId}`} replace />;
}
export default function App() {
@ -255,12 +233,6 @@ export default function App() {
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route index element={<CampaignsListPage />} />
</Route>
<Route path="/petitions" element={<FeatureGate feature="enablePetitions"><PublicLayout /></FeatureGate>}>
<Route index element={<PetitionsListPage />} />
</Route>
<Route path="/petition/:slug" element={<FeatureGate feature="enablePetitions"><PublicLayout /></FeatureGate>}>
<Route index element={<PetitionPage />} />
</Route>
<Route path="/campaigns/create" element={
<FeatureGate feature="enableInfluence">
<ProtectedRoute>
@ -301,14 +273,6 @@ export default function App() {
<Route index element={<SchedulingPollPage />} />
</Route>
{/* Straw polls — feature-gated */}
<Route path="/straw-polls" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
<Route index element={<StrawPollsListPage />} />
</Route>
<Route path="/straw-poll/:slug" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
<Route index element={<StrawPollPage />} />
</Route>
{/* Public ticketed event pages — feature-gated */}
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<TicketedEventDetailPage />} />
@ -349,9 +313,6 @@ export default function App() {
<Route index element={<ContactProfilePage />} />
</Route>
{/* Shared doc editor (no auth, token-based access) */}
<Route path="/docs/share/:shareToken" element={<SharedDocEditorPage />} />
{/* Public Media Gallery (purple theme) — feature-gated */}
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<MediaGalleryPage />} />
@ -373,9 +334,9 @@ export default function App() {
{/* Email link alias for video viewer */}
<Route path="/media/:id" element={<MediaViewerPage />} />
{/* Volunteer map — full-screen (moved from /volunteer to /volunteer/map) */}
{/* Volunteer map — full-screen, default landing page */}
<Route
path="/volunteer/map"
path="/volunteer"
element={
<ProtectedRoute>
<VolunteerMapPage />
@ -401,7 +362,6 @@ export default function App() {
</ProtectedRoute>
}
>
<Route path="/volunteer" element={<VolunteerDashboardPage />} />
<Route path="/volunteer/activity" element={<MyActivityPage />} />
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
@ -421,7 +381,6 @@ export default function App() {
<Route path="/volunteer/calendar/shared" element={<FeatureGate feature="enableSocialCalendar"><SharedCalendarsPage /></FeatureGate>} />
<Route path="/volunteer/calendar/friend/:userId" element={<FeatureGate feature="enableSocialCalendar"><FriendCalendarPage /></FeatureGate>} />
<Route path="/volunteer/calendar" element={<FeatureGate feature="enableSocialCalendar"><MyCalendarPage /></FeatureGate>} />
<Route path="/volunteer/my-analytics" element={<FeatureGate feature="enableAnalytics"><MyAnalyticsPage /></FeatureGate>} />
<Route path="/volunteer/*" element={<NotFoundPage />} />
</Route>
@ -597,62 +556,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="influence/petitions"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionsPage />
</ProtectedRoute>
}
/>
<Route
path="influence/petitions/:id/signatures"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionSignaturesPage />
</ProtectedRoute>
}
/>
<Route
path="influence/petitions/moderation"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionModerationPage />
</ProtectedRoute>
}
/>
<Route
path="influence/straw-polls"
element={
<ProtectedRoute requiredRoles={POLLS_ROLES}>
<StrawPollsPage />
</ProtectedRoute>
}
/>
<Route
path="influence/action-campaigns"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignsPage />
</ProtectedRoute>
}
/>
<Route
path="influence/action-campaigns/new"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignEditorPage />
</ProtectedRoute>
}
/>
<Route
path="influence/action-campaigns/:id"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignEditorPage />
</ProtectedRoute>
}
/>
<Route
path="listmonk"
element={
@ -701,14 +604,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="docs/metadata"
element={
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<DocsMetadataPage />
</ProtectedRoute>
}
/>
<Route
path="navigation"
element={
@ -749,14 +644,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="services/gitea/setup"
element={
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<GiteaSetupPage />
</ProtectedRoute>
}
/>
<Route
path="services/mailhog"
element={
@ -878,14 +765,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="control-panel"
element={
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<ControlPanelPage />
</ProtectedRoute>
}
/>
<Route
path="observability"
element={
@ -894,46 +773,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="analytics"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<AnalyticsOverviewPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/geo"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<GeoAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/content"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<ContentAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/users"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<UserAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/users/:userId"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<UserAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="map"
element={

View File

@ -54,7 +54,6 @@ import {
TrophyOutlined,
FlagOutlined,
UserAddOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { api } from '@/lib/api';
@ -71,8 +70,6 @@ import {
MEDIA_ROLES,
PAYMENTS_ROLES,
SOCIAL_ROLES,
POLLS_ROLES,
ANALYTICS_ROLES,
} from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api';
@ -86,10 +83,6 @@ import {
} from '@/lib/nav-defaults';
import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useFavoritesStore } from '@/stores/favorites.store';
import { useTourStore } from '@/stores/tour.store';
import { AdminTour } from './tour/AdminTour';
import { TourHub } from './tour/TourHub';
import { TourTriggerButton } from './tour/TourTriggerButton';
import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items';
import RocketChatWidget from './chat/RocketChatWidget';
@ -187,14 +180,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: badges?.pendingEmails ? <Badge count={badges.pendingEmails} size="small" offset={[8, 0]}>Outgoing Emails</Badge> : 'Outgoing Emails' },
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/action-campaigns', icon: <TrophyOutlined />, label: 'Action Campaigns' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
...(settings?.enablePetitions !== false ? [
{ key: '/app/influence/petitions', icon: <FileTextOutlined />, label: 'Petitions' },
{ key: '/app/influence/petitions/moderation', icon: <FileTextOutlined />, label: 'Petition Review' },
] : []),
...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []),
],
});
}
@ -239,7 +226,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
webChildren.push({ key: '/app/docs/metadata', icon: <DatabaseOutlined />, label: 'Metadata' });
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({
@ -332,20 +318,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
}
if (isSuperAdmin) {
if (settings?.enableAnalytics !== false && can(ANALYTICS_ROLES)) {
items.push({
key: 'analytics-submenu',
icon: <BarChartOutlined />,
label: 'Analytics',
children: [
{ key: '/app/analytics', icon: <DashboardOutlined />, label: 'Overview' },
{ key: '/app/analytics/geo', icon: <GlobalOutlined />, label: 'Geography' },
{ key: '/app/analytics/content', icon: <FileTextOutlined />, label: 'Content' },
{ key: '/app/analytics/users', icon: <TeamOutlined />, label: 'Users' },
],
});
}
items.push({
key: 'services-submenu',
icon: <CloudServerOutlined />,
@ -353,7 +325,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
children: [
{ type: 'group', label: 'Infrastructure', children: [
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
{ key: '/app/control-panel', icon: <ApiOutlined />, label: 'Control Panel' },
{ key: '/app/observability', icon: <LineChartOutlined />, label: 'Monitoring' },
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
{ key: '/app/services/vaultwarden', icon: <LockOutlined />, label: 'Vault' },
@ -362,7 +333,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ type: 'group', label: 'Tools', children: [
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/gitea/setup', icon: <SettingOutlined />, label: 'Gitea Setup' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]},
@ -461,14 +431,6 @@ export default function AppLayout() {
};
const userMenuItems: MenuProps['items'] = [
{
key: 'tour',
icon: <QuestionCircleOutlined />,
label: 'Learning Tours',
onClick: () => {
useTourStore.getState().openHub();
},
},
{
key: 'logout',
icon: <LogoutOutlined />,
@ -616,7 +578,6 @@ export default function AppLayout() {
trigger={null}
collapsible
collapsed={collapsed}
data-tour="sidebar"
style={{ overflow: 'auto', height: '100vh', position: 'sticky', top: 0, left: 0 }}
>
{sidebarMenu}
@ -654,12 +615,11 @@ export default function AppLayout() {
<Button
type="text"
icon={<SearchOutlined />}
data-tour="search-button"
onClick={() => useCommandPaletteStore.getState().open()}
/>
</Tooltip>
{pageHeader?.actions}
{!isMobile && (() => {
{(() => {
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
const withOverrides = applyAdminOverrides(merged);
const flags = buildFeatureFlags(settings);
@ -668,14 +628,11 @@ export default function AppLayout() {
const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? <GlobalOutlined />;
const handleItemClick = (item: NavItem) => {
if (item.path.startsWith('$')) {
window.open(resolveNavUrl(item.path), '_blank', 'noopener,noreferrer');
window.open(resolveNavUrl(item.path), '_blank');
} else if (item.external && item.id === 'home') {
window.open(buildHomeUrl(), '_blank', 'noopener,noreferrer');
window.open(buildHomeUrl(), '_blank');
} else if (item.external) {
// Only open http/https URLs to prevent javascript: URI injection
if (item.path.startsWith('http://') || item.path.startsWith('https://')) {
window.open(item.path, '_blank', 'noopener,noreferrer');
}
window.open(item.path, '_blank');
} else {
navigate(item.path);
}
@ -697,7 +654,7 @@ export default function AppLayout() {
placement="bottomRight"
>
<Button type="text" size="small" icon={getIcon(item.icon)}>
{!collapsed && item.label}
{!isMobile && !collapsed && item.label}
</Button>
</Dropdown>
);
@ -710,27 +667,25 @@ export default function AppLayout() {
icon={getIcon(item.icon)}
onClick={() => handleItemClick(item)}
>
{!collapsed && item.label}
{!isMobile && !collapsed && item.label}
</Button>
</Tooltip>
);
});
})()}
{/* Volunteer Portal button — always visible for quick switching */}
{!isMobile && (
<Tooltip title="Switch to Volunteer Portal">
<Button
type="text"
size="small"
icon={<TeamOutlined />}
onClick={() => navigate('/volunteer')}
>
{!collapsed && 'Volunteer'}
</Button>
</Tooltip>
)}
<Tooltip title="Switch to Volunteer Portal">
<Button
type="text"
size="small"
icon={<TeamOutlined />}
onClick={() => navigate('/volunteer')}
>
{!isMobile && !collapsed && 'Volunteer'}
</Button>
</Tooltip>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />} data-tour="user-menu">
<Button type="text" icon={<UserOutlined />}>
{!isMobile && !collapsed && (
<Text style={{ marginLeft: 8 }}>
{user?.name || user?.email || 'User'}
@ -740,7 +695,6 @@ export default function AppLayout() {
</Dropdown>
</Header>
<Content
id="app-content-area"
style={{
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
@ -748,16 +702,12 @@ export default function AppLayout() {
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
minHeight: 280,
overflow: fullBleed ? 'hidden' : undefined,
position: 'relative',
}}
>
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />
</Content>
</Layout>
</Layout>
<AdminTour />
<TourHub />
<TourTriggerButton />
<RocketChatWidget />
</>
);

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { Result, Button, Skeleton } from 'antd';
import { Result, Button } from 'antd';
import { useNavigate } from 'react-router-dom';
import { SettingOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
@ -22,13 +22,10 @@ const FEATURE_LABELS: Record<string, string> = {
enableMeetingPlanner: 'Meeting Planner',
enableTicketedEvents: 'Ticketed Events',
enableSocialCalendar: 'Social Calendar',
enablePetitions: 'Petitions',
enablePolls: 'Straw Polls',
enableAnalytics: 'Analytics Dashboard',
};
interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePetitions' | 'enablePolls' | 'enableAnalytics'>;
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar'>;
children: ReactNode;
}
@ -39,8 +36,8 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const featureName = FEATURE_LABELS[feature] || feature;
// Show skeleton while settings are loading to prevent briefly showing disabled features
if (loading || !settings) return <Skeleton active style={{ padding: 24 }} />;
// While loading or if settings haven't arrived yet, render children (optimistic)
if (loading || !settings) return <>{children}</>;
if (settings[feature] === false) {
return (

View File

@ -571,40 +571,6 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
</div>
</section>`;
}
case 'straw-poll-inline': {
const pollSlug = (defaults.pollSlug as string) || '';
return `
<section style="padding: 60px 40px;">
<div class="straw-poll-inline"
data-poll-slug="${pollSlug}"
data-show-results="true"
style="max-width: 500px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); border-radius: 12px; padding: 32px; text-align: center; color: #fff;">
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M160 960h128V480H160v480zm256 0h128V320H416v640zm256 0h128V160H672v800z"/>
</svg>
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">Straw Poll (Inline)</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Inline voting widget renders on published page</p>
</div>
</div>
</section>`;
}
case 'straw-poll-card': {
const pollSlug = (defaults.pollSlug as string) || '';
return `
<section style="padding: 40px;">
<div class="straw-poll-card"
data-poll-slug="${pollSlug}"
style="max-width: 400px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #722ed1 0%, #531dab 100%); border-radius: 12px; padding: 24px; text-align: center; color: #fff;">
<p style="margin: 0; font-size: 1rem; font-weight: 600;">Straw Poll (Card Link)</p>
<p style="margin: 8px 0 0; font-size: 0.85rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
<p style="margin: 8px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Preview card with vote link renders on published page</p>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}

View File

@ -1,47 +0,0 @@
import { Row, Col, Typography, Space } from 'antd';
import type { ReactNode } from 'react';
import { useMobile } from '@/hooks/useMobile';
interface MobilePageHeaderProps {
title: string;
/** Optional element next to the title (badge, count, etc.) */
extra?: ReactNode;
/** Action buttons — will wrap on mobile, stay inline on desktop */
actions?: ReactNode;
style?: React.CSSProperties;
}
/**
* Responsive page header that stacks title and actions on mobile.
* On desktop: title left, actions right (single row).
* On mobile: title full-width, actions below with wrapping.
*/
export function MobilePageHeader({ title, extra, actions, style }: MobilePageHeaderProps) {
const { isMobile } = useMobile();
return (
<Row
justify="space-between"
align={isMobile ? 'top' : 'middle'}
style={{ marginBottom: 16, ...style }}
gutter={[0, isMobile ? 12 : 0]}
wrap
>
<Col xs={24} md="auto">
<Space>
<Typography.Title level={4} style={{ margin: 0 }}>
{title}
</Typography.Title>
{extra}
</Space>
</Col>
{actions && (
<Col xs={24} md="auto">
<Space wrap size={isMobile ? 'small' : 'middle'}>
{actions}
</Space>
</Col>
)}
</Row>
);
}

View File

@ -344,7 +344,6 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
justifyContent: 'space-between',
padding: '0 24px',
height: 56,
overflow: 'hidden',
flexShrink: 0,
}}
>
@ -375,7 +374,7 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
/>
</Space>
) : (
<Space size={navCollapsed ? 8 : 16} style={{ flexWrap: 'nowrap', overflow: 'hidden' }}>
<Space size={navCollapsed ? 8 : 16}>
{visibleNavItems.map(renderDesktopLink)}
{overflowMenuItems.length > 0 && (
<Dropdown

View File

@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { theme } from 'antd';
import {
HomeOutlined,
EnvironmentOutlined,
ScheduleOutlined,
HistoryOutlined,
@ -16,8 +15,7 @@ import {
import { useSettingsStore } from '@/stores/settings.store';
const BASE_NAV_ITEMS = [
{ key: '/volunteer', icon: HomeOutlined, label: 'Home' },
{ key: '/volunteer/map', icon: EnvironmentOutlined, label: 'Map' },
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' },
{ key: '/volunteer/shifts', icon: ScheduleOutlined, label: 'Shifts' },
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },

View File

@ -6,7 +6,6 @@ import {
UserOutlined,
GlobalOutlined,
AppstoreOutlined,
HomeOutlined,
EnvironmentOutlined,
ScheduleOutlined,
HistoryOutlined,
@ -15,7 +14,6 @@ import {
TagOutlined,
TeamOutlined,
MessageOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
@ -50,8 +48,7 @@ export default function VolunteerLayout() {
// Build nav items list (mirrors VolunteerFooterNav logic)
const navItems = useMemo(() => {
const items: { key: string; icon: React.ReactNode; label: string }[] = [
{ key: '/volunteer', icon: <HomeOutlined />, label: 'Home' },
{ key: '/volunteer/map', icon: <EnvironmentOutlined />, label: 'Map' },
{ key: '/volunteer', icon: <EnvironmentOutlined />, label: 'Map' },
{ key: '/volunteer/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
{ key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' },
{ key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' },
@ -68,9 +65,6 @@ export default function VolunteerLayout() {
if (settings?.enableChat) {
items.push({ key: '/volunteer/chat', icon: <MessageOutlined />, label: 'Chat' });
}
if (settings?.enableAnalytics) {
items.push({ key: '/volunteer/my-analytics', icon: <BarChartOutlined />, label: 'My Stats' });
}
return items;
}, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]);
@ -103,7 +97,7 @@ export default function VolunteerLayout() {
<Content
style={{
maxWidth: location.pathname === '/volunteer' ? 1280 : 800,
maxWidth: 800,
width: '100%',
margin: '0 auto',
padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px',

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import {
Drawer,
Modal,
Form,
Input,
DatePicker,
@ -169,20 +169,13 @@ export default function CalendarItemModal({
};
return (
<Drawer
<Modal
open={open}
onClose={onCancel}
onCancel={onCancel}
title={isEditing ? 'Edit Calendar Item' : 'New Calendar Item'}
footer={null}
width={520}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button type="primary" onClick={() => form.submit()} loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
</Button>
}
>
<Form
form={form}
@ -461,18 +454,26 @@ export default function CalendarItemModal({
)}
{/* Actions */}
{isEditing && onDelete && (
<div style={{ marginTop: 8 }}>
<Button
danger
icon={<DeleteOutlined />}
onClick={onDelete}
>
Delete
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
<div>
{isEditing && onDelete && (
<Button
danger
icon={<DeleteOutlined />}
onClick={onDelete}
>
Delete
</Button>
)}
</div>
)}
<Space>
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
</Button>
</Space>
</div>
</Form>
</Drawer>
</Modal>
);
}

View File

@ -16,7 +16,6 @@ import { PlusOutlined, CheckCircleOutlined, VideoCameraOutlined, CopyOutlined }
import axios from 'axios';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { TextArea } = Input;
const { Text } = Typography;
@ -80,8 +79,9 @@ export default function EventSubmissionForm({ initialDate, onSuccess, gancioUrl,
setSuccess(true);
form.resetFields();
onSuccess?.();
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to submit event'));
} catch (err: any) {
const msg = err.response?.data?.error?.message || err.response?.data?.error || 'Failed to submit event';
message.error(msg);
} finally {
setSubmitting(false);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer, Form, Select, Checkbox, Slider, DatePicker, Switch,
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid, Space,
Modal, Form, Select, Checkbox, Slider, DatePicker, Switch,
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid,
} from 'antd';
import { ExportOutlined, EyeOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
@ -152,34 +152,32 @@ export default function ExportContactsModal({
};
return (
<Drawer
<Modal
title="Export Canvass Contacts to Campaign"
open={open}
onClose={onClose}
onCancel={onClose}
width={isMobile ? '95vw' : 640}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button
icon={<EyeOutlined />}
onClick={handlePreview}
loading={previewing}
>
Preview
</Button>
<Button
type="primary"
icon={<ExportOutlined />}
onClick={handleExport}
loading={exporting}
disabled={!preview || preview.contactsWithEmail === 0}
>
Export
</Button>
</Space>
}
footer={[
<Button key="cancel" onClick={onClose}>Cancel</Button>,
<Button
key="preview"
icon={<EyeOutlined />}
onClick={handlePreview}
loading={previewing}
>
Preview
</Button>,
<Button
key="export"
type="primary"
icon={<ExportOutlined />}
onClick={handleExport}
loading={exporting}
disabled={!preview || preview.contactsWithEmail === 0}
>
Export
</Button>,
]}
>
<Form form={form} layout="vertical" size="small">
<Form.Item
@ -296,6 +294,6 @@ export default function ExportContactsModal({
)}
</div>
)}
</Drawer>
</Modal>
);
}

View File

@ -48,11 +48,10 @@ export default function ChatPanel({ panel, leftOffset }: Props) {
if (!rcAuthToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
if (!iframeRef.current?.contentWindow || !rcServiceUrl) return;
const targetOrigin = new URL(rcServiceUrl).origin;
if (!iframeRef.current?.contentWindow) return;
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: rcAuthToken },
targetOrigin,
'*',
);
};

View File

@ -240,12 +240,7 @@ export default function CommandPalette() {
if (flatItem.type === 'command') {
const cmd = flatItem.item;
addRecent(cmd.id);
// Special handling for non-navigation actions
if (cmd.id === 'action-learning-tours') {
import('@/stores/tour.store').then(({ useTourStore }) => useTourStore.getState().openHub());
} else {
navigate(cmd.path, { state: cmd.navigationState });
}
navigate(cmd.path, { state: cmd.navigationState });
} else {
navigate(flatItem.item.path, { state: flatItem.item.navigationState });
}

View File

@ -790,18 +790,6 @@ export const commandRegistry: CommandItem[] = [
requiredRoles: ['SUPER_ADMIN'],
},
// ── Help & Tours ────────────────────────────────────────
{
id: 'action-learning-tours',
title: 'Learning Tours',
description: 'Browse interactive tutorials for each section of the admin',
group: 'Actions',
path: '',
icon: 'QuestionCircleOutlined',
keywords: ['tour', 'help', 'learn', 'tutorial', 'guide', 'onboarding', 'walkthrough'],
category: 'action',
},
// ── Quick actions ─────────────────────────────────────
{
id: 'action-create-campaign',

View File

@ -1,391 +0,0 @@
import { useState, useEffect } from 'react';
import {
Drawer,
Button,
Input,
Space,
Typography,
Popconfirm,
message,
theme,
Divider,
Empty,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
CloseOutlined,
UserOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
type AuthorsMap = Record<string, AuthorEntry>;
interface AuthorsManagementModalProps {
open: boolean;
onClose: () => void;
authors: AuthorsMap;
onSaved: () => void;
}
interface AuthorFormState {
id: string;
name: string;
description: string;
avatar: string;
}
const emptyForm: AuthorFormState = { id: '', name: '', description: '', avatar: '' };
export function AuthorsManagementModal({
open,
onClose,
authors,
onSaved,
}: AuthorsManagementModalProps) {
const [messageApi, contextHolder] = message.useMessage();
const [localAuthors, setLocalAuthors] = useState<AuthorsMap>({});
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editForm, setEditForm] = useState<AuthorFormState>(emptyForm);
const [addingNew, setAddingNew] = useState(false);
const [newForm, setNewForm] = useState<AuthorFormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
// Sync from props when modal opens
useEffect(() => {
if (open) {
setLocalAuthors({ ...authors });
setEditingKey(null);
setAddingNew(false);
setNewForm(emptyForm);
setDirty(false);
}
}, [open, authors]);
const startEdit = (key: string) => {
const entry = localAuthors[key];
if (!entry) return;
setEditingKey(key);
setEditForm({
id: key,
name: entry.name,
description: entry.description ?? '',
avatar: entry.avatar ?? '',
});
};
const cancelEdit = () => {
setEditingKey(null);
setEditForm(emptyForm);
};
const saveEdit = () => {
if (!editForm.name.trim()) {
messageApi.warning('Name is required');
return;
}
const updated = { ...localAuthors };
// If key changed, remove old and add new
if (editingKey && editingKey !== editForm.id && editForm.id.trim()) {
delete updated[editingKey];
}
const key = editForm.id.trim() || editingKey!;
updated[key] = buildEntry(editForm);
setLocalAuthors(updated);
setEditingKey(null);
setEditForm(emptyForm);
setDirty(true);
};
const deleteAuthor = (key: string) => {
const updated = { ...localAuthors };
delete updated[key];
setLocalAuthors(updated);
setDirty(true);
if (editingKey === key) cancelEdit();
};
const saveNewAuthor = () => {
if (!newForm.id.trim()) {
messageApi.warning('Author ID is required');
return;
}
if (!newForm.name.trim()) {
messageApi.warning('Name is required');
return;
}
if (localAuthors[newForm.id.trim()]) {
messageApi.warning('An author with this ID already exists');
return;
}
const updated = { ...localAuthors };
updated[newForm.id.trim()] = buildEntry(newForm);
setLocalAuthors(updated);
setNewForm(emptyForm);
setAddingNew(false);
setDirty(true);
};
const handleSaveAll = async () => {
setSaving(true);
try {
await api.put('/docs/blog/authors', { authors: localAuthors });
messageApi.success('Authors saved');
setDirty(false);
onSaved();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
?.error?.message || 'Failed to save authors';
messageApi.error(msg);
} finally {
setSaving(false);
}
};
const authorEntries = Object.entries(localAuthors);
return (
<Drawer
title={
<span>
<UserOutlined style={{ marginRight: 8 }} />
Manage Authors
</span>
}
open={open}
onClose={onClose}
width={560}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAll}
loading={saving}
disabled={!dirty}
>
Save
</Button>
}
>
{contextHolder}
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{authorEntries.length === 0 && !addingNew && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No authors defined yet"
style={{ margin: '24px 0' }}
/>
)}
{authorEntries.map(([key, entry]) => (
<div key={key}>
{editingKey === key ? (
<AuthorEditForm
form={editForm}
onChange={setEditForm}
onSave={saveEdit}
onCancel={cancelEdit}
showId
/>
) : (
<AuthorRow
id={key}
entry={entry}
onEdit={() => startEdit(key)}
onDelete={() => deleteAuthor(key)}
/>
)}
<Divider style={{ margin: '8px 0' }} />
</div>
))}
{/* New author form */}
{addingNew ? (
<AuthorEditForm
form={newForm}
onChange={setNewForm}
onSave={saveNewAuthor}
onCancel={() => {
setAddingNew(false);
setNewForm(emptyForm);
}}
showId
isNew
/>
) : (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setAddingNew(true)}
block
style={{ marginTop: 8 }}
>
Add Author
</Button>
)}
</div>
</Drawer>
);
}
function buildEntry(form: AuthorFormState): AuthorEntry {
const entry: AuthorEntry = { name: form.name.trim() };
if (form.description.trim()) entry.description = form.description.trim();
if (form.avatar.trim()) entry.avatar = form.avatar.trim();
return entry;
}
/** Read-only author row */
function AuthorRow({
id,
entry,
onEdit,
onDelete,
}: {
id: string;
entry: AuthorEntry;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
padding: '8px 4px',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Typography.Text strong>{entry.name}</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 11, fontFamily: 'monospace' }}
>
{id}
</Typography.Text>
</div>
{entry.description && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, display: 'block', marginTop: 2 }}
>
{entry.description}
</Typography.Text>
)}
{entry.avatar && (
<Typography.Text
type="secondary"
style={{
fontSize: 11,
display: 'block',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Avatar: {entry.avatar}
</Typography.Text>
)}
</div>
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} />
<Popconfirm
title="Delete this author?"
onConfirm={onDelete}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
);
}
/** Inline edit / create form for an author */
function AuthorEditForm({
form,
onChange,
onSave,
onCancel,
showId,
isNew,
}: {
form: AuthorFormState;
onChange: (f: AuthorFormState) => void;
onSave: () => void;
onCancel: () => void;
showId?: boolean;
isNew?: boolean;
}) {
const { token } = theme.useToken();
return (
<div
style={{
padding: '10px',
borderRadius: token.borderRadius,
background: token.colorFillQuaternary,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{showId && (
<Input
size="small"
placeholder="Author ID (e.g. jdoe)"
value={form.id}
onChange={(e) => onChange({ ...form, id: e.target.value })}
addonBefore="ID"
disabled={!isNew && !!form.id}
/>
)}
<Input
size="small"
placeholder="Display name"
value={form.name}
onChange={(e) => onChange({ ...form, name: e.target.value })}
addonBefore="Name"
autoFocus
/>
<Input
size="small"
placeholder="Short description (optional)"
value={form.description}
onChange={(e) => onChange({ ...form, description: e.target.value })}
addonBefore="Desc"
/>
<Input
size="small"
placeholder="Avatar URL (optional)"
value={form.avatar}
onChange={(e) => onChange({ ...form, avatar: e.target.value })}
addonBefore="Avatar"
/>
<Space size={8}>
<Button size="small" type="primary" icon={<SaveOutlined />} onClick={onSave}>
{isNew ? 'Add' : 'Update'}
</Button>
<Button size="small" icon={<CloseOutlined />} onClick={onCancel}>
Cancel
</Button>
</Space>
</div>
);
}

View File

@ -1,265 +0,0 @@
import { DatePicker, Select, Switch, Input, Typography, theme, Button, Tooltip } from 'antd';
import {
CalendarOutlined,
UserOutlined,
TagsOutlined,
FolderOutlined,
FileTextOutlined,
EyeInvisibleOutlined,
LinkOutlined,
TeamOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { BlogFrontmatter } from '@/hooks/useBlogFrontmatter';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
interface BlogFrontmatterPanelProps {
frontmatter: BlogFrontmatter | null;
onUpdate: (field: string, value: unknown) => void;
authors: Record<string, AuthorEntry>;
categories: string[];
loading: boolean;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onManageAuthors: () => void;
}
const PANEL_WIDTH = 250;
export function BlogFrontmatterPanel({
frontmatter,
onUpdate,
authors,
categories,
loading,
collapsed,
onCollapsedChange,
onManageAuthors,
}: BlogFrontmatterPanelProps) {
const { token } = theme.useToken();
if (!frontmatter) return null;
const authorOptions = Object.entries(authors || {}).map(([key, entry]) => ({
label: entry.name,
value: key,
}));
const categoryOptions = (categories || []).map((cat) => ({
label: cat,
value: cat,
}));
if (collapsed) {
return (
<div
style={{
width: 32,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: 8,
borderLeft: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
}}
>
<Tooltip title="Show blog panel" placement="left">
<Button
type="text"
size="small"
icon={<LeftOutlined />}
onClick={() => onCollapsedChange(false)}
/>
</Tooltip>
</div>
);
}
return (
<div
style={{
width: PANEL_WIDTH,
flexShrink: 0,
borderLeft: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Typography.Text strong style={{ fontSize: 13 }}>
Blog
</Typography.Text>
<Tooltip title="Collapse panel">
<Button
type="text"
size="small"
icon={<RightOutlined />}
onClick={() => onCollapsedChange(true)}
/>
</Tooltip>
</div>
{/* Fields */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
{/* Date */}
<FieldGroup icon={<CalendarOutlined />} label="Date">
<DatePicker
value={frontmatter.date ? dayjs(frontmatter.date) : undefined}
onChange={(d) => onUpdate('date', d ? d.format('YYYY-MM-DD') : undefined)}
format="YYYY-MM-DD"
size="small"
style={{ width: '100%' }}
allowClear
/>
</FieldGroup>
{/* Authors */}
<FieldGroup
icon={<UserOutlined />}
label="Authors"
extra={
<Tooltip title="Manage authors">
<Button
type="link"
size="small"
icon={<TeamOutlined />}
onClick={onManageAuthors}
style={{ padding: 0, height: 'auto', fontSize: 11 }}
/>
</Tooltip>
}
>
<Select
mode="multiple"
size="small"
placeholder="Select authors"
value={frontmatter.authors ?? []}
onChange={(val) => onUpdate('authors', val)}
options={authorOptions}
loading={loading}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Categories */}
<FieldGroup icon={<FolderOutlined />} label="Categories">
<Select
mode="tags"
size="small"
placeholder="Add categories"
value={frontmatter.categories ?? []}
onChange={(val) => onUpdate('categories', val)}
options={categoryOptions}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Tags */}
<FieldGroup icon={<TagsOutlined />} label="Tags">
<Select
mode="tags"
size="small"
placeholder="Add tags"
value={frontmatter.tags ?? []}
onChange={(val) => onUpdate('tags', val)}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Draft */}
<FieldGroup icon={<EyeInvisibleOutlined />} label="Draft">
<Switch
size="small"
checked={frontmatter.draft ?? false}
onChange={(checked) => onUpdate('draft', checked || undefined)}
/>
</FieldGroup>
{/* Slug */}
<FieldGroup icon={<LinkOutlined />} label="Slug">
<Input
size="small"
placeholder="auto-generated"
value={frontmatter.slug ?? ''}
onChange={(e) => onUpdate('slug', e.target.value || undefined)}
allowClear
/>
</FieldGroup>
{/* Description */}
<FieldGroup icon={<FileTextOutlined />} label="Description">
<Input.TextArea
size="small"
rows={3}
placeholder="Post description / excerpt"
value={frontmatter.description ?? ''}
onChange={(e) => onUpdate('description', e.target.value || undefined)}
/>
</FieldGroup>
</div>
</div>
);
}
/** Small labeled field wrapper */
function FieldGroup({
icon,
label,
extra,
children,
}: {
icon: React.ReactNode;
label: string;
extra?: React.ReactNode;
children: React.ReactNode;
}) {
const { token } = theme.useToken();
return (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 4,
}}
>
<span style={{ color: token.colorTextSecondary, fontSize: 12 }}>{icon}</span>
<Typography.Text
style={{ fontSize: 11, fontWeight: 600, color: token.colorTextSecondary, textTransform: 'uppercase', letterSpacing: 0.3 }}
>
{label}
</Typography.Text>
{extra && <span style={{ marginLeft: 'auto' }}>{extra}</span>}
</div>
{children}
</div>
);
}

View File

@ -1,322 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Form,
Select,
Switch,
Button,
Spin,
Tag,
Space,
Typography,
Divider,
message,
Alert,
} from 'antd';
import {
LockOutlined,
TeamOutlined,
UserOutlined,
SaveOutlined,
UndoOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
const { Text, Title } = Typography;
interface DocAccessPolicyPanelProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
}
interface EffectivePolicy {
id: string | null;
documentPath: string;
isDirectory: boolean;
allowedEditors: string[];
isDefault: boolean;
}
const AVAILABLE_ROLES: { value: string; label: string }[] = [
{ value: 'role:SUPER_ADMIN', label: 'Super Admin' },
{ value: 'role:CONTENT_ADMIN', label: 'Content Admin' },
{ value: 'role:INFLUENCE_ADMIN', label: 'Influence Admin' },
{ value: 'role:MAP_ADMIN', label: 'Map Admin' },
{ value: 'role:BROADCAST_ADMIN', label: 'Broadcast Admin' },
{ value: 'role:MEDIA_ADMIN', label: 'Media Admin' },
{ value: 'role:PAYMENTS_ADMIN', label: 'Payments Admin' },
{ value: 'role:EVENTS_ADMIN', label: 'Events Admin' },
{ value: 'role:SOCIAL_ADMIN', label: 'Social Admin' },
];
function editorLabel(editor: string): string {
if (editor === 'all_content_editors') return 'All Content Editors';
if (editor.startsWith('role:')) {
const roleName = editor.substring(5);
return roleName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
if (editor.startsWith('user:')) return editor.substring(5);
return editor;
}
function editorColor(editor: string): string {
if (editor === 'all_content_editors') return 'green';
if (editor.startsWith('role:')) return 'blue';
if (editor.startsWith('user:')) return 'purple';
return 'default';
}
export function DocAccessPolicyPanel({ open, onClose, documentPath }: DocAccessPolicyPanelProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [policy, setPolicy] = useState<EffectivePolicy | null>(null);
const [allContentEditors, setAllContentEditors] = useState(true);
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const [userEmails, setUserEmails] = useState<string[]>([]);
const [isDirectory, setIsDirectory] = useState(false);
const fetchPolicy = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<EffectivePolicy>('/docs-access/policy', {
params: { path: documentPath },
});
setPolicy(data);
// Parse allowedEditors into form state
const hasAllContentEditors = data.allowedEditors.includes('all_content_editors');
setAllContentEditors(hasAllContentEditors);
setIsDirectory(data.isDirectory);
const roles: string[] = [];
const users: string[] = [];
for (const editor of data.allowedEditors) {
if (editor === 'all_content_editors') continue;
if (editor.startsWith('role:')) roles.push(editor);
else if (editor.startsWith('user:')) users.push(editor.substring(5));
}
setSelectedRoles(roles);
setUserEmails(users);
} catch {
message.error('Failed to load access policy');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchPolicy();
}
}, [open, documentPath, fetchPolicy]);
const handleSave = async () => {
if (!documentPath) return;
setSaving(true);
try {
const allowedEditors: string[] = [];
if (allContentEditors) {
allowedEditors.push('all_content_editors');
}
allowedEditors.push(...selectedRoles);
allowedEditors.push(...userEmails.map((e) => `user:${e}`));
if (allowedEditors.length === 0) {
message.warning('At least one editor must be specified');
setSaving(false);
return;
}
await api.put('/docs-access/policy', {
documentPath,
isDirectory,
allowedEditors,
});
message.success('Access policy saved');
fetchPolicy();
} catch {
message.error('Failed to save access policy');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!documentPath) return;
setSaving(true);
try {
await api.delete('/docs-access/policy', {
params: { path: documentPath },
});
message.success('Policy reset to default');
fetchPolicy();
} catch {
message.error('Failed to reset policy');
} finally {
setSaving(false);
}
};
const fileName = documentPath?.split('/').pop() || 'Document';
return (
<Drawer
title={
<Space>
<LockOutlined />
<span>Access Policy</span>
</Space>
}
open={open}
onClose={onClose}
width={480}
destroyOnClose
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : !documentPath ? (
<Alert message="No document selected" type="info" />
) : (
<>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Text type="secondary">Document</Text>
<br />
<Text strong>{fileName}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{documentPath}
</Text>
</div>
{policy?.isDefault && !policy.id && (
<Alert
message="Default policy"
description="No custom policy is set. All content editors can edit this file."
type="info"
showIcon
/>
)}
{policy && !policy.isDefault && (
<Alert
message="Custom policy active"
description={
policy.isDirectory
? `Directory policy applied from: ${policy.documentPath}`
: 'File-specific policy'
}
type="warning"
showIcon
/>
)}
<Divider style={{ margin: '8px 0' }} />
<Title level={5} style={{ margin: 0 }}>
Current Editors
</Title>
<div>
{policy?.allowedEditors.map((editor) => (
<Tag
key={editor}
color={editorColor(editor)}
icon={
editor === 'all_content_editors' ? (
<TeamOutlined />
) : editor.startsWith('user:') ? (
<UserOutlined />
) : undefined
}
style={{ marginBottom: 4 }}
>
{editorLabel(editor)}
</Tag>
))}
</div>
<Divider style={{ margin: '8px 0' }} />
<Title level={5} style={{ margin: 0 }}>
Edit Policy
</Title>
<Form layout="vertical" style={{ width: '100%' }}>
<Form.Item label="All content editors (default)">
<Switch
checked={allContentEditors}
onChange={setAllContentEditors}
checkedChildren="On"
unCheckedChildren="Off"
/>
</Form.Item>
<Form.Item label="Additional roles">
<Select
mode="multiple"
value={selectedRoles}
onChange={setSelectedRoles}
options={AVAILABLE_ROLES}
placeholder="Select roles..."
allowClear
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Specific users (by email)">
<Select
mode="tags"
value={userEmails}
onChange={setUserEmails}
placeholder="Type email and press Enter..."
tokenSeparators={[',']}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Apply to directory">
<Switch
checked={isDirectory}
onChange={setIsDirectory}
checkedChildren="Directory"
unCheckedChildren="File only"
/>
{isDirectory && (
<Text type="secondary" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>
This policy will apply to all files within this directory and subdirectories.
</Text>
)}
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
>
Save Policy
</Button>
<Button
icon={<UndoOutlined />}
onClick={handleReset}
loading={saving}
disabled={policy?.isDefault ?? true}
>
Reset to Default
</Button>
</Space>
</Form.Item>
</Form>
</Space>
</>
)}
</Drawer>
);
}

View File

@ -1,377 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Timeline,
Button,
Spin,
Space,
Typography,
Popconfirm,
message,
Empty,
Tag,
Divider,
theme,
} from 'antd';
import {
HistoryOutlined,
UserOutlined,
RollbackOutlined,
FileTextOutlined,
EyeOutlined,
CloseOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
interface DocHistoryDrawerProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
currentContent: string;
onRestore: (content: string) => void;
}
interface HistoryCommit {
sha: string;
commit: {
message: string;
author: { name: string; email: string; date: string };
committer: { name: string; email: string; date: string };
};
}
export function DocHistoryDrawer({
open,
onClose,
documentPath,
currentContent,
onRestore,
}: DocHistoryDrawerProps) {
const { token: themeToken } = theme.useToken();
const [loading, setLoading] = useState(false);
const [commits, setCommits] = useState<HistoryCommit[]>([]);
const [selectedSha, setSelectedSha] = useState<string | null>(null);
const [revisionContent, setRevisionContent] = useState<string | null>(null);
const [loadingRevision, setLoadingRevision] = useState(false);
const [restoring, setRestoring] = useState(false);
const fetchHistory = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<{ commits: HistoryCommit[] }>(
`/docs/history/${documentPath}`,
);
setCommits(data.commits);
} catch {
message.error('Failed to load version history');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchHistory();
setSelectedSha(null);
setRevisionContent(null);
}
}, [open, documentPath, fetchHistory]);
const handleSelectCommit = async (sha: string) => {
if (!documentPath) return;
if (selectedSha === sha) {
// Toggle off
setSelectedSha(null);
setRevisionContent(null);
return;
}
setSelectedSha(sha);
setLoadingRevision(true);
try {
const { data } = await api.get<{ sha: string; path: string; content: string }>(
`/docs/revision/${sha}/${documentPath}`,
);
setRevisionContent(data.content);
} catch {
message.error('Failed to load revision');
setRevisionContent(null);
} finally {
setLoadingRevision(false);
}
};
const handleRestore = async () => {
if (!documentPath || !selectedSha) return;
setRestoring(true);
try {
const { data } = await api.post<{ success: boolean; path: string }>(
`/docs/restore/${selectedSha}/${documentPath}`,
);
if (data.success && revisionContent) {
onRestore(revisionContent);
message.success('File restored to selected version');
setSelectedSha(null);
setRevisionContent(null);
fetchHistory();
} else {
message.error('Failed to restore revision');
}
} catch {
message.error('Failed to restore revision');
} finally {
setRestoring(false);
}
};
const fileName = documentPath?.split('/').pop() || 'Document';
// Simple line-by-line diff display
const renderDiffView = () => {
if (loadingRevision) {
return (
<div style={{ textAlign: 'center', padding: 20 }}>
<Spin />
</div>
);
}
if (revisionContent === null) return null;
const currentLines = currentContent.split('\n');
const historicalLines = revisionContent.split('\n');
return (
<div style={{ marginTop: 12 }}>
<Space style={{ marginBottom: 8 }}>
<Tag color="blue">Viewing revision {selectedSha?.substring(0, 7)}</Tag>
<Popconfirm
title="Restore this version?"
description="The current file content will be replaced with this revision."
onConfirm={handleRestore}
okText="Restore"
cancelText="Cancel"
>
<Button
type="primary"
size="small"
icon={<RollbackOutlined />}
loading={restoring}
>
Restore
</Button>
</Popconfirm>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => {
setSelectedSha(null);
setRevisionContent(null);
}}
>
Close
</Button>
</Space>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
}}
>
<div>
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
Historical ({selectedSha?.substring(0, 7)})
</Text>
<pre
style={{
background: themeToken.colorBgLayout,
border: `1px solid ${themeToken.colorBorderSecondary}`,
borderRadius: themeToken.borderRadius,
padding: 8,
fontSize: 11,
lineHeight: 1.5,
overflow: 'auto',
maxHeight: 400,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}
>
{historicalLines.map((line, i) => {
const isDiff = i < currentLines.length && line !== currentLines[i];
const isAdded = i >= currentLines.length;
return (
<div
key={i}
style={{
background: isDiff
? 'rgba(82, 196, 26, 0.1)'
: isAdded
? 'rgba(82, 196, 26, 0.15)'
: undefined,
}}
>
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
{i + 1}
</Text>
{line}
</div>
);
})}
</pre>
</div>
<div>
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
Current
</Text>
<pre
style={{
background: themeToken.colorBgLayout,
border: `1px solid ${themeToken.colorBorderSecondary}`,
borderRadius: themeToken.borderRadius,
padding: 8,
fontSize: 11,
lineHeight: 1.5,
overflow: 'auto',
maxHeight: 400,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}
>
{currentLines.map((line, i) => {
const isDiff = i < historicalLines.length && line !== historicalLines[i];
const isAdded = i >= historicalLines.length;
return (
<div
key={i}
style={{
background: isDiff
? 'rgba(245, 34, 45, 0.1)'
: isAdded
? 'rgba(245, 34, 45, 0.15)'
: undefined,
}}
>
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
{i + 1}
</Text>
{line}
</div>
);
})}
</pre>
</div>
</div>
</div>
);
};
return (
<Drawer
title={
<Space>
<HistoryOutlined />
<span>Version History</span>
</Space>
}
open={open}
onClose={onClose}
width={selectedSha ? 800 : 420}
destroyOnClose
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : !documentPath ? (
<Empty description="No document selected" />
) : commits.length === 0 ? (
<Empty
image={<FileTextOutlined style={{ fontSize: 48, color: themeToken.colorTextDisabled }} />}
description="No version history available"
>
<Text type="secondary">
History is recorded when files are saved with Gitea integration enabled.
</Text>
</Empty>
) : (
<Space direction="vertical" style={{ width: '100%' }} size="small">
<div>
<Text type="secondary">File: </Text>
<Text strong>{fileName}</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{commits.length} revision{commits.length !== 1 ? 's' : ''}
</Text>
<Divider style={{ margin: '8px 0' }} />
<Timeline
items={commits.map((commit) => ({
color: selectedSha === commit.sha ? themeToken.colorPrimary : themeToken.colorTextSecondary,
children: (
<div
key={commit.sha}
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: themeToken.borderRadius,
background:
selectedSha === commit.sha
? themeToken.colorPrimaryBg
: undefined,
transition: 'background 0.2s',
}}
onClick={() => handleSelectCommit(commit.sha)}
>
<div style={{ marginBottom: 2 }}>
<Text strong style={{ fontSize: 13 }}>
{commit.commit.message}
</Text>
</div>
<Space size={8}>
<Text type="secondary" style={{ fontSize: 11 }}>
<UserOutlined style={{ marginRight: 4 }} />
{commit.commit.author.name}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(commit.commit.author.date).fromNow()}
</Text>
<Tag style={{ fontSize: 10 }}>{commit.sha.substring(0, 7)}</Tag>
</Space>
{selectedSha !== commit.sha && (
<div style={{ marginTop: 4 }}>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={(e) => {
e.stopPropagation();
handleSelectCommit(commit.sha);
}}
>
View
</Button>
</div>
)}
</div>
),
}))}
/>
{renderDiffView()}
</Space>
)}
</Drawer>
);
}

View File

@ -1,390 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Form,
Select,
Switch,
Button,
Input,
InputNumber,
Table,
Tag,
Space,
Typography,
Divider,
Tooltip,
message,
Alert,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ShareAltOutlined,
CopyOutlined,
StopOutlined,
LinkOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
dayjs.extend(relativeTime);
const { Text, Paragraph } = Typography;
interface DocSharePanelProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
}
interface ShareLink {
id: string;
documentPath: string;
shareToken: string;
status: 'ACTIVE' | 'REVOKED' | 'EXPIRED';
canEdit: boolean;
expiresAt: string | null;
maxUses: number | null;
useCount: number;
guestName: string | null;
createdBy: { id: string; name: string | null; email: string };
createdAt: string;
updatedAt: string;
}
const EXPIRY_OPTIONS = [
{ value: 1, label: '1 hour' },
{ value: 24, label: '24 hours' },
{ value: 168, label: '7 days' },
{ value: 720, label: '30 days' },
{ value: 0, label: 'No expiry' },
];
function statusTag(status: string) {
switch (status) {
case 'ACTIVE':
return (
<Tag icon={<CheckCircleOutlined />} color="success">
Active
</Tag>
);
case 'REVOKED':
return (
<Tag icon={<CloseCircleOutlined />} color="error">
Revoked
</Tag>
);
case 'EXPIRED':
return (
<Tag icon={<ClockCircleOutlined />} color="default">
Expired
</Tag>
);
default:
return <Tag>{status}</Tag>;
}
}
export function DocSharePanel({ open, onClose, documentPath }: DocSharePanelProps) {
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [links, setLinks] = useState<ShareLink[]>([]);
const [generatedUrl, setGeneratedUrl] = useState<string | null>(null);
// Form state
const [canEdit, setCanEdit] = useState(true);
const [expiryHours, setExpiryHours] = useState<number>(168); // 7 days default
const [maxUses, setMaxUses] = useState<number | null>(null);
const [guestName, setGuestName] = useState('');
const fetchLinks = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<{ links: ShareLink[] }>('/docs-access/share/links', {
params: { path: documentPath },
});
setLinks(data.links);
} catch {
message.error('Failed to load share links');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchLinks();
setGeneratedUrl(null);
}
}, [open, documentPath, fetchLinks]);
const handleCreate = async () => {
if (!documentPath) return;
setCreating(true);
try {
const payload: Record<string, unknown> = {
documentPath,
canEdit,
};
if (expiryHours > 0) {
payload.expiresInHours = expiryHours;
}
if (maxUses && maxUses > 0) {
payload.maxUses = maxUses;
}
if (guestName.trim()) {
payload.guestName = guestName.trim();
}
const { data } = await api.post<{ id: string; shareToken: string; documentPath: string }>(
'/docs-access/share/create',
payload,
);
const url = `${window.location.origin}/docs/share/${data.shareToken}`;
setGeneratedUrl(url);
message.success('Share link created');
fetchLinks();
} catch {
message.error('Failed to create share link');
} finally {
setCreating(false);
}
};
const handleCopyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
message.success('Link copied to clipboard');
} catch {
message.error('Failed to copy link');
}
};
const handleRevoke = async (id: string) => {
try {
await api.patch(`/docs-access/share/${id}/revoke`);
message.success('Share link revoked');
fetchLinks();
} catch {
message.error('Failed to revoke share link');
}
};
const columns: ColumnsType<ShareLink> = [
{
title: 'Token',
dataIndex: 'shareToken',
key: 'token',
width: 100,
render: (token: string) => (
<Tooltip title={token}>
<Text code copyable={{ text: `${window.location.origin}/docs/share/${token}` }}>
{token.substring(0, 8)}...
</Text>
</Tooltip>
),
},
{
title: 'Guest',
dataIndex: 'guestName',
key: 'guest',
width: 100,
render: (name: string | null) => name || <Text type="secondary">--</Text>,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'created',
width: 110,
render: (date: string) => (
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
<span>{dayjs(date).fromNow()}</span>
</Tooltip>
),
},
{
title: 'Expiry',
dataIndex: 'expiresAt',
key: 'expiry',
width: 110,
render: (date: string | null) =>
date ? (
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
<span>{dayjs(date).fromNow()}</span>
</Tooltip>
) : (
<Text type="secondary">Never</Text>
),
},
{
title: 'Uses',
key: 'uses',
width: 70,
render: (_: unknown, record: ShareLink) => (
<span>
{record.useCount}
{record.maxUses ? ` / ${record.maxUses}` : ''}
</span>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 90,
render: (status: string) => statusTag(status),
},
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, record: ShareLink) =>
record.status === 'ACTIVE' ? (
<Tooltip title="Revoke">
<Button
type="text"
danger
size="small"
icon={<StopOutlined />}
onClick={() => handleRevoke(record.id)}
/>
</Tooltip>
) : null,
},
];
const fileName = documentPath?.split('/').pop() || 'Document';
return (
<Drawer
title={
<Space>
<ShareAltOutlined />
<span>Share Document</span>
</Space>
}
open={open}
onClose={onClose}
width={640}
destroyOnClose
>
{!documentPath ? (
<Alert message="No document selected" type="info" />
) : (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Text type="secondary">Document</Text>
<br />
<Text strong>{fileName}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{documentPath}
</Text>
</div>
<Divider style={{ margin: '8px 0' }}>Create Share Link</Divider>
<Form layout="vertical" style={{ width: '100%' }}>
<Form.Item label="Can edit">
<Switch
checked={canEdit}
onChange={setCanEdit}
checkedChildren="Edit"
unCheckedChildren="View only"
/>
</Form.Item>
<Form.Item label="Expiry">
<Select
value={expiryHours}
onChange={setExpiryHours}
options={EXPIRY_OPTIONS}
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item label="Max uses (optional)">
<InputNumber
min={1}
max={1000}
value={maxUses}
onChange={(v) => setMaxUses(v)}
placeholder="Unlimited"
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item label="Guest name (optional)">
<Input
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
placeholder="Name shown to collaborators"
maxLength={200}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
icon={<LinkOutlined />}
onClick={handleCreate}
loading={creating}
>
Generate Link
</Button>
</Form.Item>
</Form>
{generatedUrl && (
<Alert
message="Share link created"
description={
<Space direction="vertical" style={{ width: '100%' }}>
<Paragraph
copyable={{
text: generatedUrl,
onCopy: () => message.success('Copied'),
}}
style={{ margin: 0, wordBreak: 'break-all' }}
>
{generatedUrl}
</Paragraph>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyUrl(generatedUrl)}
>
Copy Link
</Button>
</Space>
}
type="success"
showIcon
closable
onClose={() => setGeneratedUrl(null)}
/>
)}
<Divider style={{ margin: '8px 0' }}>Active Links</Divider>
<Table<ShareLink>
columns={columns}
dataSource={links}
rowKey="id"
loading={loading}
size="small"
pagination={false}
scroll={{ x: 600 }}
locale={{ emptyText: 'No share links for this document' }}
/>
</Space>
)}
</Drawer>
);
}

View File

@ -1,129 +0,0 @@
import { useCallback } from 'react';
import { Button, Dropdown, Tooltip } from 'antd';
import {
BoldOutlined,
ItalicOutlined,
StrikethroughOutlined,
HighlightOutlined,
CodeOutlined,
FontSizeOutlined,
AlertOutlined,
PlusOutlined,
DownOutlined,
LinkOutlined,
FileMarkdownOutlined,
TableOutlined,
} from '@ant-design/icons';
import type { editor as monacoEditor } from 'monaco-editor';
import { SNIPPETS, PLATFORM_INSERT_IDS, applySnippet } from './mkdocs-snippets';
interface DocsEditorToolbarProps {
editorRef: React.RefObject<monacoEditor.IStandaloneCodeEditor | null>;
monacoRef: React.RefObject<typeof import('monaco-editor') | null>;
/** If true, show platform-specific inserts (video card, donate, etc.) */
showPlatformInserts?: boolean;
/** Custom handler for snippet IDs that need special treatment (modals, etc.) */
onCustomSnippet?: (snippetId: string) => boolean;
/** Background color — defaults to transparent */
background?: string;
/** Border color — defaults to rgba(255,255,255,0.08) */
borderColor?: string;
}
export default function DocsEditorToolbar({
editorRef,
monacoRef,
showPlatformInserts = false,
onCustomSnippet,
background = 'transparent',
borderColor = 'rgba(255,255,255,0.08)',
}: DocsEditorToolbarProps) {
const handleSnippet = useCallback((snippetId: string) => {
if (onCustomSnippet?.(snippetId)) return;
const snippet = SNIPPETS.find(s => s.id === snippetId);
if (!snippet || !editorRef.current || !monacoRef.current) return;
applySnippet(editorRef.current, snippet, monacoRef.current);
}, [editorRef, monacoRef, onCustomSnippet]);
const insertSnippets = SNIPPETS.filter(s =>
s.group === 'insert' && (showPlatformInserts || !PLATFORM_INSERT_IDS.has(s.id))
);
const getInsertIcon = (id: string) => {
if (id === 'link') return <LinkOutlined />;
if (id === 'image') return <FileMarkdownOutlined />;
if (id === 'table') return <TableOutlined />;
return <PlusOutlined />;
};
const btnStyle = { width: 26, height: 24 };
return (
<div
style={{
height: 28,
display: 'flex',
alignItems: 'center',
padding: '0 8px',
background,
borderBottom: `1px solid ${borderColor}`,
gap: 2,
flexShrink: 0,
overflow: 'hidden',
}}
>
<Tooltip title="Bold (Ctrl+B)" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<BoldOutlined />} onClick={() => handleSnippet('bold')} style={btnStyle} />
</Tooltip>
<Tooltip title="Italic (Ctrl+I)" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<ItalicOutlined />} onClick={() => handleSnippet('italic')} style={btnStyle} />
</Tooltip>
<Tooltip title="Strikethrough" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<StrikethroughOutlined />} onClick={() => handleSnippet('strikethrough')} style={btnStyle} />
</Tooltip>
<Tooltip title="Highlight" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<HighlightOutlined />} onClick={() => handleSnippet('highlight')} style={btnStyle} />
</Tooltip>
<Tooltip title="Inline Code" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<CodeOutlined />} onClick={() => handleSnippet('inline-code')} style={btnStyle} />
</Tooltip>
<Tooltip title="Keyboard Key" mouseEnterDelay={0.4}>
<Button type="text" size="small" style={{ ...btnStyle, fontSize: 11, fontWeight: 700 }} onClick={() => handleSnippet('kbd')}>K</Button>
</Tooltip>
<div style={{ width: 1, height: 16, background: borderColor, margin: '0 4px' }} />
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'heading').map(s => ({ key: s.id, label: s.label, icon: <FontSizeOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<FontSizeOutlined /> H <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<div style={{ width: 1, height: 16, background: borderColor, margin: '0 4px' }} />
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'admonition').map(s => ({ key: s.id, label: s.label, icon: <AlertOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<AlertOutlined /> Admonitions <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'code').map(s => ({ key: s.id, label: s.label, icon: <CodeOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<CodeOutlined /> Code <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<Dropdown menu={{ items: insertSnippets.map(s => ({
key: s.id,
label: s.label,
icon: getInsertIcon(s.id),
onClick: () => handleSnippet(s.id),
})) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<PlusOutlined /> Insert <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
</div>
);
}

View File

@ -30,7 +30,6 @@ import {
PlusOutlined,
CloseOutlined,
FileOutlined,
FolderOpenOutlined,
} from '@ant-design/icons';
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
import { isImageFile } from '@/hooks/useDocsEditor';
@ -53,7 +52,6 @@ import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
import { MoveToModal } from '@/components/docs/MoveToModal';
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
import { YTextareaBinding } from '@/lib/y-textarea';
@ -261,8 +259,6 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false);
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
const [moveSourcePath, setMoveSourcePath] = useState('');
const {
fileTree,
@ -291,7 +287,6 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
onContentChange,
handleDelete,
handleModalOk,
handleMoveFile,
handleNewFileRoot,
handleNewFolderRoot,
refreshTree,
@ -435,7 +430,6 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
}
items.push(
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
{ key: 'moveTo', icon: <FolderOpenOutlined />, label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } },
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
);
return items;
@ -916,14 +910,6 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
setWikiLinkPickerOpen(false);
}}
/>
<MoveToModal
open={moveToModalOpen}
fileTree={fileTree}
sourcePath={moveSourcePath}
onMove={(targetDir) => { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }}
onClose={() => setMoveToModalOpen(false)}
/>
</>
);
}

View File

@ -1,156 +0,0 @@
import { useState, useMemo } from 'react';
import { Drawer, Input, List, theme, Typography } from 'antd';
import { FolderOutlined, HomeOutlined } from '@ant-design/icons';
import type { FileNode } from '@/types/api';
interface DirEntry {
path: string;
depth: number;
}
function collectDirs(nodes: FileNode[], exclude?: string, depth = 0): DirEntry[] {
const dirs: DirEntry[] = [];
for (const node of nodes) {
if (!node.isDirectory) continue;
if (exclude && (node.path === exclude || node.path.startsWith(exclude + '/'))) continue;
dirs.push({ path: node.path, depth });
if (node.children) dirs.push(...collectDirs(node.children, exclude, depth + 1));
}
return dirs;
}
interface MoveToModalProps {
open: boolean;
fileTree: FileNode[];
sourcePath: string;
onMove: (targetDir: string) => void;
onClose: () => void;
}
export function MoveToModal({ open, fileTree, sourcePath, onMove, onClose }: MoveToModalProps) {
const { token } = theme.useToken();
const [search, setSearch] = useState('');
const currentParent = useMemo(() => {
const lastSlash = sourcePath.lastIndexOf('/');
return lastSlash >= 0 ? sourcePath.substring(0, lastSlash) : '';
}, [sourcePath]);
const isSourceDir = useMemo(() => {
function find(nodes: FileNode[]): boolean {
for (const n of nodes) {
if (n.path === sourcePath) return n.isDirectory;
if (n.isDirectory && n.children && find(n.children)) return true;
}
return false;
}
return find(fileTree);
}, [fileTree, sourcePath]);
const allDirs = useMemo(
() => collectDirs(fileTree, isSourceDir ? sourcePath : undefined),
[fileTree, sourcePath, isSourceDir],
);
const filtered = useMemo(() => {
if (!search.trim()) return allDirs;
const q = search.toLowerCase();
return allDirs.filter(d => d.path.toLowerCase().includes(q));
}, [allDirs, search]);
const handleSelect = (dir: string) => {
onMove(dir);
setSearch('');
};
const handleClose = () => {
onClose();
setSearch('');
};
const fileName = sourcePath.split('/').pop() || sourcePath;
return (
<Drawer
title={`Move "${fileName}"`}
open={open}
onClose={handleClose}
width={420}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
>
<Input.Search
placeholder="Search directories..."
value={search}
onChange={e => setSearch(e.target.value)}
allowClear
autoFocus
style={{ marginBottom: 12 }}
/>
<div style={{ maxHeight: 360, overflow: 'auto' }}>
{/* Root directory option */}
{(!search.trim() || '/ (root)'.includes(search.toLowerCase())) && (
<div
style={{
padding: '8px 12px',
cursor: currentParent === '' ? 'not-allowed' : 'pointer',
opacity: currentParent === '' ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
gap: 8,
borderRadius: 4,
transition: 'background 0.15s',
}}
onClick={() => currentParent !== '' && handleSelect('')}
onMouseEnter={e => { if (currentParent !== '') (e.currentTarget.style.background = token.colorBgTextHover); }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>
<HomeOutlined style={{ color: token.colorTextSecondary }} />
<span style={{ flex: 1 }}>/ (root)</span>
{currentParent === '' && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>current</Typography.Text>
)}
</div>
)}
<List
size="small"
dataSource={filtered}
locale={{ emptyText: 'No matching directories' }}
renderItem={item => {
const isCurrent = item.path === currentParent;
return (
<div
style={{
padding: '8px 12px',
paddingLeft: 12 + item.depth * 16,
cursor: isCurrent ? 'not-allowed' : 'pointer',
opacity: isCurrent ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
gap: 8,
borderRadius: 4,
transition: 'background 0.15s',
}}
onClick={() => !isCurrent && handleSelect(item.path)}
onMouseEnter={e => { if (!isCurrent) (e.currentTarget.style.background = token.colorBgTextHover); }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>
<FolderOutlined style={{ color: token.colorTextSecondary, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.path}
</div>
{isCurrent && (
<Typography.Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>current</Typography.Text>
)}
</div>
);
}}
/>
</div>
</Drawer>
);
}

View File

@ -1,170 +0,0 @@
import { useState } from 'react';
import { Drawer, Form, Input, DatePicker, Select, Switch, Button, message, theme } from 'antd';
import { FileMarkdownOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
interface NewBlogPostModalProps {
open: boolean;
onClose: () => void;
onCreated: (path: string) => void;
authors: Record<string, AuthorEntry>;
categories: string[];
}
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function NewBlogPostModal({
open,
onClose,
onCreated,
authors,
categories,
}: NewBlogPostModalProps) {
const { token } = theme.useToken();
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const titleValue = Form.useWatch('title', form) as string | undefined;
const dateValue = Form.useWatch('date', form) as dayjs.Dayjs | undefined;
const slug = titleValue ? slugify(titleValue) : '';
const dateStr = dateValue ? dateValue.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
const previewFilename = slug ? `blog/posts/${dateStr}-${slug}.md` : '';
const authorOptions = Object.entries(authors || {}).map(([key, entry]) => ({
label: entry.name,
value: key,
}));
const categoryOptions = (categories || []).map((cat) => ({
label: cat,
value: cat,
}));
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const res = await api.post<{ path: string }>('/docs/blog/posts', {
title: values.title,
date: values.date ? values.date.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
authors: values.authors ?? [],
categories: values.categories ?? [],
draft: values.draft ?? true,
});
messageApi.success('Blog post created');
form.resetFields();
onCreated(res.data.path);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
?.error?.message || 'Failed to create blog post';
messageApi.error(msg);
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
form.resetFields();
onClose();
};
return (
<Drawer
title={
<span>
<FileMarkdownOutlined style={{ marginRight: 8 }} />
New Blog Post
</span>
}
open={open}
onClose={handleClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button type="primary" onClick={handleSubmit} loading={submitting}>
Create
</Button>
}
>
{contextHolder}
<Form
form={form}
layout="vertical"
initialValues={{
date: dayjs(),
draft: true,
}}
style={{ marginTop: 16 }}
>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input placeholder="My New Blog Post" autoFocus />
</Form.Item>
{previewFilename && (
<div
style={{
marginTop: -12,
marginBottom: 16,
padding: '6px 10px',
borderRadius: token.borderRadius,
background: token.colorFillQuaternary,
fontSize: 12,
color: token.colorTextSecondary,
fontFamily: 'monospace',
}}
>
{previewFilename}
</div>
)}
<Form.Item name="date" label="Date">
<DatePicker format="YYYY-MM-DD" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="authors" label="Author(s)">
<Select
mode="multiple"
placeholder="Select authors"
options={authorOptions}
allowClear
/>
</Form.Item>
<Form.Item name="categories" label="Categories">
<Select
mode="tags"
placeholder="Add categories"
options={categoryOptions}
allowClear
/>
</Form.Item>
<Form.Item name="draft" label="Draft" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { Drawer, Input, List, theme, Typography, Tag } from 'antd';
import { Modal, Input, List, theme, Typography, Tag } from 'antd';
import { FileOutlined, PictureOutlined } from '@ant-design/icons';
import type { FileNode } from '@/types/api';
@ -62,15 +62,13 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
};
return (
<Drawer
<Modal
title="Insert Wiki Link"
open={open}
onClose={() => { onClose(); setSearch(''); }}
onCancel={() => { onClose(); setSearch(''); }}
footer={null}
destroyOnHidden
width={420}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
>
<Input.Search
placeholder="Search files..."
@ -150,6 +148,6 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
</div>
)}
</div>
</Drawer>
</Modal>
);
}

View File

@ -1,117 +0,0 @@
import type { editor as monacoEditor } from 'monaco-editor';
export interface MkDocsSnippet {
id: string;
label: string;
group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert';
type: 'wrap' | 'block' | 'insert';
prefix?: string;
suffix?: string;
template?: string;
keybinding?: 'ctrl+b' | 'ctrl+i';
}
export const SNIPPETS: MkDocsSnippet[] = [
// Formatting
{ id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' },
{ id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' },
{ id: 'strikethrough', label: 'Strikethrough', group: 'formatting', type: 'wrap', prefix: '~~', suffix: '~~' },
{ id: 'highlight', label: 'Highlight', group: 'formatting', type: 'wrap', prefix: '==', suffix: '==' },
{ id: 'inline-code', label: 'Inline Code', group: 'formatting', type: 'wrap', prefix: '`', suffix: '`' },
{ id: 'kbd', label: 'Keyboard Key', group: 'formatting', type: 'wrap', prefix: '++', suffix: '++' },
// Headings
{ id: 'h1', label: 'Heading 1', group: 'heading', type: 'block', template: '# $CURSOR' },
{ id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' },
{ id: 'h3', label: 'Heading 3', group: 'heading', type: 'block', template: '### $CURSOR' },
{ id: 'h4', label: 'Heading 4', group: 'heading', type: 'block', template: '#### $CURSOR' },
// Admonitions
...(['note', 'warning', 'tip', 'danger', 'info', 'success', 'question', 'abstract', 'example', 'bug', 'quote'] as const).map((t) => ({
id: `admonition-${t}`,
label: `${t.charAt(0).toUpperCase() + t.slice(1)}`,
group: 'admonition' as const,
type: 'block' as const,
template: `!!! ${t} "Title"\n Content here`,
})),
{ id: 'admonition-collapsible-open', label: 'Collapsible (open)', group: 'admonition', type: 'block', template: '???+ note "Title"\n Content here' },
{ id: 'admonition-collapsible-closed', label: 'Collapsible (closed)', group: 'admonition', type: 'block', template: '??? note "Title"\n Content here' },
// Code
{ id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\n$CURSOR\n```' },
{ id: 'code-annotated', label: 'Annotated Code', group: 'code', type: 'block', template: '```python\ncode # (1)!\n```\n\n1. Annotation' },
{ id: 'mermaid', label: 'Mermaid Diagram', group: 'code', type: 'block', template: '```mermaid\ngraph LR\n A --> B\n```' },
// Inserts (standard markdown — no auth required)
{ id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' },
{ id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' },
{ id: 'button', label: 'Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button }' },
{ id: 'button-primary', label: 'Primary Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button .md-button--primary }' },
{ id: 'icon', label: 'Material Icon', group: 'insert', type: 'insert', template: ':material-icon-name:' },
{ id: 'table', label: 'Table', group: 'insert', type: 'insert', template: '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |' },
{ id: 'tasklist', label: 'Task List', group: 'insert', type: 'insert', template: '- [ ] Task 1\n- [ ] Task 2\n- [x] Done' },
{ id: 'tabs', label: 'Tabs', group: 'insert', type: 'insert', template: '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content' },
{ id: 'math-block', label: 'Math Block', group: 'insert', type: 'block', template: '$$\n$CURSOR\n$$' },
{ id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' },
{ id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' },
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
// Platform-specific inserts (require auth — handled by DocsPage modals)
{ id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' },
{ id: 'photo-insert', label: 'Photo', group: 'insert', type: 'insert', template: '' },
{ id: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' },
{ id: 'wiki-link', label: 'Wiki Link [[]]', group: 'insert', type: 'insert', template: '' },
];
/** IDs of insert snippets that require authenticated API access (modal-based) */
export const PLATFORM_INSERT_IDS = new Set([
'video-card', 'photo-insert', 'donate-button', 'pricing-table',
'product-card', 'ad-insert', 'scheduling-poll', 'wiki-link',
]);
export function applySnippet(
ed: monacoEditor.IStandaloneCodeEditor,
snippet: MkDocsSnippet,
monaco: typeof import('monaco-editor'),
) {
const sel = ed.getSelection();
const model = ed.getModel();
if (!sel || !model) return;
const selectedText = model.getValueInRange(sel);
if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) {
if (selectedText) {
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.prefix + selectedText + snippet.suffix,
}]);
} else {
const placeholder = 'text';
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.prefix + placeholder + snippet.suffix,
}]);
const pos = sel.getStartPosition();
const startCol = pos.column + snippet.prefix.length;
ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length));
}
} else if (snippet.type === 'block' && snippet.template) {
const pos = sel.getStartPosition();
let text = snippet.template.replace('$CURSOR', selectedText);
const lineContent = model.getLineContent(pos.lineNumber);
if (pos.column > 1 && lineContent.substring(0, pos.column - 1).trim().length > 0) {
text = '\n' + text;
}
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text,
}]);
} else if (snippet.type === 'insert' && snippet.template) {
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.template,
}]);
}
ed.focus();
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Drawer, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography, Grid } from 'antd';
import { Modal, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography, Grid } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@ -118,19 +118,19 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
];
return (
<Drawer
<Modal
title={`Send Test Email: ${template.name}`}
open={open}
onClose={onClose}
onCancel={onClose}
width={isMobile ? '95vw' : 900}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" loading={sending} onClick={handleSend}>
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button key="send" type="primary" loading={sending} onClick={handleSend}>
Send Test Email
</Button>
}
</Button>,
]}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Form form={form} layout="vertical">
@ -244,6 +244,6 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
]}
/>
</Space>
</Drawer>
</Modal>
);
}

View File

@ -39,7 +39,6 @@ import type {
AreaImportProgress,
AreaImportSourceStatus,
} from '@/types/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text, Title } = Typography;
@ -233,8 +232,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
try {
const { data } = await api.post('/map/area-import/preview', buildRequestBody());
setPreview(data);
} catch (err: unknown) {
setPreviewError(getErrorMessage(err, 'Preview failed'));
} catch (err: any) {
setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed');
} finally {
setPreviewLoading(false);
}
@ -260,8 +259,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
// Ignore polling errors
}
}, 2000);
} catch (err: unknown) {
setPreviewError(getErrorMessage(err, 'Failed to start import'));
} catch (err: any) {
setPreviewError(err?.response?.data?.error?.message || 'Failed to start import');
setImporting(false);
}
};

View File

@ -1,10 +1,9 @@
import { useState, useEffect } from 'react';
import { Drawer, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
import { Modal, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PlaylistSummary } from '@/types/media';
import axios from 'axios';
const { Text } = Typography;
@ -140,8 +139,8 @@ export default function AddToPlaylistModal({
{ ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
]);
setSelections((prev) => ({ ...prev, [data.id]: true }));
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else {
message.error('Failed to create playlist');
@ -152,19 +151,13 @@ export default function AddToPlaylistModal({
};
return (
<Drawer
<Modal
title="Add to Playlist"
open={open}
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
Save
</Button>
}
onOk={handleSave}
onCancel={onClose}
confirmLoading={saving}
okText="Save"
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
@ -244,6 +237,6 @@ export default function AddToPlaylistModal({
)}
</>
)}
</Drawer>
</Modal>
);
}

View File

@ -1,8 +1,17 @@
import { Card, Tag, Badge } from 'antd';
import { FolderOpenOutlined, GlobalOutlined, PictureOutlined } from '@ant-design/icons';
import { useSignedMediaUrl } from '@/lib/media-url';
import { getAuthCallbacks } from '@/lib/api';
import type { PhotoAlbum } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface AlbumCardProps {
album: PhotoAlbum;
onClick?: (album: PhotoAlbum) => void;
@ -10,7 +19,6 @@ interface AlbumCardProps {
export default function AlbumCard({ album, onClick }: AlbumCardProps) {
const coverUrl = album.coverThumbnailUrl;
const signedCoverUrl = useSignedMediaUrl(coverUrl);
return (
<Card
@ -27,9 +35,9 @@ export default function AlbumCard({ album, onClick }: AlbumCardProps) {
overflow: 'hidden',
}}
>
{coverUrl && signedCoverUrl ? (
{coverUrl ? (
<img
src={signedCoverUrl}
src={getAuthenticatedUrl(coverUrl)}
alt={album.title}
style={{
position: 'absolute',

View File

@ -7,26 +7,16 @@ import {
GlobalOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { useSignedMediaUrl } from '@/lib/media-url';
import { getAuthCallbacks } from '@/lib/api';
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
function PhotoThumbnail({ url, alt }: { url: string; alt: string }) {
const signed = useSignedMediaUrl(url);
if (!signed) {
return (
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4 }} aria-label={alt} />
);
}
return (
<Image
src={signed}
width={60}
height={45}
style={{ objectFit: 'cover', borderRadius: 4 }}
preview={false}
alt={alt}
/>
);
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface AlbumDetailDrawerProps {
@ -210,7 +200,13 @@ export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }:
<List.Item.Meta
avatar={
photo.thumbnailUrl ? (
<PhotoThumbnail url={photo.thumbnailUrl} alt={photo.title || photo.originalFilename || ''} />
<Image
src={getAuthenticatedUrl(photo.thumbnailUrl)}
width={60}
height={45}
style={{ objectFit: 'cover', borderRadius: 4 }}
preview={false}
/>
) : (
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PictureOutlined style={{ color: '#555' }} />

View File

@ -1,7 +1,6 @@
import { Drawer, Select, Button, message } from 'antd';
import { Modal, Select, message } from 'antd';
import { useState } from 'react';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface BulkAccessLevelModalProps {
open: boolean;
@ -29,27 +28,21 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
});
message.success(`Updated access level to "${accessLevel}" for ${data.updatedCount} video(s)`);
onSuccess();
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to update access levels'));
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to update access levels');
} finally {
setLoading(false);
}
};
return (
<Drawer
<Modal
title={`Set Access Level (${videoIds.length} video${videoIds.length !== 1 ? 's' : ''})`}
open={open}
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleOk} loading={loading}>
Apply
</Button>
}
onOk={handleOk}
onCancel={onClose}
confirmLoading={loading}
okText="Apply"
>
<div style={{ marginTop: 16 }}>
<Select
@ -60,6 +53,6 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
size="large"
/>
</div>
</Drawer>
</Modal>
);
}

View File

@ -1,9 +1,8 @@
import { useState, useEffect } from 'react';
import { Drawer, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import type { PlaylistSummary } from '@/types/media';
import axios from 'axios';
const { Text } = Typography;
@ -63,8 +62,8 @@ export default function BulkAddToPlaylistModal({
try {
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
added++;
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
} catch (error: any) {
if (error.response?.status === 409) {
skipped++;
} else {
throw error;
@ -99,8 +98,8 @@ export default function BulkAddToPlaylistModal({
setNewName('');
setShowCreate(false);
message.success(`Created "${data.name}"`);
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else {
message.error('Failed to create playlist');
@ -113,19 +112,14 @@ export default function BulkAddToPlaylistModal({
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
return (
<Drawer
<Modal
title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`}
open={open}
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleAdd} loading={saving} disabled={!selectedPlaylistId}>
Add
</Button>
}
onOk={handleAdd}
onCancel={onClose}
confirmLoading={saving}
okText="Add"
okButtonProps={{ disabled: !selectedPlaylistId }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
@ -189,6 +183,6 @@ export default function BulkAddToPlaylistModal({
)}
</>
)}
</Drawer>
</Modal>
);
}

View File

@ -16,7 +16,6 @@ import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import axios from 'axios';
dayjs.extend(relativeTime);
@ -106,8 +105,8 @@ export default function CommentSection({ videoId }: CommentSectionProps) {
setComments((prev) => [response.data.comment, ...prev]);
setCommentText('');
message.success('Comment posted!');
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
} catch (error: any) {
if (error.response?.status === 401) {
message.error('Please log in to comment');
} else {
message.error('Failed to post comment');

View File

@ -1,7 +1,6 @@
import { useState } from 'react';
import { Drawer, Form, Input, Button, message } from 'antd';
import { Modal, Form, Input, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
interface CreateAlbumModalProps {
open: boolean;
@ -31,8 +30,8 @@ export default function CreateAlbumModal({
message.success('Album created');
form.resetFields();
onSuccess();
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.data?.message) {
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message);
}
} finally {
@ -41,19 +40,13 @@ export default function CreateAlbumModal({
};
return (
<Drawer
<Modal
title="Create Album"
open={open}
onClose={onClose}
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleCreate} loading={loading}>
Create
</Button>
}
onOk={handleCreate}
onCancel={onClose}
confirmLoading={loading}
okText="Create"
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
@ -68,6 +61,6 @@ export default function CreateAlbumModal({
{selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album
</div>
)}
</Drawer>
</Modal>
);
}

View File

@ -1,7 +1,6 @@
import { useState } from 'react';
import { Drawer, Form, Input, Switch, Button, message } from 'antd';
import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
interface CreatePlaylistModalProps {
open: boolean;
@ -32,10 +31,10 @@ export default function CreatePlaylistModal({
form.resetFields();
onCreated?.(data);
onClose();
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else if (!(error instanceof Object && 'errorFields' in error)) {
} else if (!error.errorFields) {
message.error('Failed to create playlist');
}
} finally {
@ -53,12 +52,17 @@ export default function CreatePlaylistModal({
}}
placement="right"
width={420}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
style={{ top: 64 }}
styles={{ body: { paddingTop: 24 } }}
extra={
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create
</Button>
<Space>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create
</Button>
</Space>
}
>
<Form form={form} layout="vertical">

View File

@ -2,7 +2,6 @@ import { useEffect } from 'react';
import { Drawer, Form, Input, Select, Descriptions, message, Button } from 'antd';
import { mediaApi } from '@/lib/media-api';
import type { Photo } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface EditPhotoModalProps {
photo: Photo | null;
@ -35,8 +34,8 @@ export default function EditPhotoModal({ photo, open, onClose, onSuccess }: Edit
await mediaApi.patch(`/photos/${photo.id}`, values);
message.success('Photo updated');
onSuccess();
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to update photo'));
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to update photo');
}
};

View File

@ -4,7 +4,6 @@ import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/
import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PlaylistVideoItem } from '@/types/media';
import axios from 'axios';
const { Text } = Typography;
@ -72,10 +71,10 @@ export default function EditPlaylistModal({
message.success('Playlist updated');
onUpdated?.();
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else if (!(error instanceof Object && 'errorFields' in error)) {
} else if (!error.errorFields) {
message.error('Failed to update playlist');
}
} finally {
@ -130,8 +129,7 @@ export default function EditPlaylistModal({
}}
placement="right"
width={isMobile ? '100%' : 520}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
style={{ top: 64 }}
loading={loading}
>
<Tabs

View File

@ -3,7 +3,6 @@ import { EditOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import { mediaApi } from '@/lib/media-api';
import type { Video } from '@/types/media';
import axios from 'axios';
interface EditVideoDrawerProps {
video: Video | null;
@ -88,8 +87,8 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
message.success('Video updated successfully');
onSuccess?.();
onClose();
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.data?.message) {
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message);
}
// form validation errors are shown inline

View File

@ -13,7 +13,6 @@ import {
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicAlbum } from '@/types/media';
import axios from 'axios';
const { useBreakpoint } = Grid;
@ -104,8 +103,8 @@ export default function ExpandedAlbumCard({ album }: ExpandedAlbumCardProps) {
}
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
} catch (error: any) {
if (error.response?.status === 401) {
message.info('Please log in to upvote');
}
} finally {

View File

@ -13,7 +13,6 @@ import {
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicPhoto } from '@/types/media';
import axios from 'axios';
const { useBreakpoint } = Grid;
@ -81,8 +80,8 @@ export default function ExpandedPhotoCard({ photo }: ExpandedPhotoCardProps) {
await mediaPublicApi.post(`/photos/${photo.id}/upvote`);
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
} catch (error: any) {
if (error.response?.status === 401) {
message.info('Please log in to upvote');
}
} finally {

View File

@ -18,7 +18,6 @@ import ReactionButtons from './ReactionButtons';
import AddToPlaylistModal from './AddToPlaylistModal';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import axios from 'axios';
const { useBreakpoint } = Grid;
@ -118,9 +117,9 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
await mediaPublicApi.post(`/public/${video.id}/upvote`);
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: unknown) {
} catch (error: any) {
console.error('Upvote failed:', error);
if (axios.isAxiosError(error) && error.response?.status === 401) {
if (error.response?.status === 401) {
alert('Please log in to upvote videos');
}
} finally {

View File

@ -26,7 +26,6 @@ import {
ExpandOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { TextArea } = Input;
const { Text } = Typography;
@ -148,9 +147,15 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
const connectSSE = async () => {
try {
// Get auth token from in-memory store (not localStorage)
const { useAuthStore } = await import('@/stores/auth.store');
const token = useAuthStore.getState().accessToken || '';
// Get auth token from localStorage
const stored = localStorage.getItem('auth-storage');
let token = '';
if (stored) {
try {
const parsed = JSON.parse(stored);
token = parsed?.state?.accessToken || '';
} catch {}
}
const response = await fetch(baseUrl, {
headers: {
@ -255,8 +260,8 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
// Immediately expand the new job
setExpandedJobId(data.jobId);
fetchJobs();
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to submit fetch job'));
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to submit fetch job');
} finally {
setSubmitting(false);
}
@ -267,8 +272,8 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
await mediaApi.delete(`/videos/fetch/jobs/${jobId}`);
message.success('Job cancelled');
fetchJobs();
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to cancel job'));
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to cancel job');
}
};

View File

@ -20,7 +20,6 @@ import {
import { useMediaAuth } from '@/contexts/MediaAuthContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
const { Text } = Typography;
const { TextArea } = Input;
@ -303,12 +302,12 @@ export default function LiveChat({
setCommentInput('');
// Note: New comment will appear via SSE broadcast
} catch (err: unknown) {
} catch (err: any) {
console.error('Failed to submit comment:', err);
if (axios.isAxiosError(err) && err.response?.status === 429) {
if (err.response?.status === 429) {
alert('Rate limit exceeded. Please wait a minute before commenting again.');
} else if (axios.isAxiosError(err) && err.response?.status === 401) {
} else if (err.response?.status === 401) {
alert('Please log in to comment.');
if (onRequestLogin) {
onRequestLogin();

View File

@ -8,9 +8,18 @@ import {
FolderOutlined,
PictureOutlined,
} from '@ant-design/icons';
import { useSignedMediaUrl } from '@/lib/media-url';
import { getAuthCallbacks } from '@/lib/api';
import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface PhotoCardProps {
photo: Photo;
selected?: boolean;
@ -41,7 +50,6 @@ export default function PhotoCard({
onTogglePublish,
}: PhotoCardProps) {
const thumbnailUrl = photo.thumbnailUrl;
const signedThumbnailUrl = useSignedMediaUrl(thumbnailUrl);
const hoverActions = (
<div
@ -104,9 +112,9 @@ export default function PhotoCard({
overflow: 'hidden',
}}
>
{thumbnailUrl && signedThumbnailUrl ? (
{thumbnailUrl ? (
<img
src={signedThumbnailUrl}
src={getAuthenticatedUrl(thumbnailUrl)}
alt={photo.title || photo.filename}
style={{
position: 'absolute',

View File

@ -1,8 +1,17 @@
import { Modal, Descriptions, Tag, Grid } from 'antd';
import { CameraOutlined } from '@ant-design/icons';
import { useSignedMediaUrl } from '@/lib/media-url';
import { getAuthCallbacks } from '@/lib/api';
import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface PhotoViewerModalProps {
photo: Photo | null;
open: boolean;
@ -13,11 +22,10 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const adminImageUrl = photo ? `/media/photos/${photo.id}/image?size=large` : null;
const signedImageUrl = useSignedMediaUrl(adminImageUrl);
if (!photo) return null;
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`;
return (
<Modal
open={open}
@ -40,7 +48,7 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
}}
>
<img
src={signedImageUrl}
src={getAuthenticatedUrl(adminImageUrl)}
alt={photo.title || photo.filename}
style={{
maxWidth: '100%',

View File

@ -1,7 +1,6 @@
import { useState } from 'react';
import { Modal, Select, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface PublishModalProps {
open: boolean;
@ -29,8 +28,8 @@ export default function PublishModal({ open, videoIds, onSuccess, onCancel }: Pu
message.success(`Successfully published ${videoIds.length} video(s) to ${category}`);
onSuccess();
setCategory('videos');
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to publish videos'));
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to publish videos');
} finally {
setLoading(false);
}

View File

@ -9,7 +9,6 @@ import {
import { useEffect, useState } from 'react';
import { mediaApi } from '@/lib/media-api';
import type { VideoAnalytics } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface QuickAnalyticsModalProps {
videoId: number;
@ -42,9 +41,9 @@ export default function QuickAnalyticsModal({
setError(null);
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
setAnalytics(response.data);
} catch (error: unknown) {
} catch (error: any) {
console.error('Failed to fetch analytics:', error);
setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
} finally {
setLoading(false);
}

View File

@ -3,7 +3,6 @@ import { Space, Button, message, theme } from 'antd';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color';
import axios from 'axios';
interface ReactionButtonsProps {
videoId: number;
@ -64,8 +63,8 @@ export default function ReactionButtons({ videoId, currentTime }: ReactionButton
}, 2000);
message.success(`${emoji} reaction added!`);
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
} catch (error: any) {
if (error.response?.status === 401) {
message.error('Please log in to add reactions');
} else {
message.error('Failed to add reaction');

View File

@ -4,7 +4,6 @@ import { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined }
import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface ScheduleEvent {
jobId: string;
@ -56,9 +55,9 @@ export default function ScheduleCalendarDrawer({
params: { limit: 100 },
});
setSchedules(response.data.schedules || []);
} catch (error: unknown) {
} catch (error: any) {
console.error('Failed to fetch schedules:', error);
setError(getErrorMessage(error, 'Failed to load schedules. Please try again.'));
setError(error.response?.data?.message || 'Failed to load schedules. Please try again.');
} finally {
setLoading(false);
}
@ -70,8 +69,8 @@ export default function ScheduleCalendarDrawer({
message.success(`${action} schedule cancelled`);
fetchSchedules();
onRefresh?.();
} catch (error: unknown) {
message.error(getErrorMessage(error, `Failed to cancel ${action} schedule`));
} catch (error: any) {
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`);
}
};

View File

@ -1,4 +1,4 @@
import { Drawer, DatePicker, Select, Space, Alert, Switch, Button, message, Grid } from 'antd';
import { Modal, DatePicker, Select, Space, Alert, Switch, message, Grid } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs';
@ -6,7 +6,6 @@ import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { mediaApi } from '@/lib/media-api';
import type { Video } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
dayjs.extend(utc);
dayjs.extend(timezone);
@ -101,8 +100,8 @@ export default function SchedulePublishModal({
onSuccess?.();
onClose();
} catch (error: unknown) {
message.error(getErrorMessage(error, 'Failed to schedule video'));
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to schedule video');
} finally {
setLoading(false);
}
@ -117,8 +116,8 @@ export default function SchedulePublishModal({
message.success(`${action} schedule cancelled`);
onSuccess?.();
onClose();
} catch (error: unknown) {
message.error(getErrorMessage(error, `Failed to cancel ${action} schedule`));
} catch (error: any) {
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`);
} finally {
setLoading(false);
}
@ -152,7 +151,7 @@ export default function SchedulePublishModal({
const serverTime = publishAt?.utc().format('YYYY-MM-DD HH:mm:ss UTC');
return (
<Drawer
<Modal
title={
<Space>
<ClockCircleOutlined />
@ -160,16 +159,15 @@ export default function SchedulePublishModal({
</Space>
}
open={open}
onClose={onClose}
onCancel={onClose}
onOk={handleSchedule}
okText={publishNow ? 'Publish Now' : 'Schedule'}
confirmLoading={loading}
width={isMobile ? '95vw' : 600}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSchedule} loading={loading}>
{publishNow ? 'Publish Now' : 'Schedule'}
</Button>
}
style={{ top: 20 }}
styles={{
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
}}
aria-label="Schedule video publishing"
>
{video && (
@ -303,6 +301,6 @@ export default function SchedulePublishModal({
)}
</div>
)}
</Drawer>
</Modal>
);
}

View File

@ -3,7 +3,6 @@ import { Drawer, Upload, Form, Input, Select, Button, message, Progress, List, T
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import type { PhotoAlbum } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Dragger } = Upload;
@ -76,11 +75,11 @@ export default function UploadPhotoDrawer({ open, onClose, onSuccess, albumId }:
headers: { 'Content-Type': 'multipart/form-data' },
});
uploadResults.push({ filename: file.name, success: true });
} catch (error: unknown) {
} catch (error: any) {
uploadResults.push({
filename: file.name,
success: false,
error: getErrorMessage(error, 'Upload failed'),
error: error.response?.data?.message || 'Upload failed',
});
}

View File

@ -16,7 +16,6 @@ import {
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Dragger } = Upload;
const { Text } = Typography;
@ -118,11 +117,11 @@ export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVi
filename: file.name,
success: true,
});
} catch (error: unknown) {
} catch (error: any) {
uploadResults.push({
filename: file.name,
success: false,
error: getErrorMessage(error, 'Upload failed'),
error: error.response?.data?.message || 'Upload failed',
});
}

View File

@ -12,7 +12,6 @@ import { mediaApi } from '@/lib/media-api';
import type { VideoAnalytics } from '@/types/media';
import AnalyticsChart from './AnalyticsChart';
import ViewersTable from './ViewersTable';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface VideoAnalyticsModalProps {
videoId: number | null;
@ -47,9 +46,9 @@ export default function VideoAnalyticsModal({
setError(null);
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
setAnalytics(response.data);
} catch (error: unknown) {
} catch (error: any) {
console.error('Failed to fetch analytics:', error);
setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
} finally {
setLoading(false);
}

View File

@ -2,10 +2,19 @@ import { Card, Checkbox, Tag, Spin } from 'antd';
import { ClockCircleOutlined, CheckCircleOutlined, ThunderboltFilled, LockOutlined, CrownOutlined } from '@ant-design/icons';
import { useState } from 'react';
import type { Video } from '@/types/media';
import { useSignedMediaUrl } from '@/lib/media-url';
import { getAuthCallbacks } from '@/lib/api';
import VideoActions from './VideoActions';
import ScheduleBadge from './ScheduleBadge';
/** Append JWT access token as query param for <img>/<video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoCardProps {
video: Video;
selected: boolean;
@ -39,7 +48,6 @@ export default function VideoCard({
}: VideoCardProps) {
const [thumbnailLoading, setThumbnailLoading] = useState(true);
const [thumbnailError, setThumbnailError] = useState(false);
const signedThumbnailUrl = useSignedMediaUrl(video.thumbnailUrl);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
@ -68,10 +76,10 @@ export default function VideoCard({
}}
>
{/* Thumbnail image or fallback */}
{video.thumbnailUrl && !thumbnailError && signedThumbnailUrl ? (
{video.thumbnailUrl && !thumbnailError ? (
<>
<img
src={signedThumbnailUrl}
src={getAuthenticatedUrl(video.thumbnailUrl)}
alt={video.title}
style={{
position: 'absolute',

View File

@ -2,8 +2,6 @@ import { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 're
import { Alert, Spin } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
import { signedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
export interface VideoMetadata {
id: number;
@ -16,8 +14,6 @@ export interface VideoMetadata {
quality: string | null;
streamUrl: string;
thumbnailUrl: string | null;
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
hlsManifestUrl?: string | null;
createdAt: string;
}
@ -71,13 +67,6 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Attach HLS when manifest is ready. Must be called unconditionally on
// every render (rules of hooks) — even before the loading/error early
// returns. The hook is a no-op when manifestUrl is null.
const hlsManifestUrl = metadata?.hlsStatus === 'READY' ? metadata.hlsManifestUrl ?? null : null;
const { error: hlsError } = useHls(videoRef, hlsManifestUrl);
const useMp4Src = !hlsManifestUrl || !!hlsError;
// Expose control methods via ref
useImperativeHandle(ref, () => ({
play: () => {
@ -133,6 +122,15 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
fetchMetadata();
}, [videoId]);
const appendToken = (url: string): string => {
if (!isAdmin) return url;
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${accessToken}`;
};
const fetchMetadata = async () => {
setLoading(true);
setError(null);
@ -141,8 +139,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
// Use relative URL to go through nginx proxy
const headers: Record<string, string> = {};
if (isAdmin) {
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
@ -159,13 +157,10 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const data = await response.json();
// For admin previews of unpublished media, sign stream/thumbnail URLs
// (the legacy ?token=<JWT> path was removed 2026-04-12). The HLS
// manifest URL is already signed server-side by the metadata route, so
// we leave it untouched.
// For admin, append token to stream/thumbnail URLs so <video>/<img> can access them
if (isAdmin) {
if (data.streamUrl) data.streamUrl = await signedMediaUrl(data.streamUrl);
if (data.thumbnailUrl) data.thumbnailUrl = await signedMediaUrl(data.thumbnailUrl);
if (data.streamUrl) data.streamUrl = appendToken(data.streamUrl);
if (data.thumbnailUrl) data.thumbnailUrl = appendToken(data.thumbnailUrl);
}
setMetadata(data);
@ -224,10 +219,6 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
? (metadata.height / metadata.width) * 100
: 56.25; // Default to 16:9
// (HLS attachment + MP4 fallback flag are computed at the top of the
// component, before the loading/error early returns, to satisfy the rules
// of hooks. See useMp4Src above.)
return (
<div
style={{
@ -240,7 +231,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
>
<video
ref={videoRef}
src={useMp4Src ? metadata.streamUrl : undefined}
src={metadata.streamUrl}
poster={poster || metadata.thumbnailUrl || undefined}
autoPlay={autoplay}
controls={controls}

View File

@ -2,8 +2,16 @@ import { Modal } from 'antd';
import { useEffect, useRef, useState } from 'react';
import type { Video } from '@/types/media';
import { mediaApi } from '@/lib/media-api';
import { useSignedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
import { getAuthCallbacks } from '@/lib/api';
/** Append JWT access token as query param for <video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoViewerModalProps {
video: Video | null;
@ -16,17 +24,6 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
const [viewId, setViewId] = useState<number | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const lastWatchTime = useRef<number>(0);
const streamUrl = useSignedMediaUrl(video ? `/media/videos/${video.id}/stream` : null);
// Sign the HLS manifest URL too so admin previews of unpublished videos
// can play HLS. The hook is a no-op for nulls.
const hlsManifestUrl = useSignedMediaUrl(
video && video.hlsStatus === 'READY'
? `/media/videos/${video.id}/hls/master.m3u8`
: null,
);
const { error: hlsError } = useHls(videoRef, hlsManifestUrl ?? null);
// Fall back to MP4 src when HLS isn't ready or hls.js fatal-errored.
const useMp4Src = !hlsManifestUrl || !!hlsError;
useEffect(() => {
if (open && video) {
@ -178,7 +175,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
>
<video
ref={videoRef}
src={useMp4Src ? streamUrl : undefined}
src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)}
controls
autoPlay
style={{

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Drawer, Radio, InputNumber, Spin, Typography, Space, Button, theme, Grid } from 'antd';
import { Modal, Radio, InputNumber, Spin, Typography, Space, theme, Grid } from 'antd';
import { HeartOutlined, DollarOutlined, AppstoreOutlined } from '@ant-design/icons';
import axios from 'axios';
@ -80,23 +80,14 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
});
return (
<Drawer
<Modal
title="Insert Donate Block"
open={open}
onClose={onClose}
onCancel={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: variant === 'set-amount' && (!amount || amount <= 0) }}
width={isMobile ? '95vw' : 520}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button
type="primary"
onClick={handleOk}
disabled={variant === 'set-amount' && (!amount || amount <= 0)}
>
Insert
</Button>
}
>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Choose a donation block style to insert into your document.
@ -185,6 +176,6 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
</div>
</Space>
</Radio.Group>
</Drawer>
</Modal>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Drawer, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Button, Grid } from 'antd';
import { Modal, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Grid } from 'antd';
import { ShoppingCartOutlined, SearchOutlined, CheckCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import type { Product, ProductType } from '@/types/api';
@ -35,8 +35,8 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
if (open && products.length === 0) {
setLoading(true);
setError(null);
axios.get('/api/payments/products', { params: { limit: 50 } })
.then(({ data }) => setProducts(data.products))
axios.get('/api/payments/products')
.then(({ data }) => setProducts(data))
.catch(() => setError('Failed to load products'))
.finally(() => setLoading(false));
}
@ -60,19 +60,14 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
});
return (
<Drawer
<Modal
title="Insert Product Card"
open={open}
onClose={onClose}
onCancel={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: !selectedId }}
width={isMobile ? '95vw' : 640}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleOk} disabled={!selectedId}>
Insert
</Button>
}
>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
Select a product to embed as an inline purchase card.
@ -153,6 +148,6 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
})}
</Row>
</div>
</Drawer>
</Modal>
);
}

View File

@ -21,9 +21,9 @@ export function ProductWidget({ productSlug, buttonText = 'Buy Now' }: ProductWi
return;
}
axios.get('/api/payments/products', { params: { limit: 50 } })
axios.get('/api/payments/products')
.then(({ data }) => {
const found = (data.products as Product[]).find(p => p.slug === productSlug);
const found = (data as Product[]).find(p => p.slug === productSlug);
if (found) {
setProduct(found);
} else {

View File

@ -1,4 +1,4 @@
import { Drawer, Form, Input, Select, Switch, Button, Typography, message, Space } from 'antd';
import { Modal, Form, Input, Select, Switch, Button, Typography, message, Space } from 'antd';
import { useState } from 'react';
import { CopyOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
@ -76,20 +76,13 @@ export default function CreateUserFromContactModal({
};
return (
<Drawer
<Modal
title="Create User Account"
open={open}
onClose={() => { form.resetFields(); onClose(); }}
onCancel={() => { form.resetFields(); onClose(); }}
footer={null}
destroyOnHidden
width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button type="primary" onClick={() => form.submit()} loading={submitting}>
Create Account
</Button>
}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Contact</Typography.Text>
@ -130,12 +123,17 @@ export default function CreateUserFromContactModal({
<Switch />
</Form.Item>
<Form.Item style={{ marginBottom: 0, display: 'none' }}>
<Button type="primary" htmlType="submit">
Create Account
</Button>
<Form.Item style={{ marginBottom: 0 }}>
<Space>
<Button type="primary" htmlType="submit" loading={submitting}>
Create Account
</Button>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
</Space>
</Form.Item>
</Form>
</Drawer>
</Modal>
);
}

View File

@ -1,6 +1,6 @@
import { useState, useRef, useCallback } from 'react';
import {
Drawer,
Modal,
Select,
Typography,
Radio,
@ -155,7 +155,7 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
];
return (
<Drawer
<Modal
title={
<Space>
<SwapOutlined />
@ -163,13 +163,14 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
</Space>
}
open={open}
onClose={handleClose}
onCancel={handleClose}
width={700}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
footer={[
<Button key="cancel" onClick={handleClose}>
Cancel
</Button>,
<Button
key="merge"
type="primary"
danger
onClick={handleMerge}
@ -177,8 +178,8 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
disabled={!sourcePerson}
>
Confirm Merge
</Button>
}
</Button>,
]}
>
{/* Search for source person */}
<div style={{ marginBottom: 20 }}>
@ -300,6 +301,6 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
</Typography.Text>
</>
)}
</Drawer>
</Modal>
);
}

View File

@ -16,8 +16,6 @@ const roleColors: Record<UserRole, string> = {
PAYMENTS_ADMIN: 'green',
EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta',
POLLS_ADMIN: 'geekblue',
ANALYTICS_ADMIN: 'processing',
USER: 'blue',
TEMP: 'default',
};

View File

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { Modal, Button, Typography, Spin, Result, message, Space } from 'antd';
import { VideoCameraOutlined, CopyOutlined, LoginOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text, Paragraph } = Typography;
@ -57,9 +56,9 @@ export default function VideoCallModal({ open, onClose, personName }: VideoCallM
const { data } = await api.post<{ token: string; jitsiRoom: string; domain: string }>(
`/jitsi/meetings/${meeting.slug}/token`,
);
window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank', 'noopener,noreferrer');
} catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to get moderator token'));
window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank');
} catch (err: any) {
message.error(err.response?.data?.error || 'Failed to get moderator token');
} finally {
setJoining(false);
}

Some files were not shown because too many files have changed in this diff Show More