Compare commits

..

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

1442 changed files with 76269 additions and 88811 deletions

View File

@ -46,28 +46,13 @@ JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_REFRESH_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_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_ACCESS_EXPIRY=15m JWT_ACCESS_EXPIRY=15m
# Reduced from 7d → 24h on 2026-04-12 (P2-3 hardening). Combined with JWT_REFRESH_EXPIRY=7d
# device-fingerprint binding in the JWT payload, this tightens the
# exploitation window for stolen refresh tokens.
JWT_REFRESH_EXPIRY=24h
# Encryption key for DB-stored secrets (SMTP password, etc.) # Encryption key for DB-stored secrets (SMTP password, etc.)
# REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET # REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
ENCRYPTION_KEY=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) --- # --- Initial Super Admin User (auto-created during database seeding) ---
# These credentials are used to create the initial super admin account # These credentials are used to create the initial super admin account
# Change these before running the seed script in production # Change these before running the seed script in production
@ -188,13 +173,6 @@ MEDIA_API_PORT=4100
MEDIA_API_PUBLIC_URL=http://media-api:4100 MEDIA_API_PUBLIC_URL=http://media-api:4100
# Used during admin Docker build to set the media API endpoint for Vite # Used during admin Docker build to set the media API endpoint for Vite
VITE_MEDIA_API_URL=http://changemaker-media-api:4100 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_ROOT=/media/library
MEDIA_UPLOADS=/media/uploads MEDIA_UPLOADS=/media/uploads
MAX_UPLOAD_SIZE_GB=10 MAX_UPLOAD_SIZE_GB=10
@ -226,21 +204,12 @@ COMPOSE_PROFILES=
# For docker push/pull, run: docker login gitea.bnkops.com # For docker push/pull, run: docker login gitea.bnkops.com
GITEA_REGISTRY_USER=admin GITEA_REGISTRY_USER=admin
GITEA_REGISTRY_PASS= 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_URL=http://gitea-changemaker:3000
GITEA_PORT=3030 GITEA_PORT=3030
GITEA_WEB_PORT=3030 GITEA_WEB_PORT=3030
GITEA_SSH_PORT=2222 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_TYPE=mysql
GITEA_DB_HOST=gitea-db:3306 GITEA_DB_HOST=gitea-db:3306
GITEA_DB_NAME=gitea GITEA_DB_NAME=gitea
@ -253,9 +222,7 @@ GITEA_DOMAIN=git.cmlite.org
# --- Gitea Docs Comments --- # --- Gitea Docs Comments ---
# Enable comments on MkDocs pages (backed by Gitea Issues) # Enable comments on MkDocs pages (backed by Gitea Issues)
GITEA_COMMENTS_ENABLED=false GITEA_COMMENTS_ENABLED=false
# Personal access token for the LOCAL Gitea instance (docs comments, user provisioning, SSO) # Personal access token with repo write scope (create in Gitea → Settings → Applications)
# 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)
GITEA_API_TOKEN= GITEA_API_TOKEN=
# Repository owner (Gitea username that will own the docs-comments repo) # Repository owner (Gitea username that will own the docs-comments repo)
GITEA_COMMENTS_REPO_OWNER= GITEA_COMMENTS_REPO_OWNER=
@ -288,7 +255,6 @@ MKDOCS_DOCS_PATH=/mkdocs/docs
# --- Code Server --- # --- Code Server ---
CODE_SERVER_PORT=8888 CODE_SERVER_PORT=8888
CODE_SERVER_URL=http://code-server-changemaker:8443 CODE_SERVER_URL=http://code-server-changemaker:8443
USER_NAME=coder
# --- Homepage --- # --- Homepage ---
HOMEPAGE_PORT=3010 HOMEPAGE_PORT=3010
@ -423,26 +389,6 @@ SMS_MAX_RETRIES=3
SMS_RESPONSE_SYNC_INTERVAL_MS=120000 SMS_RESPONSE_SYNC_INTERVAL_MS=120000
SMS_DEVICE_MONITOR_INTERVAL_MS=300000 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) --- # --- Monitoring (only used with --profile monitoring) ---
PROMETHEUS_PORT=9090 PROMETHEUS_PORT=9090
GRAFANA_PORT=3005 GRAFANA_PORT=3005
@ -460,8 +406,3 @@ GOTIFY_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN) INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN)
BUNKER_OPS_ENABLED=false # Enable remote metrics push to central server 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) 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)

28
.gitignore vendored
View File

@ -9,9 +9,6 @@ node_modules/
/configs/code-server/.config/* /configs/code-server/.config/*
!/configs/code-server/.config/.gitkeep !/configs/code-server/.config/.gitkeep
/configs/code-server/data/*
!/configs/code-server/data/.gitkeep
# Root assets (generated by containers) # Root assets (generated by containers)
/assets/ /assets/
@ -36,8 +33,7 @@ node_modules/
# NAR data directory (large voter registry files) # NAR data directory (large voter registry files)
/data/* /data/*
!/data/upgrade/ !/data/upgrade/
/data/upgrade/* /data/upgrade/*.json
!/data/upgrade/.gitkeep
# Media files (managed by Docker volumes, not git) # Media files (managed by Docker volumes, not git)
/media/ /media/
@ -64,35 +60,13 @@ core.*
/backups/ /backups/
.upgrade.lock .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) # Release tarballs (generated by build-release.sh)
/releases/ /releases/
# API compiled output (generated by tsc, baked into Docker images) # API compiled output (generated by tsc, baked into Docker images)
/api/dist/ /api/dist/
# TypeScript incremental build cache (machine-specific)
*.tsbuildinfo
# Control Panel runtime data (managed deployments + backups) # Control Panel runtime data (managed deployments + backups)
/changemaker-control-panel/instances/ /changemaker-control-panel/instances/
/changemaker-control-panel/backups/ /changemaker-control-panel/backups/
logs/ 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

View File

@ -0,0 +1 @@
[ 567ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0

View File

@ -0,0 +1,66 @@
[ 156566ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 159562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 162561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 165562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 168561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 171561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 174562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 177561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 180561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 183561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 186561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 189561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 192561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 195561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 198562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 201561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 204561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 207561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 210561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 213562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 216562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 219561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 222561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 225562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 228561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 231562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 234562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 237562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 240390ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/dashboard/summary:0
[ 240562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 243562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 246561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 249561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 252562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 255561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 258561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 261562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 264562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 267562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 270562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 273561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 276562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 279563ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 282561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 285562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 288562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 291562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 294562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 297561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 300562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 303561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 306562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 309561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 312562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 315561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 318561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 321561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 324561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 327561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 330562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 333561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 336561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 339561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 342561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 345561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 348561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0

View File

@ -0,0 +1,2 @@
[ 480462ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1440463ms] [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,14 @@
[ 140214ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 143212ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 146211ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 149211ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 360382ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 960378ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/dashboard/summary:0
[ 1320377ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1920379ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/dashboard/summary:0
[ 2040375ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/dashboard/summary:0
[ 2280391ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2400379ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/summary:0
[ 3240382ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4200393ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5160392ms] [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 @@
[ 616ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
[ 628ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0

View File

@ -0,0 +1 @@
[ 605ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0

View File

@ -0,0 +1 @@
[ 1538ms] [ERROR] Failed to load resource: the server responded with a status of 400 (Bad Request) @ http://localhost:8091/auth/token/refresh:0

View File

@ -0,0 +1 @@
[ 32ms] [WARNING] Manifest: property 'start_url' ignored, should be same origin as document. @ data:application/json;base64,eyJuYW1lIjoiR2l0ZWE6IEdpdCB3aXRoIGEgY3VwIG9mIHRlYSIsInNob3J0X25hbWUiOiJHaXRlYTogR2l0IHdpdGggYSBjdXAgb2YgdGVhIiwic3RhcnRfdXJsIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy8iLCJpY29ucyI6W3sic3JjIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy9hc3NldHMvaW1nL2xvZ28ucG5nIiwidHlwZSI6ImltYWdlL3BuZyIsInNpemVzIjoiNTEyeDUxMiJ9LHsic3JjIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy9hc3NldHMvaW1nL2xvZ28uc3ZnIiwidHlwZSI6ImltYWdlL3N2Zyt4bWwiLCJzaXplcyI6IjUxMng1MTIifV19:0

View File

@ -0,0 +1 @@
[ 871ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5678/rest/login:0

View File

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

View File

@ -0,0 +1 @@
[ 238ms] [WARNING] Simple Analytics: Set hostname on localhost:8090. See https://docs.simpleanalytics.com/overwrite-domain-name @ https://scripts.simpleanalyticscdn.com/latest.js:2

View File

@ -0,0 +1,5 @@
[ 967ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/node_modules/vsda/rust/web/vsda_bg.wasm:0
[ 969ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/node_modules/vsda/rust/web/vsda.js:0
[ 1271ms] [WARNING] The web worker extension host is started in a same-origin iframe! @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:4591
[ 1304ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html?&vscodeWebWorkerExtHostId=2ab26743-4428-4df3-944e-d603e9a82c44:0
[ 2145ms] [WARNING] AI generated workspace trust dialog contents not available. @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:4552

View File

@ -0,0 +1,11 @@
[ 5ms] [ERROR] %c ERR color: #f33 [lifecycle] Long running operations during shutdown are unsupported in the web (id: join.disconnectRemote) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:37
[ 6ms] [ERROR] %c ERR color: #f33 [lifecycle] Long running operations during shutdown are unsupported in the web (id: join.chatSessionStore) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:37
[ 7ms] [ERROR] %c ERR color: #f33 [lifecycle] Long running operations during shutdown are unsupported in the web (id: join.chatEditingSession) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:37
[ 27ms] [ERROR] %c ERR color: #f33 Error creating chat editing session content folder vscode-remote:/home/coder/.local/share/code-server/User/workspaceStorage/4a334e63/chatEditingSessions/266a91b3-2e96-499a-bfc1-6d451b72bd57/contents Canceled: Canceled
at Object.call (http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:612:1374)
at http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:613:2181
at async vOt.W (http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:4587:115075)
at async vOt.createFolder (http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:4587:114875)
at async ice.storeState (http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:2978:15032)
at async Promise.all (index 0) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:37
[ 137ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:8025/api/v2/jim:0

View File

@ -0,0 +1 @@
[ 1014ms] [WARNING] GPS permission denied — enable location access in your browser settings @ http://localhost:3002/src/components/canvass/GPSTracker.tsx:32

View File

@ -0,0 +1,2 @@
[ 600748ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/social/notifications/count:0
[ 1560748ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/social/notifications/count:0

View File

@ -0,0 +1,12 @@
[ 127ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 127ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3242:41)
at async initSearch (http://localhost:4003/lander/:3307:9) @ http://localhost:4003/lander/:3254
[ 626262ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 626262ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3212:41)
at async initSearch (http://localhost:4003/lander/:3277:9) @ http://localhost:4003/lander/:3224
[ 628485ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 628485ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3212:41)
at async initSearch (http://localhost:4003/lander/:3277:9) @ http://localhost:4003/lander/:3224

View File

@ -0,0 +1,60 @@
[ 86ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 87ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3212:41)
at async initSearch (http://localhost:4003/lander/:3277:9) @ http://localhost:4003/lander/:3224
[ 426459ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 426459ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3213:41)
at async initSearch (http://localhost:4003/lander/:3278:9) @ http://localhost:4003/lander/:3225
[ 433706ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 433706ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3214:41)
at async initSearch (http://localhost:4003/lander/:3279:9) @ http://localhost:4003/lander/:3226
[ 436108ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 436108ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3214:41)
at async initSearch (http://localhost:4003/lander/:3279:9) @ http://localhost:4003/lander/:3226
[ 445396ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 445396ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3237:41)
at async initSearch (http://localhost:4003/lander/:3302:9) @ http://localhost:4003/lander/:3249
[ 447757ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 447757ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3237:41)
at async initSearch (http://localhost:4003/lander/:3302:9) @ http://localhost:4003/lander/:3249
[ 455113ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 455113ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3237:41)
at async initSearch (http://localhost:4003/lander/:3302:9) @ http://localhost:4003/lander/:3249
[ 457733ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 457733ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3237:41)
at async initSearch (http://localhost:4003/lander/:3302:9) @ http://localhost:4003/lander/:3249
[ 489124ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 489124ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3252:41)
at async initSearch (http://localhost:4003/lander/:3317:9) @ http://localhost:4003/lander/:3264
[ 491509ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 491509ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3252:41)
at async initSearch (http://localhost:4003/lander/:3317:9) @ http://localhost:4003/lander/:3264
[ 510185ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 510185ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3257:41)
at async initSearch (http://localhost:4003/lander/:3322:9) @ http://localhost:4003/lander/:3269
[ 528536ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 528536ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3262:41)
at async initSearch (http://localhost:4003/lander/:3327:9) @ http://localhost:4003/lander/:3274
[ 530976ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 530976ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3262:41)
at async initSearch (http://localhost:4003/lander/:3327:9) @ http://localhost:4003/lander/:3274
[ 541596ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 541596ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3322:41)
at async initSearch (http://localhost:4003/lander/:3387:9) @ http://localhost:4003/lander/:3334
[ 543950ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 543950ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3322:41)
at async initSearch (http://localhost:4003/lander/:3387:9) @ http://localhost:4003/lander/:3334

View File

@ -0,0 +1,28 @@
[ 96ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 97ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3322:41)
at async initSearch (http://localhost:4003/lander/:3387:9) @ http://localhost:4003/lander/:3334
[ 320202ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 320202ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3335:41)
at async initSearch (http://localhost:4003/lander/:3400:9) @ http://localhost:4003/lander/:3347
[ 329151ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 329151ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349
[ 331533ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 331533ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349
[ 628619ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 628619ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349
[ 631005ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 631005ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349
[ 633385ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 633385ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

289
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`. 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:** **Status Summary:**
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps) - ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
- ✅ Drizzle to Prisma Migration Complete (single-ORM, Feb 2026) - ✅ Security Audit Complete (13 findings addressed, Feb 2026)
- ✅ Automated Pangolin Setup (one-command tunnel deployment) - ✅ NAR 2025 Server Import (Canadian electoral data)
- ✅ 3 Security Audits Complete (Feb 2025 + Mar 22/27/30 2026) - ✅ Media Manager Integration (dual API architecture)
- ✅ Social Connections + Calendar (friendship, shared views, availability finder) - ✅ Email Templates System
- ✅ Payments + Ticketed Events (Stripe integration, check-in scanner) - ✅ Data Quality Dashboard
- ✅ Meeting Planner + Straw Polls (scheduling, voting) - ✅ Observability Dashboard
- ✅ SMS Campaign Connector (Termux Android bridge) - ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026)
- ✅ Docs CMS (blog authoring, access policies, collaboration, version history) - ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026)
- ✅ User Provisioning Framework (Gitea, Vaultwarden, Listmonk) - ✅ **Migration Drift Fixed** (Baseline catch-up migration, 14 migrations cover full schema, Feb 2026)
- ✅ 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))
- 🚧 Phase 15 (Testing + Polish) - Next - 🚧 Phase 15 (Testing + Polish) - Next
--- ---
@ -63,9 +59,10 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
changemaker.lite/ changemaker.lite/
├── api/ # Dual API servers (Express + Fastify) ├── api/ # Dual API servers (Express + Fastify)
│ ├── prisma/ │ ├── prisma/
│ │ ├── schema.prisma # 192 models: User, Campaign, Location, Shift, Payment, Social, etc. │ │ ├── schema.prisma # 30+ models: User, Campaign, Location, Shift, etc.
│ │ ├── migrations/ # 50 Prisma migrations (full schema history) │ │ ├── migrations/ # Prisma migration history
│ │ └── seed.ts # Admin user, settings, page blocks │ │ └── seed.ts # Admin user, settings, page blocks
│ ├── drizzle/ # Media tables (Drizzle ORM)
│ ├── Dockerfile.media # Fastify media server container │ ├── Dockerfile.media # Fastify media server container
│ └── src/ │ └── src/
│ ├── server.ts # Express API entry point (port 4000) │ ├── server.ts # Express API entry point (port 4000)
@ -73,10 +70,10 @@ changemaker.lite/
│ ├── config/ │ ├── config/
│ │ └── env.ts # Zod-validated environment config (100+ vars) │ │ └── env.ts # Zod-validated environment config (100+ vars)
│ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler │ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler
│ ├── modules/ # 44 modules total │ ├── modules/
│ │ ├── auth/ # JWT login, register, refresh, logout │ │ ├── auth/ # JWT login, register, refresh, logout
│ │ ├── users/ # User CRUD + pagination + search │ │ ├── users/ # User CRUD + pagination + search
│ │ ├── settings/ # Site settings singleton (20+ feature flags) │ │ ├── settings/ # Site settings singleton
│ │ ├── services/ # Service health checks │ │ ├── services/ # Service health checks
│ │ ├── influence/ │ │ ├── influence/
│ │ │ ├── campaigns/ # Campaign CRUD + public routes │ │ │ ├── campaigns/ # Campaign CRUD + public routes
@ -93,39 +90,16 @@ changemaker.lite/
│ │ │ ├── canvass/ # Canvassing sessions + visits + routes │ │ │ ├── canvass/ # Canvassing sessions + visits + routes
│ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes) │ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes)
│ │ │ └── settings/ # Map settings singleton │ │ │ └── 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 │ │ ├── email-templates/ # Email template CRUD + rendering
│ │ ├── media/ # Fastify media API (videos, reactions, jobs, analytics) │ │ ├── media/ # Fastify media API (videos, reactions, jobs)
│ │ ├── 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
│ │ ├── listmonk/ # Newsletter sync admin routes │ │ ├── listmonk/ # Newsletter sync admin routes
│ │ ├── pangolin/ # Tunnel management (Newt integration) │ │ ├── pangolin/ # Tunnel management (Newt integration)
│ │ ├── rocketchat/ # Rocket.Chat integration │ │ ├── docs/ # MkDocs + Code Server health checks
│ │ ├── 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
│ │ ├── qr/ # QR code PNG generation (public) │ │ ├── qr/ # QR code PNG generation (public)
│ │ ├── dashboard/ # Admin dashboard data
│ │ ├── activity/ # Activity feed
│ │ └── observability/ # Prometheus/Grafana/Alertmanager integration │ │ └── observability/ # Prometheus/Grafana/Alertmanager integration
│ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker │ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker
│ ├── types/ # express.d.ts (Request augmentation) │ ├── types/ # express.d.ts (Request augmentation)
@ -145,50 +119,34 @@ changemaker.lite/
│ │ ├── media/ # VideoCard, BulkActions, gallery components │ │ ├── media/ # VideoCard, BulkActions, gallery components
│ │ ├── email-templates/ # Email template components │ │ ├── email-templates/ # Email template components
│ │ └── observability/ # Monitoring components │ │ └── observability/ # Monitoring components
│ ├── pages/ # 52 root pages + 8 subdirectories │ ├── pages/
│ │ ├── influence/ # Campaign moderation, effectiveness, impact stories, straw polls │ │ ├── auth/ # LoginPage
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard │ │ ├── influence/ # CampaignsPage, ResponsesPage, RepresentativesPage, EmailQueuePage
│ │ ├── media/ # Library, Playlists, Analytics, Gallery Ads, Comment Moderation │ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboardPage
│ │ ├── payments/ # Dashboard, Products, Plans, Donations, Subscribers, Settings │ │ ├── volunteer/ # VolunteerMapPage, VolunteerShiftsPage, MyActivityPage, MyRoutesPage
│ │ ├── social/ # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges │ │ ├── public/ # CampaignsListPage, CampaignPage, ResponseWallPage, MapPage, ShiftsPage, LandingPage, MediaGalleryPage, MediaViewerPage
│ │ ├── sms/ # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup │ │ ├── media/ # LibraryPage, SharedMediaPage, MediaJobsPage, AnalyticsDashboardPage
│ │ ├── events/ # Ticketed Events, Event Detail, Check-in Scanner │ │ ├── services/ # MiniQRPage, MailHogPage, CodeEditorPage, N8nPage, GiteaPage, NocoDBPage
│ │ ├── volunteer/ # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements │ │ └── (root) # DashboardPage, UsersPage, SettingsPage, CanvassDashboardPage, WalkSheetPage, CutExportPage, LandingPagesPage, PageEditorPage, EmailTemplatesPage, ListmonkPage, PangolinPage, ObservabilityPage
│ │ ├── public/ # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet │ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand)
│ │ └── (root) # Dashboard, Users, Settings, Docs*, MeetingPlanner, Observability, etc. │ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios)
│ ├── 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
│ ├── hooks/ # useDebounce, useLocalStorage │ ├── hooks/ # useDebounce, useLocalStorage
│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces) │ └── 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) ├── nginx/ # Reverse proxy config (subdomain routing + CSP)
├── configs/ # Prometheus, Grafana, Alertmanager, Pangolin configs ├── configs/ # Prometheus, Grafana, Alertmanager configs
├── scripts/ # Deployment, backup, upgrade, registry scripts ├── scripts/ # Deployment, backup, upgrade, registry scripts
│ ├── install.sh # Curl-friendly installer (downloads tarball + runs config.sh) │ ├── 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-and-push.sh # Build production images → push to Gitea registry
│ ├── build-release.sh # Package runtime files into release tarball │ ├── build-release.sh # Package runtime files into release tarball
│ ├── mirror-images.sh # Mirror third-party images to Gitea │ ├── mirror-images.sh # Mirror third-party images to Gitea
│ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode) │ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode)
│ ├── upgrade-check.sh # Check for updates (git or Gitea API) │ ├── upgrade-check.sh # Check for updates (git or Gitea API)
│ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades │ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades
│ ├── update-env.sh # Merge new variables from .env.example into existing .env │ └── backup.sh # PostgreSQL + Listmonk + uploads backup
│ ├── backup.sh / restore.sh # PostgreSQL + Listmonk + uploads backup/restore ├── docker-compose.yml # V2 orchestration (20+ services)
│ ├── validate-env.sh # Required env variable validation ├── docker-compose.v1.yml # V1 backup (reference)
│ ├── 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)
├── .env.example # All required environment variables ├── .env.example # All required environment variables
└── V2_PLAN.md # Full 14-phase roadmap └── V2_PLAN.md # Full 14-phase roadmap
``` ```
@ -202,7 +160,7 @@ changemaker.lite/
The fastest way to deploy. No source code, no compilation: The fastest way to deploy. No source code, no compilation:
```bash ```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: 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) ### Source Install (Development)
1. **Clone repository:** 1. **Clone repository and checkout v2 branch:**
```bash ```bash
git clone <repo-url> changemaker.lite git clone <repo-url> changemaker.lite
cd changemaker.lite cd changemaker.lite
git checkout v2
``` ```
2. **Create environment file:** 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 | | Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env |
| API | http://localhost:4000 | - | | API | http://localhost:4000 | - |
| Media API | http://localhost:4100 | - |
| NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_ADMIN_PASSWORD` in .env | | 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 | - | | MailHog | http://localhost:8025 | - |
| Grafana | http://localhost:3001 | admin / admin | | Grafana | http://localhost:3001 | admin / admin |
| Prometheus | http://localhost:9090 | - | | Prometheus | http://localhost:9090 | - |
| Listmonk | http://localhost:9001 | See `LISTMONK_WEB_ADMIN_USER`/`PASSWORD` in .env | | 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 ### 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 ```bash
# .env feature flags (env-level) # Media Manager
ENABLE_MEDIA_FEATURES=true # Media manager ENABLE_MEDIA_FEATURES=true
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
```
**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 tsc --noEmit # Type-check
cd api && npx prisma migrate dev # Run/create Prisma migrations cd api && npx prisma migrate dev # Run/create Prisma migrations
cd api && npx prisma studio # Browse database cd api && npx prisma studio # Browse database
cd api && npx drizzle-kit push # Push Drizzle schema changes (media)
``` ```
### Admin Development ### Admin Development
@ -344,6 +296,7 @@ docker compose logs -f media-api
# Database operations # Database operations
docker compose exec api npx prisma migrate dev docker compose exec api npx prisma migrate dev
docker compose exec api npx drizzle-kit push
# Stop services # Stop services
docker compose down docker compose down
@ -368,7 +321,7 @@ docker compose down
./scripts/upgrade.sh --use-registry --force --skip-backup ./scripts/upgrade.sh --use-registry --force --skip-backup
# Install from tarball (end-user one-liner) # 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:** **Two compose files:**
@ -490,13 +443,9 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
**Files:** **Files:**
- `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics) - `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/services/` — FFprobe, video analytics service
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload, **HLS streaming** - `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/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/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/LibraryPage.tsx` — Video library with quick actions + calendar
- `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard - `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/pages/public/MediaGalleryPage.tsx` — Public video gallery
- `admin/src/components/media/` — VideoCard, VideoActions, modals, charts - `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)**. **Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts
**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.
**Routes:** **Routes:**
- Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs` - 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/services/pangolin.client.ts` — Pangolin Integration API client
- `api/src/modules/pangolin/pangolin.routes.ts` — Tunnel management routes (includes `/setup-automated`) - `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 - `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-setup.sh` — CLI wrapper for automated setup
- `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
- `configs/pangolin/resources.yml` — Central resource definitions (12 services) - `configs/pangolin/resources.yml` — Central resource definitions (12 services)
- Newt container integration (Cloudflare alternative) - 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 - **Continuous sync:** Hourly resource sync via nginx cron job
**MkDocs + Code Server:** **MkDocs + Code Server:**
@ -575,25 +514,20 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
| **Core Services** | | | | **Core Services** | | |
| 3000 | Admin GUI | Vite dev / React production | | 3000 | Admin GUI | Vite dev / React production |
| 4000 | Express API | Main V2 API (Prisma) | | 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) | | 5433 | V2 PostgreSQL | Localhost (container: 5432) |
| 6379 | Redis | Cache, rate limit, BullMQ | | 6379 | Redis | Cache, rate limit, BullMQ |
| **Supporting Services** | | | | **Supporting Services** | | |
| 3001 | Grafana | Metrics visualization | | 3001 | Grafana | Metrics visualization |
| 3010 | Homepage | Service dashboard | | 3010 | Homepage | Service dashboard |
| 3030 | Gitea | Git hosting + SSO | | 3030 | Gitea | Git hosting |
| 3100 | Rocket.Chat | Team chat (embed proxy) |
| 4001 | MkDocs Site | Served docs | | 4001 | MkDocs Site | Served docs |
| 4003 | MkDocs Dev | Live preview | | 4003 | MkDocs Dev | Live preview |
| 5432 | Listmonk PostgreSQL | Listmonk DB | | 5432 | Listmonk PostgreSQL | Listmonk DB |
| 5678 | n8n | Workflow automation | | 5678 | n8n | Workflow automation |
| 8025 | MailHog | Email capture (dev) | | 8025 | MailHog | Email capture (dev) |
| 8089 | Mini QR | QR generator | | 8089 | Mini QR | QR generator |
| 8090 | Excalidraw | Collaborative whiteboard |
| 8091 | NocoDB | Data browser | | 8091 | NocoDB | Data browser |
| 8092 | Gancio | Event management |
| 8093 | Vaultwarden | Password manager |
| 8443 | Jitsi Web | Video conferencing |
| 8885 | Mini QR Proxy | Iframe-friendly | | 8885 | Mini QR Proxy | Iframe-friendly |
| 8888 | Code Server | Web IDE | | 8888 | Code Server | Web IDE |
| 9001 | Listmonk | Newsletter platform | | 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 | | `docs.cmlite.org` | MkDocs (4003) | Docs site |
| `code.cmlite.org` | Code Server (8888) | Web IDE | | `code.cmlite.org` | Code Server (8888) | Web IDE |
| `n8n.cmlite.org` | n8n (5678) | Workflow automation | | `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 | | `home.cmlite.org` | Homepage (3010) | Dashboard |
| `grafana.cmlite.org` | Grafana (3001) | Metrics viz | | `grafana.cmlite.org` | Grafana (3001) | Metrics viz |
| `listmonk.cmlite.org` | Listmonk (9001) | Newsletters | | `listmonk.cmlite.org` | Listmonk (9001) | Newsletters |
| `qr.cmlite.org` | Mini QR (8089) | QR generator | | `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** | | `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. **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 ## 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 ### API Router Structure
- Service layer (`*.service.ts`) — business logic, database queries - 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` - Login redirects: ADMIN_ROLES → `/app`, USER/TEMP → `/volunteer`
### Frontend Architecture ### Frontend Architecture
- Admin pages: `admin/src/pages/` + subdirs (AppLayout) - Admin pages: `admin/src/pages/` (AppLayout)
- Public pages: `admin/src/pages/public/` (PublicLayout, dark theme) - Public pages: `admin/src/pages/public/` (PublicLayout, dark theme)
- Volunteer pages: `admin/src/pages/volunteer/` (VolunteerLayout) - 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` - API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts`
### Database ORM ### Database ORMs
- **Prisma** (both APIs): 192 models in single `schema.prisma`. Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays - **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 ### 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 - **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`) - **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 - **Prisma migrations:** Never use `db push` — always `migrate dev` to keep history in sync
- Nginx media API block must come BEFORE general API block - 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 - `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` - **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` - **`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` - See MEMORY.md "Common Gotchas" for additional gotchas (ports, volumes, media upload, registry, etc.)
- **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
--- ---
## Security & Configuration ## Security & Configuration
### Security Audits ### Security Audit
Four security audits completed. See audit reports for full details: Comprehensive security audit completed 2025-02-11, addressing 13 findings. See `SECURITY_AUDIT_2025-02-11.md` for full report.
- **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
**Key security features:** **Key improvements:**
- Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced) - Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced)
- Rate limits on auth endpoints (10/min per IP) + nginx rate limiting - Rate limits on auth endpoints (10/min per IP)
- Refresh token rotation (atomic Prisma transaction) - Refresh token rotation (atomic transaction)
- JWT algorithm locked to HS256, separate invite secret
- User enumeration prevention (401 not 404) - User enumeration prevention (401 not 404)
- Redis authentication required - Redis authentication required
- XSS/injection prevention (HTML escaping, DOMPurify, SSTI protection) - XSS/injection prevention (HTML escaping)
- Path traversal protection (resolve + startsWith checks) - Path traversal protection
- Encryption key for DB secrets (`ENCRYPTION_KEY` required in all environments) - Encryption key for DB secrets (`ENCRYPTION_KEY` required in all environments)
- Nginx security headers (HSTS, Permissions-Policy, CSP, X-Forwarded-For) - Nginx security headers (HSTS, Permissions-Policy, CSP)
- MongoDB keyfile authentication
- httpOnly cookies for refresh tokens
### Required Environment Variables ### Required Environment Variables
See `.env.example` for all 100+ variables. Critical ones: 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`: When deploying to a production domain via Pangolin tunnel, you MUST update the `.env` file to include the production domain in `CORS_ORIGINS`:
```bash ```bash
# Example for cmlite.org # Example for betteredmonton.org
CORS_ORIGINS=http://app.cmlite.org,https://app.cmlite.org,http://localhost:3000,http://localhost CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost
# Also set production mode # Also set production mode
NODE_ENV=production NODE_ENV=production
@ -755,16 +673,18 @@ docker compose restart api
4. Save changes 4. Save changes
**Critical resources to fix first:** **Critical resources to fix first:**
- `api.${DOMAIN}` - Main API (all endpoints fail without this) - `api.betteredmonton.org` - Main API (all endpoints fail without this)
- `app.${DOMAIN}` - Admin GUI + public pages - `app.betteredmonton.org` - Admin GUI + public pages
- `media.${DOMAIN}` - Media API - `media.betteredmonton.org` - Media API
**Verification:** **Verification:**
```bash ```bash
# Should return JSON, NOT a 302 redirect # 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 ### CORS Errors in Production
**Symptom:** Browser console shows CORS errors when accessing production domain. **Symptom:** Browser console shows CORS errors when accessing production domain.
@ -783,46 +703,29 @@ Check in order:
### Database/Redis Connection Failures ### 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`. 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 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 ## Key Configuration Files
### Infrastructure ### 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`) - `docker-compose.prod.yml` — Production orchestration (image-only, no source mounts, `IMAGE_TAG:-latest`)
- `.env` / `.env.example` — Environment variables (100+ vars) - `.env` / `.env.example` — Environment variables (100+ vars)
- `config.sh` — Interactive setup wizard (14 steps, release-mode aware) - `config.sh` — Interactive setup wizard (14 steps, release-mode aware)
### Database ### Database
- `api/prisma/schema.prisma` — Main schema (192 Prisma models) - `api/prisma/schema.prisma` — Main schema (30+ Prisma models)
- `api/prisma/migrations/` — 50 migration files (full schema history) - `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 - `api/prisma/seed.ts` — Database seeding
### Nginx ### Nginx
@ -840,5 +743,5 @@ V1 code has been removed from the repo. History preserved as `v1-archive` git ta
### Documentation ### Documentation
- `CLAUDE.md` — Project-wide instructions (this file) - `CLAUDE.md` — Project-wide instructions (this file)
- `V2_PLAN.md` — Full 14-phase roadmap - `V2_PLAN.md` — Full 14-phase roadmap
- `SECURITY_AUDIT_2025-02-11.md`Initial security audit report - `SECURITY_AUDIT_2025-02-11.md`Security audit report
- `.mcp.json` — MCP server configuration for Claude Code - `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 │ │ BUILD & PUBLISH │
│ │ │ │
│ Step 1: ./scripts/build-and-push.sh │ │ Step 1: ./scripts/build-and-push.sh │
│ Builds 5 production images, pushes to Gitea registry │ │ Builds 4 production images, pushes to Gitea registry │
│ (api, admin, media-api, nginx, ccp-agent) │ │ (api, admin, media-api, nginx) tagged :SHA + :latest │
│ tagged :SHA + :latest │
│ │ │ │
│ Step 2: ./scripts/mirror-images.sh (run once/rarely) │ │ Step 2: ./scripts/mirror-images.sh (run once/rarely) │
│ Mirrors 36 third-party images to Gitea registry │ │ 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 │ │ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │
│ Packages runtime files into ~9MB tarball, uploads to │ │ Packages runtime files into ~9MB tarball, uploads to │
│ Gitea Releases │ │ Gitea Releases │
└──────────────────┬─────────────────100.90.78.47──────────────────────────────┘ └──────────────────┬───────────────────────────────────────────────┘
┌───────────┴───────────┐ ┌───────────┴───────────┐
▼ ▼ ▼ ▼
@ -99,7 +98,7 @@ After code changes are tested locally:
./scripts/build-and-push.sh ./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 | | 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 | | `admin` | `admin/Dockerfile` | Nginx serving React build output |
| `media-api` | `api/Dockerfile.media` | Fastify + FFmpeg (compiled JS) | | `media-api` | `api/Dockerfile.media` | Fastify + FFmpeg (compiled JS) |
| `nginx` | `nginx/Dockerfile` | Nginx with `envsubst` domain templating | | `nginx` | `nginx/Dockerfile` | Nginx with `envsubst` domain templating |
| `ccp-agent` | `../changemaker-control-panel/agent/Dockerfile` | Remote management agent (sibling repo) |
```bash ```bash
# Build specific services only # 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 # Preview contents without creating tarball
./scripts/build-release.sh --dry-run ./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: The tarball contains:
- `docker-compose.yml` (copy of `docker-compose.prod.yml` — image-only, no build blocks) - `docker-compose.yml` (copy of `docker-compose.prod.yml` — image-only, no build blocks)
- `.env.example`, `config.sh` (configuration wizard) - `.env.example`, `config.sh` (configuration wizard)
@ -185,7 +174,7 @@ The tarball contains:
```bash ```bash
# One-liner # 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 # Or manual
curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz 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 docker compose exec api npx prisma migrate dev # Create migration
# ── Build & Publish ── # ── 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 ./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 v2.2.0 --upload # Package + upload release
./scripts/build-release.sh --tag vX.Y.Z --upload # Package + upload release
# ── Deploy ── # ── Deploy ──
curl -fsSL .../install.sh | bash # New install (release) 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 ## 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) 2. [ ] `docker compose up -d` works locally (smoke test)
3. [ ] **Determine version tag:** 3. [ ] `./scripts/build-and-push.sh` — builds and pushes 4 production images
```bash 4. [ ] `./scripts/mirror-images.sh` — only if third-party versions changed
# Check the latest existing tag to pick the next version 5. [ ] `./scripts/build-release.sh --tag vX.Y.Z --upload` — packages and uploads tarball
git tag --sort=-v:refname | head -5 6. [ ] Test clean install: `tar xzf ... && cd changemaker-lite && bash config.sh && docker compose up -d`
# Check commits since the last tag 7. [ ] Test upgrade: `./scripts/upgrade.sh` on an existing installation
git log $(git tag --sort=-v:refname | head -1)..HEAD --oneline 8. [ ] Verify: `curl http://localhost:4000/api/health` returns `{"status":"ok"}`
```
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"}`

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"> # Changemaker Lite
<img src="mkdocs/docs/assets/logo.png" alt="Changemaker Lite" width="120" />
</p>
<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"> ## What Is This?
A self-hosted campaign platform for community organizers who want to own their data.
</p>
<p align="center"> Changemaker Lite gives community organizers the tools they need to:
<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>
--- - **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. The entire platform runs on Docker Compose with a single `.env` file for configuration.
<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>
## Quick Start ## 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 ```bash
# Clone and switch to the v2 branch
git clone <repo-url> changemaker.lite git clone <repo-url> changemaker.lite
cd changemaker.lite cd changemaker.lite
git checkout v2
# Create your environment file
cp .env.example .env 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 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 migrate deploy
docker compose exec api npx prisma db seed docker compose exec api npx prisma db seed
``` ```
Then open **http://localhost:3000** and log in with the admin credentials from your `.env`. 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
bash scripts/validate-env.sh # re-check .env + host ports ENABLE_MEDIA_FEATURES=true # Video library + gallery
bash scripts/test-deployment.sh # full deployment health sweep LISTMONK_SYNC_ENABLED=true # Newsletter subscriber sync
bash scripts/pangolin-teardown.sh # wipe tunnel org before reinstall (dry-run by default) EMAIL_TEST_MODE=true # Route emails to MailHog (dev)
bash scripts/ccp-deregister.sh # deregister from Changemaker Control Panel (dry-run by default)
``` ```
## 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 ## 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 This project is licensed under the [Apache License 2.0](https://opensource.org/license/apache-2-0).
| 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)
## AI Disclaimer ## AI Disclaimer

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

View File

@ -34,11 +34,9 @@
"grapesjs-tabs": "^1.0.6", "grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1", "grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1", "grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"minisearch": "^7.2.0", "minisearch": "^7.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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 NocoDBPage from '@/pages/NocoDBPage';
import N8nPage from '@/pages/N8nPage'; import N8nPage from '@/pages/N8nPage';
import GiteaPage from '@/pages/GiteaPage'; import GiteaPage from '@/pages/GiteaPage';
import GiteaSetupPage from '@/pages/GiteaSetupPage';
import MailHogPage from '@/pages/MailHogPage'; import MailHogPage from '@/pages/MailHogPage';
import MiniQRPage from '@/pages/MiniQRPage'; import MiniQRPage from '@/pages/MiniQRPage';
import ExcalidrawPage from '@/pages/ExcalidrawPage'; import ExcalidrawPage from '@/pages/ExcalidrawPage';
@ -43,15 +42,9 @@ import JitsiMeetPage from '@/pages/JitsiMeetPage';
import SettingsPage from '@/pages/SettingsPage'; import SettingsPage from '@/pages/SettingsPage';
import NavigationSettingsPage from '@/pages/NavigationSettingsPage'; import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
import PangolinPage from '@/pages/PangolinPage'; import PangolinPage from '@/pages/PangolinPage';
import ControlPanelPage from '@/pages/ControlPanelPage';
import ObservabilityPage from '@/pages/ObservabilityPage'; import ObservabilityPage from '@/pages/ObservabilityPage';
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage'; 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 DocsCommentsPage from '@/pages/DocsCommentsPage';
import DocsMetadataPage from '@/pages/DocsMetadataPage';
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage'; import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
import SubscribersPage from '@/pages/payments/SubscribersPage'; import SubscribersPage from '@/pages/payments/SubscribersPage';
import PaymentProductsPage from '@/pages/payments/ProductsPage'; import PaymentProductsPage from '@/pages/payments/ProductsPage';
@ -67,11 +60,6 @@ import GalleryAdsPage from '@/pages/media/GalleryAdsPage';
import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage'; import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage';
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage'; import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage'; 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 PublicLandingPage from '@/pages/public/LandingPage';
import PagesIndexPage from '@/pages/public/PagesIndexPage'; import PagesIndexPage from '@/pages/public/PagesIndexPage';
import EventsPage from '@/pages/public/EventsPage'; import EventsPage from '@/pages/public/EventsPage';
@ -110,7 +98,6 @@ import SocialFeedPage from '@/pages/volunteer/SocialFeedPage';
import DiscoverPage from '@/pages/volunteer/DiscoverPage'; import DiscoverPage from '@/pages/volunteer/DiscoverPage';
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage'; import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
import AchievementsPage from '@/pages/volunteer/AchievementsPage'; import AchievementsPage from '@/pages/volunteer/AchievementsPage';
import MyAnalyticsPage from '@/pages/volunteer/MyAnalyticsPage';
import { import {
ADMIN_ROLES, ADMIN_ROLES,
INFLUENCE_ROLES, INFLUENCE_ROLES,
@ -123,8 +110,6 @@ import {
EVENTS_ROLES, EVENTS_ROLES,
SOCIAL_ROLES, SOCIAL_ROLES,
SYSTEM_ROLES, SYSTEM_ROLES,
POLLS_ROLES,
ANALYTICS_ROLES,
} from '@/types/api'; } from '@/types/api';
import { isAdmin } from '@/utils/roles'; import { isAdmin } from '@/utils/roles';
import QuickJoinPage from '@/pages/public/QuickJoinPage'; import QuickJoinPage from '@/pages/public/QuickJoinPage';
@ -145,10 +130,6 @@ import ReferralAdminPage from '@/pages/social/ReferralAdminPage';
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage'; import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage'; import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage'; 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 ReferralsPage from '@/pages/volunteer/ReferralsPage';
import ChallengesPage from '@/pages/volunteer/ChallengesPage'; import ChallengesPage from '@/pages/volunteer/ChallengesPage';
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage'; import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
@ -159,8 +140,6 @@ import MeetingAgendaPage from '@/pages/MeetingAgendaPage';
import ActionItemsPage from '@/pages/ActionItemsPage'; import ActionItemsPage from '@/pages/ActionItemsPage';
import SchedulingPollPage from '@/pages/public/SchedulingPollPage'; import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage'; import PollsListPage from '@/pages/public/PollsListPage';
import StrawPollPage from '@/pages/public/StrawPollPage';
import StrawPollsListPage from '@/pages/public/StrawPollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage'; import JitsiAuthPage from '@/pages/JitsiAuthPage';
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage'; import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage'; import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
@ -174,7 +153,6 @@ import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage'; import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage'; import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage'; import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
import SharedDocEditorPage from '@/pages/public/SharedDocEditorPage';
import NotFoundPage from '@/pages/NotFoundPage'; import NotFoundPage from '@/pages/NotFoundPage';
import CommandPalette from '@/components/command-palette/CommandPalette'; import CommandPalette from '@/components/command-palette/CommandPalette';
@ -187,7 +165,7 @@ function RoleAwareRedirect() {
function NavigateToCutMap() { function NavigateToCutMap() {
const { cutId } = useParams<{ cutId: string }>(); const { cutId } = useParams<{ cutId: string }>();
return <Navigate to={`/volunteer/map?cutId=${cutId}`} replace />; return <Navigate to={`/volunteer?cutId=${cutId}`} replace />;
} }
export default function App() { export default function App() {
@ -255,12 +233,6 @@ export default function App() {
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}> <Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route index element={<CampaignsListPage />} /> <Route index element={<CampaignsListPage />} />
</Route> </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={ <Route path="/campaigns/create" element={
<FeatureGate feature="enableInfluence"> <FeatureGate feature="enableInfluence">
<ProtectedRoute> <ProtectedRoute>
@ -301,14 +273,6 @@ export default function App() {
<Route index element={<SchedulingPollPage />} /> <Route index element={<SchedulingPollPage />} />
</Route> </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 */} {/* Public ticketed event pages — feature-gated */}
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}> <Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<TicketedEventDetailPage />} /> <Route index element={<TicketedEventDetailPage />} />
@ -349,9 +313,6 @@ export default function App() {
<Route index element={<ContactProfilePage />} /> <Route index element={<ContactProfilePage />} />
</Route> </Route>
{/* Shared doc editor (no auth, token-based access) */}
<Route path="/docs/share/:shareToken" element={<SharedDocEditorPage />} />
{/* Public Media Gallery (purple theme) — feature-gated */} {/* Public Media Gallery (purple theme) — feature-gated */}
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}> <Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<MediaGalleryPage />} /> <Route index element={<MediaGalleryPage />} />
@ -373,9 +334,9 @@ export default function App() {
{/* Email link alias for video viewer */} {/* Email link alias for video viewer */}
<Route path="/media/:id" element={<MediaViewerPage />} /> <Route path="/media/:id" element={<MediaViewerPage />} />
{/* Volunteer map — full-screen (moved from /volunteer to /volunteer/map) */} {/* Volunteer map — full-screen, default landing page */}
<Route <Route
path="/volunteer/map" path="/volunteer"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<VolunteerMapPage /> <VolunteerMapPage />
@ -401,7 +362,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
> >
<Route path="/volunteer" element={<VolunteerDashboardPage />} />
<Route path="/volunteer/activity" element={<MyActivityPage />} /> <Route path="/volunteer/activity" element={<MyActivityPage />} />
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} /> <Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
<Route path="/volunteer/routes" element={<MyRoutesPage />} /> <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/shared" element={<FeatureGate feature="enableSocialCalendar"><SharedCalendarsPage /></FeatureGate>} />
<Route path="/volunteer/calendar/friend/:userId" element={<FeatureGate feature="enableSocialCalendar"><FriendCalendarPage /></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/calendar" element={<FeatureGate feature="enableSocialCalendar"><MyCalendarPage /></FeatureGate>} />
<Route path="/volunteer/my-analytics" element={<FeatureGate feature="enableAnalytics"><MyAnalyticsPage /></FeatureGate>} />
<Route path="/volunteer/*" element={<NotFoundPage />} /> <Route path="/volunteer/*" element={<NotFoundPage />} />
</Route> </Route>
@ -597,62 +556,6 @@ export default function App() {
</ProtectedRoute> </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 <Route
path="listmonk" path="listmonk"
element={ element={
@ -701,14 +604,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="docs/metadata"
element={
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<DocsMetadataPage />
</ProtectedRoute>
}
/>
<Route <Route
path="navigation" path="navigation"
element={ element={
@ -749,14 +644,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="services/gitea/setup"
element={
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<GiteaSetupPage />
</ProtectedRoute>
}
/>
<Route <Route
path="services/mailhog" path="services/mailhog"
element={ element={
@ -878,14 +765,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="control-panel"
element={
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<ControlPanelPage />
</ProtectedRoute>
}
/>
<Route <Route
path="observability" path="observability"
element={ element={
@ -894,46 +773,6 @@ export default function App() {
</ProtectedRoute> </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 <Route
path="map" path="map"
element={ element={

View File

@ -71,8 +71,6 @@ import {
MEDIA_ROLES, MEDIA_ROLES,
PAYMENTS_ROLES, PAYMENTS_ROLES,
SOCIAL_ROLES, SOCIAL_ROLES,
POLLS_ROLES,
ANALYTICS_ROLES,
} from '@/types/api'; } from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url'; import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api'; import type { NavItem } from '@/types/api';
@ -187,14 +185,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' }, { 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/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/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/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' }, { 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 +231,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' }); webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' }); 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/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/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' }); webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({ items.push({
@ -332,20 +323,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
} }
if (isSuperAdmin) { 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({ items.push({
key: 'services-submenu', key: 'services-submenu',
icon: <CloudServerOutlined />, icon: <CloudServerOutlined />,
@ -353,7 +330,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
children: [ children: [
{ type: 'group', label: 'Infrastructure', children: [ { type: 'group', label: 'Infrastructure', children: [
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' }, { 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/observability', icon: <LineChartOutlined />, label: 'Monitoring' },
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' }, { key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
{ key: '/app/services/vaultwarden', icon: <LockOutlined />, label: 'Vault' }, { key: '/app/services/vaultwarden', icon: <LockOutlined />, label: 'Vault' },
@ -362,7 +338,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ type: 'group', label: 'Tools', children: [ { type: 'group', label: 'Tools', children: [
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' }, { key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' }, { 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/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' }, { key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]}, ]},
@ -659,7 +634,7 @@ export default function AppLayout() {
/> />
</Tooltip> </Tooltip>
{pageHeader?.actions} {pageHeader?.actions}
{!isMobile && (() => { {(() => {
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS); const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
const withOverrides = applyAdminOverrides(merged); const withOverrides = applyAdminOverrides(merged);
const flags = buildFeatureFlags(settings); const flags = buildFeatureFlags(settings);
@ -668,14 +643,11 @@ export default function AppLayout() {
const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? <GlobalOutlined />; const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? <GlobalOutlined />;
const handleItemClick = (item: NavItem) => { const handleItemClick = (item: NavItem) => {
if (item.path.startsWith('$')) { 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') { } else if (item.external && item.id === 'home') {
window.open(buildHomeUrl(), '_blank', 'noopener,noreferrer'); window.open(buildHomeUrl(), '_blank');
} else if (item.external) { } else if (item.external) {
// Only open http/https URLs to prevent javascript: URI injection window.open(item.path, '_blank');
if (item.path.startsWith('http://') || item.path.startsWith('https://')) {
window.open(item.path, '_blank', 'noopener,noreferrer');
}
} else { } else {
navigate(item.path); navigate(item.path);
} }
@ -697,7 +669,7 @@ export default function AppLayout() {
placement="bottomRight" placement="bottomRight"
> >
<Button type="text" size="small" icon={getIcon(item.icon)}> <Button type="text" size="small" icon={getIcon(item.icon)}>
{!collapsed && item.label} {!isMobile && !collapsed && item.label}
</Button> </Button>
</Dropdown> </Dropdown>
); );
@ -710,25 +682,23 @@ export default function AppLayout() {
icon={getIcon(item.icon)} icon={getIcon(item.icon)}
onClick={() => handleItemClick(item)} onClick={() => handleItemClick(item)}
> >
{!collapsed && item.label} {!isMobile && !collapsed && item.label}
</Button> </Button>
</Tooltip> </Tooltip>
); );
}); });
})()} })()}
{/* Volunteer Portal button — always visible for quick switching */} {/* Volunteer Portal button — always visible for quick switching */}
{!isMobile && ( <Tooltip title="Switch to Volunteer Portal">
<Tooltip title="Switch to Volunteer Portal"> <Button
<Button type="text"
type="text" size="small"
size="small" icon={<TeamOutlined />}
icon={<TeamOutlined />} onClick={() => navigate('/volunteer')}
onClick={() => navigate('/volunteer')} >
> {!isMobile && !collapsed && 'Volunteer'}
{!collapsed && 'Volunteer'} </Button>
</Button> </Tooltip>
</Tooltip>
)}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />} data-tour="user-menu"> <Button type="text" icon={<UserOutlined />} data-tour="user-menu">
{!isMobile && !collapsed && ( {!isMobile && !collapsed && (
@ -740,7 +710,6 @@ export default function AppLayout() {
</Dropdown> </Dropdown>
</Header> </Header>
<Content <Content
id="app-content-area"
style={{ style={{
margin: fullBleed ? 0 : (isMobile ? 12 : 24), margin: fullBleed ? 0 : (isMobile ? 12 : 24),
padding: fullBleed ? 0 : (isMobile ? 16 : 24), padding: fullBleed ? 0 : (isMobile ? 16 : 24),
@ -748,7 +717,6 @@ export default function AppLayout() {
borderRadius: fullBleed ? 0 : token.borderRadiusLG, borderRadius: fullBleed ? 0 : token.borderRadiusLG,
minHeight: 280, minHeight: 280,
overflow: fullBleed ? 'hidden' : undefined, overflow: fullBleed ? 'hidden' : undefined,
position: 'relative',
}} }}
> >
<Outlet context={{ setPageHeader } satisfies AppOutletContext} /> <Outlet context={{ setPageHeader } satisfies AppOutletContext} />

View File

@ -22,13 +22,10 @@ const FEATURE_LABELS: Record<string, string> = {
enableMeetingPlanner: 'Meeting Planner', enableMeetingPlanner: 'Meeting Planner',
enableTicketedEvents: 'Ticketed Events', enableTicketedEvents: 'Ticketed Events',
enableSocialCalendar: 'Social Calendar', enableSocialCalendar: 'Social Calendar',
enablePetitions: 'Petitions',
enablePolls: 'Straw Polls',
enableAnalytics: 'Analytics Dashboard',
}; };
interface FeatureGateProps { 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; children: ReactNode;
} }

View File

@ -571,40 +571,6 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
</div> </div>
</section>`; </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: default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`; 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

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

View File

@ -6,7 +6,6 @@ import {
UserOutlined, UserOutlined,
GlobalOutlined, GlobalOutlined,
AppstoreOutlined, AppstoreOutlined,
HomeOutlined,
EnvironmentOutlined, EnvironmentOutlined,
ScheduleOutlined, ScheduleOutlined,
HistoryOutlined, HistoryOutlined,
@ -15,7 +14,6 @@ import {
TagOutlined, TagOutlined,
TeamOutlined, TeamOutlined,
MessageOutlined, MessageOutlined,
BarChartOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -50,8 +48,7 @@ export default function VolunteerLayout() {
// Build nav items list (mirrors VolunteerFooterNav logic) // Build nav items list (mirrors VolunteerFooterNav logic)
const navItems = useMemo(() => { const navItems = useMemo(() => {
const items: { key: string; icon: React.ReactNode; label: string }[] = [ const items: { key: string; icon: React.ReactNode; label: string }[] = [
{ key: '/volunteer', icon: <HomeOutlined />, label: 'Home' }, { key: '/volunteer', icon: <EnvironmentOutlined />, label: 'Map' },
{ key: '/volunteer/map', icon: <EnvironmentOutlined />, label: 'Map' },
{ key: '/volunteer/shifts', icon: <ScheduleOutlined />, label: 'Shifts' }, { key: '/volunteer/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
{ key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' }, { key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' },
{ key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' }, { key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' },
@ -68,9 +65,6 @@ export default function VolunteerLayout() {
if (settings?.enableChat) { if (settings?.enableChat) {
items.push({ key: '/volunteer/chat', icon: <MessageOutlined />, label: 'Chat' }); 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; return items;
}, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]); }, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]);
@ -103,7 +97,7 @@ export default function VolunteerLayout() {
<Content <Content
style={{ style={{
maxWidth: location.pathname === '/volunteer' ? 1280 : 800, maxWidth: 800,
width: '100%', width: '100%',
margin: '0 auto', margin: '0 auto',
padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px', 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 { useState, useEffect, useMemo } from 'react';
import { import {
Drawer, Modal,
Form, Form,
Input, Input,
DatePicker, DatePicker,
@ -169,20 +169,13 @@ export default function CalendarItemModal({
}; };
return ( return (
<Drawer <Modal
open={open} open={open}
onClose={onCancel} onCancel={onCancel}
title={isEditing ? 'Edit Calendar Item' : 'New Calendar Item'} title={isEditing ? 'Edit Calendar Item' : 'New Calendar Item'}
footer={null}
width={520} width={520}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose destroyOnClose
extra={
<Button type="primary" onClick={() => form.submit()} loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
</Button>
}
> >
<Form <Form
form={form} form={form}
@ -461,18 +454,26 @@ export default function CalendarItemModal({
)} )}
{/* Actions */} {/* Actions */}
{isEditing && onDelete && ( <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
<div style={{ marginTop: 8 }}> <div>
<Button {isEditing && onDelete && (
danger <Button
icon={<DeleteOutlined />} danger
onClick={onDelete} icon={<DeleteOutlined />}
> onClick={onDelete}
Delete >
</Button> Delete
</Button>
)}
</div> </div>
)} <Space>
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
</Button>
</Space>
</div>
</Form> </Form>
</Drawer> </Modal>
); );
} }

View File

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

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, PlusOutlined,
CloseOutlined, CloseOutlined,
FileOutlined, FileOutlined,
FolderOpenOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor'; import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
import { isImageFile } 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 type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal'; import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal'; import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
import { MoveToModal } from '@/components/docs/MoveToModal';
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration'; import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars'; import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
import { YTextareaBinding } from '@/lib/y-textarea'; import { YTextareaBinding } from '@/lib/y-textarea';
@ -261,8 +259,6 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
const [adPickerOpen, setAdPickerOpen] = useState(false); const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false); const [pollInsertOpen, setPollInsertOpen] = useState(false);
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false); const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
const [moveSourcePath, setMoveSourcePath] = useState('');
const { const {
fileTree, fileTree,
@ -291,7 +287,6 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
onContentChange, onContentChange,
handleDelete, handleDelete,
handleModalOk, handleModalOk,
handleMoveFile,
handleNewFileRoot, handleNewFileRoot,
handleNewFolderRoot, handleNewFolderRoot,
refreshTree, refreshTree,
@ -435,7 +430,6 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
} }
items.push( items.push(
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } }, { 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) }, { key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
); );
return items; return items;
@ -916,14 +910,6 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
setWikiLinkPickerOpen(false); 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 { 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 { FileOutlined, PictureOutlined } from '@ant-design/icons';
import type { FileNode } from '@/types/api'; import type { FileNode } from '@/types/api';
@ -62,15 +62,13 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
}; };
return ( return (
<Drawer <Modal
title="Insert Wiki Link" title="Insert Wiki Link"
open={open} open={open}
onClose={() => { onClose(); setSearch(''); }} onCancel={() => { onClose(); setSearch(''); }}
footer={null}
destroyOnHidden
width={420} width={420}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
> >
<Input.Search <Input.Search
placeholder="Search files..." placeholder="Search files..."
@ -150,6 +148,6 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
</div> </div>
)} )}
</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 { 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 type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
@ -118,19 +118,19 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
]; ];
return ( return (
<Drawer <Modal
title={`Send Test Email: ${template.name}`} title={`Send Test Email: ${template.name}`}
open={open} open={open}
onClose={onClose} onCancel={onClose}
width={isMobile ? '95vw' : 900} width={isMobile ? '95vw' : 900}
placement="right" footer={[
mask={false} <Button key="cancel" onClick={onClose}>
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }} Cancel
extra={ </Button>,
<Button type="primary" loading={sending} onClick={handleSend}> <Button key="send" type="primary" loading={sending} onClick={handleSend}>
Send Test Email Send Test Email
</Button> </Button>,
} ]}
> >
<Space direction="vertical" style={{ width: '100%' }} size="large"> <Space direction="vertical" style={{ width: '100%' }} size="large">
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
@ -244,6 +244,6 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
]} ]}
/> />
</Space> </Space>
</Drawer> </Modal>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api'; import { mediaPublicApi } from '@/lib/media-public-api';
@ -152,19 +152,13 @@ export default function AddToPlaylistModal({
}; };
return ( return (
<Drawer <Modal
title="Add to Playlist" title="Add to Playlist"
open={open} open={open}
onClose={onClose} onOk={handleSave}
width={480} onCancel={onClose}
placement="right" confirmLoading={saving}
mask={false} okText="Save"
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
Save
</Button>
}
> >
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: 32 }}> <div style={{ textAlign: 'center', padding: 32 }}>
@ -244,6 +238,6 @@ export default function AddToPlaylistModal({
)} )}
</> </>
)} )}
</Drawer> </Modal>
); );
} }

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { Drawer, Select, Button, message } from 'antd'; import { Modal, Select, message } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage'; import { getErrorMessage } from '@/utils/getErrorMessage';
@ -37,19 +37,13 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
}; };
return ( return (
<Drawer <Modal
title={`Set Access Level (${videoIds.length} video${videoIds.length !== 1 ? 's' : ''})`} title={`Set Access Level (${videoIds.length} video${videoIds.length !== 1 ? 's' : ''})`}
open={open} open={open}
onClose={onClose} onOk={handleOk}
width={480} onCancel={onClose}
placement="right" confirmLoading={loading}
mask={false} okText="Apply"
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleOk} loading={loading}>
Apply
</Button>
}
> >
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Select <Select
@ -60,6 +54,6 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
size="large" size="large"
/> />
</div> </div>
</Drawer> </Modal>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { PlusOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import type { PlaylistSummary } from '@/types/media'; import type { PlaylistSummary } from '@/types/media';
@ -113,19 +113,14 @@ export default function BulkAddToPlaylistModal({
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId); const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
return ( return (
<Drawer <Modal
title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`} title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`}
open={open} open={open}
onClose={onClose} onOk={handleAdd}
width={480} onCancel={onClose}
placement="right" confirmLoading={saving}
mask={false} okText="Add"
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }} okButtonProps={{ disabled: !selectedPlaylistId }}
extra={
<Button type="primary" onClick={handleAdd} loading={saving} disabled={!selectedPlaylistId}>
Add
</Button>
}
> >
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: 32 }}> <div style={{ textAlign: 'center', padding: 32 }}>
@ -189,6 +184,6 @@ export default function BulkAddToPlaylistModal({
)} )}
</> </>
)} )}
</Drawer> </Modal>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; 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 { mediaApi } from '@/lib/media-api';
import axios from 'axios'; import axios from 'axios';
@ -41,19 +41,13 @@ export default function CreateAlbumModal({
}; };
return ( return (
<Drawer <Modal
title="Create Album" title="Create Album"
open={open} open={open}
onClose={onClose} onOk={handleCreate}
width={480} onCancel={onClose}
placement="right" confirmLoading={loading}
mask={false} okText="Create"
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleCreate} loading={loading}>
Create
</Button>
}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}> <Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
@ -68,6 +62,6 @@ export default function CreateAlbumModal({
{selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album {selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album
</div> </div>
)} )}
</Drawer> </Modal>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; 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 { mediaApi } from '@/lib/media-api';
import axios from 'axios'; import axios from 'axios';
@ -53,12 +53,17 @@ export default function CreatePlaylistModal({
}} }}
placement="right" placement="right"
width={420} width={420}
mask={false} style={{ top: 64 }}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }} styles={{ body: { paddingTop: 24 } }}
extra={ extra={
<Button type="primary" onClick={handleSubmit} loading={loading}> <Space>
Create <Button onClick={() => { form.resetFields(); onClose(); }}>
</Button> Cancel
</Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create
</Button>
</Space>
} }
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">

View File

@ -130,8 +130,7 @@ export default function EditPlaylistModal({
}} }}
placement="right" placement="right"
width={isMobile ? '100%' : 520} width={isMobile ? '100%' : 520}
mask={false} style={{ top: 64 }}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
loading={loading} loading={loading}
> >
<Tabs <Tabs

View File

@ -148,9 +148,15 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
const connectSSE = async () => { const connectSSE = async () => {
try { try {
// Get auth token from in-memory store (not localStorage) // Get auth token from localStorage
const { useAuthStore } = await import('@/stores/auth.store'); const stored = localStorage.getItem('auth-storage');
const token = useAuthStore.getState().accessToken || ''; let token = '';
if (stored) {
try {
const parsed = JSON.parse(stored);
token = parsed?.state?.accessToken || '';
} catch {}
}
const response = await fetch(baseUrl, { const response = await fetch(baseUrl, {
headers: { headers: {

View File

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

View File

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

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 { ClockCircleOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
@ -152,7 +152,7 @@ export default function SchedulePublishModal({
const serverTime = publishAt?.utc().format('YYYY-MM-DD HH:mm:ss UTC'); const serverTime = publishAt?.utc().format('YYYY-MM-DD HH:mm:ss UTC');
return ( return (
<Drawer <Modal
title={ title={
<Space> <Space>
<ClockCircleOutlined /> <ClockCircleOutlined />
@ -160,16 +160,15 @@ export default function SchedulePublishModal({
</Space> </Space>
} }
open={open} open={open}
onClose={onClose} onCancel={onClose}
onOk={handleSchedule}
okText={publishNow ? 'Publish Now' : 'Schedule'}
confirmLoading={loading}
width={isMobile ? '95vw' : 600} width={isMobile ? '95vw' : 600}
placement="right" style={{ top: 20 }}
mask={false} styles={{
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }} body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
extra={ }}
<Button type="primary" onClick={handleSchedule} loading={loading}>
{publishNow ? 'Publish Now' : 'Schedule'}
</Button>
}
aria-label="Schedule video publishing" aria-label="Schedule video publishing"
> >
{video && ( {video && (
@ -303,6 +302,6 @@ export default function SchedulePublishModal({
)} )}
</div> </div>
)} )}
</Drawer> </Modal>
); );
} }

View File

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

View File

@ -2,8 +2,6 @@ import { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 're
import { Alert, Spin } from 'antd'; import { Alert, Spin } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons'; import { PlayCircleOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api'; import { getAuthCallbacks } from '@/lib/api';
import { signedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
export interface VideoMetadata { export interface VideoMetadata {
id: number; id: number;
@ -16,8 +14,6 @@ export interface VideoMetadata {
quality: string | null; quality: string | null;
streamUrl: string; streamUrl: string;
thumbnailUrl: string | null; thumbnailUrl: string | null;
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
hlsManifestUrl?: string | null;
createdAt: string; createdAt: string;
} }
@ -71,13 +67,6 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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 // Expose control methods via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
play: () => { play: () => {
@ -133,6 +122,15 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
fetchMetadata(); fetchMetadata();
}, [videoId]); }, [videoId]);
const appendToken = (url: string): string => {
if (!isAdmin) return url;
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${accessToken}`;
};
const fetchMetadata = async () => { const fetchMetadata = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -159,13 +157,10 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const data = await response.json(); const data = await response.json();
// For admin previews of unpublished media, sign stream/thumbnail URLs // For admin, append token to stream/thumbnail URLs so <video>/<img> can access them
// (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.
if (isAdmin) { if (isAdmin) {
if (data.streamUrl) data.streamUrl = await signedMediaUrl(data.streamUrl); if (data.streamUrl) data.streamUrl = appendToken(data.streamUrl);
if (data.thumbnailUrl) data.thumbnailUrl = await signedMediaUrl(data.thumbnailUrl); if (data.thumbnailUrl) data.thumbnailUrl = appendToken(data.thumbnailUrl);
} }
setMetadata(data); setMetadata(data);
@ -224,10 +219,6 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
? (metadata.height / metadata.width) * 100 ? (metadata.height / metadata.width) * 100
: 56.25; // Default to 16:9 : 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 ( return (
<div <div
style={{ style={{
@ -240,7 +231,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
> >
<video <video
ref={videoRef} ref={videoRef}
src={useMp4Src ? metadata.streamUrl : undefined} src={metadata.streamUrl}
poster={poster || metadata.thumbnailUrl || undefined} poster={poster || metadata.thumbnailUrl || undefined}
autoPlay={autoplay} autoPlay={autoplay}
controls={controls} controls={controls}

View File

@ -2,8 +2,16 @@ import { Modal } from 'antd';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { Video } from '@/types/media'; import type { Video } from '@/types/media';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { useSignedMediaUrl } from '@/lib/media-url'; import { getAuthCallbacks } from '@/lib/api';
import { useHls } from '@/lib/use-hls';
/** Append JWT access token as query param for <video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoViewerModalProps { interface VideoViewerModalProps {
video: Video | null; video: Video | null;
@ -16,17 +24,6 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
const [viewId, setViewId] = useState<number | null>(null); const [viewId, setViewId] = useState<number | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null); const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const lastWatchTime = useRef<number>(0); 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(() => { useEffect(() => {
if (open && video) { if (open && video) {
@ -178,7 +175,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
> >
<video <video
ref={videoRef} ref={videoRef}
src={useMp4Src ? streamUrl : undefined} src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)}
controls controls
autoPlay autoPlay
style={{ style={{

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { HeartOutlined, DollarOutlined, AppstoreOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
@ -80,23 +80,14 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
}); });
return ( return (
<Drawer <Modal
title="Insert Donate Block" title="Insert Donate Block"
open={open} open={open}
onClose={onClose} onCancel={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: variant === 'set-amount' && (!amount || amount <= 0) }}
width={isMobile ? '95vw' : 520} 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 }}> <Paragraph type="secondary" style={{ marginBottom: 16 }}>
Choose a donation block style to insert into your document. Choose a donation block style to insert into your document.
@ -185,6 +176,6 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
</div> </div>
</Space> </Space>
</Radio.Group> </Radio.Group>
</Drawer> </Modal>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { ShoppingCartOutlined, SearchOutlined, CheckCircleOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
import type { Product, ProductType } from '@/types/api'; import type { Product, ProductType } from '@/types/api';
@ -35,8 +35,8 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
if (open && products.length === 0) { if (open && products.length === 0) {
setLoading(true); setLoading(true);
setError(null); setError(null);
axios.get('/api/payments/products', { params: { limit: 50 } }) axios.get('/api/payments/products')
.then(({ data }) => setProducts(data.products)) .then(({ data }) => setProducts(data))
.catch(() => setError('Failed to load products')) .catch(() => setError('Failed to load products'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} }
@ -60,19 +60,14 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
}); });
return ( return (
<Drawer <Modal
title="Insert Product Card" title="Insert Product Card"
open={open} open={open}
onClose={onClose} onCancel={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: !selectedId }}
width={isMobile ? '95vw' : 640} 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 }}> <Paragraph type="secondary" style={{ marginBottom: 12 }}>
Select a product to embed as an inline purchase card. Select a product to embed as an inline purchase card.
@ -153,6 +148,6 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
})} })}
</Row> </Row>
</div> </div>
</Drawer> </Modal>
); );
} }

View File

@ -21,9 +21,9 @@ export function ProductWidget({ productSlug, buttonText = 'Buy Now' }: ProductWi
return; return;
} }
axios.get('/api/payments/products', { params: { limit: 50 } }) axios.get('/api/payments/products')
.then(({ data }) => { .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) { if (found) {
setProduct(found); setProduct(found);
} else { } 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 { useState } from 'react';
import { CopyOutlined } from '@ant-design/icons'; import { CopyOutlined } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -76,20 +76,13 @@ export default function CreateUserFromContactModal({
}; };
return ( return (
<Drawer <Modal
title="Create User Account" title="Create User Account"
open={open} open={open}
onClose={() => { form.resetFields(); onClose(); }} onCancel={() => { form.resetFields(); onClose(); }}
footer={null}
destroyOnHidden
width={480} 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 }}> <div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Contact</Typography.Text> <Typography.Text type="secondary" style={{ fontSize: 12 }}>Contact</Typography.Text>
@ -130,12 +123,17 @@ export default function CreateUserFromContactModal({
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 0, display: 'none' }}> <Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit"> <Space>
Create Account <Button type="primary" htmlType="submit" loading={submitting}>
</Button> Create Account
</Button>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
</Space>
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Modal>
); );
} }

View File

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

View File

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

View File

@ -57,7 +57,7 @@ export default function VideoCallModal({ open, onClose, personName }: VideoCallM
const { data } = await api.post<{ token: string; jitsiRoom: string; domain: string }>( const { data } = await api.post<{ token: string; jitsiRoom: string; domain: string }>(
`/jitsi/meetings/${meeting.slug}/token`, `/jitsi/meetings/${meeting.slug}/token`,
); );
window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank', 'noopener,noreferrer'); window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank');
} catch (err: unknown) { } catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to get moderator token')); message.error(getErrorMessage(err, 'Failed to get moderator token'));
} finally { } finally {

View File

@ -1,51 +0,0 @@
import { Progress, Space, Typography } from 'antd';
import type { StrawPollOption } from '@/types/api';
const { Text } = Typography;
const YES_NO_COLORS: Record<string, string> = {
Yes: '#52c41a',
No: '#ff4d4f',
Abstain: '#8c8c8c',
};
interface PollResultsProps {
options: StrawPollOption[];
totalVotes: number;
type: 'SINGLE_CHOICE' | 'YES_NO_ABSTAIN';
}
const COLORS = ['#1890ff', '#52c41a', '#faad14', '#ff4d4f', '#722ed1', '#13c2c2', '#eb2f96', '#fa8c16', '#a0d911', '#2f54eb'];
export default function PollResults({ options, totalVotes, type }: PollResultsProps) {
return (
<div>
{options.map((opt, i) => {
const count = opt.voteCount ?? opt._count?.votes ?? 0;
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
const color = type === 'YES_NO_ABSTAIN'
? YES_NO_COLORS[opt.label] || COLORS[i % COLORS.length]
: COLORS[i % COLORS.length];
return (
<div key={opt.id} style={{ marginBottom: 12 }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>{opt.label}</Text>
<Text type="secondary">{count} vote{count !== 1 ? 's' : ''} ({pct}%)</Text>
</Space>
<Progress
percent={pct}
showInfo={false}
strokeColor={color}
size="small"
style={{ marginTop: 4 }}
/>
</div>
);
})}
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
Total: {totalVotes} vote{totalVotes !== 1 ? 's' : ''}
</Text>
</div>
);
}

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