Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5331cdcc67 | |||
| 8af11af720 | |||
| bf997e84c1 | |||
| 35175a7136 | |||
| abb4034e4b | |||
| 97444645cb | |||
| f34382ebdd | |||
| 4a3d9d7c41 | |||
| 731e70ee42 | |||
| a7d3dd772b | |||
| 9613c3ec81 | |||
| e88ac79ae8 | |||
| 1b80e8294c | |||
| a531f9b9ce | |||
| a82e95946b | |||
| 3f6102cf6d | |||
| 1f240ad518 | |||
| 21208b58c7 | |||
| 2ae7d8b968 | |||
| aba935c8ac | |||
| 4ccc433eb9 | |||
| 94451f9aa0 | |||
| 6d562da4b2 | |||
| 3f8c064649 | |||
| 5082fe7b76 | |||
| 3a528d9a49 | |||
| 8a2b82a4e8 | |||
| 5968df5b42 | |||
| 824f3cce99 | |||
| 450b5ad4ba | |||
| d2da13929a | |||
| 6504598752 | |||
| ce8c5aaf1f | |||
| c2f12aa2bf | |||
| 6e01d580b2 | |||
| dbbff8adc9 | |||
| f9d566bd84 | |||
| 13513aeca5 | |||
| ac901c9e53 | |||
| 47704667b1 | |||
| 12708e5824 | |||
| 23df6a8b52 | |||
| 5115c65691 | |||
| e55bc07eb6 | |||
| 26ec925d9b | |||
| 29d1f3998a | |||
| 054902b9f9 | |||
| 82db26fcef | |||
| df65b1b72e | |||
| 80321f04e7 | |||
| 96ff2a85d6 | |||
| 76fd3c7065 | |||
| c00b4432d7 | |||
| ae5a90d8d4 | |||
| ed011a762b | |||
| 3fc67cd81a | |||
| 5f0ae6bc5a | |||
| aa69048024 | |||
| c180bb5ace | |||
| c5209887cc | |||
| ca446136a1 | |||
| 0510420772 | |||
| 36b709b911 | |||
| 0a8e1fe46b | |||
| f8c8a939d7 | |||
| bca4cb8227 | |||
| f0d994074d | |||
| 849dea7ce2 | |||
| 72dbd0189c | |||
| 0b0c33cfee | |||
| c6f8a49925 | |||
| 215da79284 | |||
| 145ba4268f | |||
| d010993994 | |||
| 513b8cfea5 | |||
| 38ccaa8a5b | |||
| d17e197a1b | |||
| cbfa4f9e28 | |||
| 530551f568 | |||
| 74e5fa6475 | |||
| 72622671a2 | |||
| 08bd1f92b0 | |||
| 0a20444a74 | |||
| 610f547dbf | |||
| 6db44eadc6 | |||
| 5a0c4641a1 | |||
| d7ab8f0d99 | |||
| c306e061ab | |||
| f378db89b5 |
71
.env.example
71
.env.example
@ -46,20 +46,27 @@ 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
|
||||||
JWT_REFRESH_EXPIRY=7d
|
# Reduced from 7d → 24h on 2026-04-12 (P2-3 hardening). Combined with
|
||||||
|
# device-fingerprint binding in the JWT payload, this tightens the
|
||||||
|
# exploitation window for stolen refresh tokens.
|
||||||
|
JWT_REFRESH_EXPIRY=24h
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Gitea SSO cookie signing secret (separate from JWT — falls back to JWT_ACCESS_SECRET if empty)
|
# BREAKING CHANGE (2026-04-12): both GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
|
||||||
# Generate with: openssl rand -hex 32
|
# are now REQUIRED (min 32 chars). The previous fallback to JWT_ACCESS_SECRET
|
||||||
GITEA_SSO_SECRET=
|
# has been removed — a JWT leak must not compromise SSO cookies or service
|
||||||
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat)
|
# account passwords. Both values must be distinct from each other and from
|
||||||
# Falls back to JWT_ACCESS_SECRET if empty — set a dedicated value to isolate secret rotation
|
# all JWT_* secrets. Generate with: openssl rand -hex 32
|
||||||
# Generate with: openssl rand -hex 32
|
|
||||||
SERVICE_PASSWORD_SALT=
|
# 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
|
||||||
@ -181,6 +188,13 @@ 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
|
||||||
@ -212,12 +226,21 @@ 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 ---
|
# --- Gitea (Local Platform Instance) ---
|
||||||
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
|
||||||
@ -230,7 +253,9 @@ 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 with repo write scope (create in Gitea → Settings → Applications)
|
# Personal access token for the LOCAL Gitea instance (docs comments, user provisioning, SSO)
|
||||||
|
# Create at: http://localhost:3030/user/settings/applications (or https://git.DOMAIN/...)
|
||||||
|
# This is NOT the same as GITEA_REGISTRY_API_TOKEN (which is for the remote registry above)
|
||||||
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=
|
||||||
@ -263,6 +288,7 @@ 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
|
||||||
@ -397,6 +423,26 @@ 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
|
||||||
@ -414,3 +460,8 @@ 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
28
.gitignore
vendored
@ -9,6 +9,9 @@ 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/
|
||||||
|
|
||||||
@ -33,7 +36,8 @@ node_modules/
|
|||||||
# NAR data directory (large voter registry files)
|
# NAR data directory (large voter registry files)
|
||||||
/data/*
|
/data/*
|
||||||
!/data/upgrade/
|
!/data/upgrade/
|
||||||
/data/upgrade/*.json
|
/data/upgrade/*
|
||||||
|
!/data/upgrade/.gitkeep
|
||||||
|
|
||||||
# Media files (managed by Docker volumes, not git)
|
# Media files (managed by Docker volumes, not git)
|
||||||
/media/
|
/media/
|
||||||
@ -60,13 +64,35 @@ 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/
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 287ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4004/favicon.ico:0
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 915ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
|
|
||||||
@ -1,442 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 567ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 605ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 1538ms] [ERROR] Failed to load resource: the server responded with a status of 400 (Bad Request) @ http://localhost:8091/auth/token/refresh:0
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 32ms] [WARNING] Manifest: property 'start_url' ignored, should be same origin as document. @ data:application/json;base64,eyJuYW1lIjoiR2l0ZWE6IEdpdCB3aXRoIGEgY3VwIG9mIHRlYSIsInNob3J0X25hbWUiOiJHaXRlYTogR2l0IHdpdGggYSBjdXAgb2YgdGVhIiwic3RhcnRfdXJsIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy8iLCJpY29ucyI6W3sic3JjIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy9hc3NldHMvaW1nL2xvZ28ucG5nIiwidHlwZSI6ImltYWdlL3BuZyIsInNpemVzIjoiNTEyeDUxMiJ9LHsic3JjIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy9hc3NldHMvaW1nL2xvZ28uc3ZnIiwidHlwZSI6ImltYWdlL3N2Zyt4bWwiLCJzaXplcyI6IjUxMng1MTIifV19:0
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 871ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5678/rest/login:0
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 343ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/favicon.ico:0
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 238ms] [WARNING] Simple Analytics: Set hostname on localhost:8090. See https://docs.simpleanalytics.com/overwrite-domain-name @ https://scripts.simpleanalyticscdn.com/latest.js:2
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[ 1014ms] [WARNING] GPS permission denied — enable location access in your browser settings @ http://localhost:3002/src/components/canvass/GPSTracker.tsx:32
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
[ 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
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
[ 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.
|
Before Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 161 KiB |
280
CLAUDE.md
280
CLAUDE.md
@ -10,15 +10,19 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
|
|||||||
|
|
||||||
**Status Summary:**
|
**Status Summary:**
|
||||||
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
|
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
|
||||||
- ✅ Security Audit Complete (13 findings addressed, Feb 2026)
|
- ✅ Drizzle to Prisma Migration Complete (single-ORM, Feb 2026)
|
||||||
- ✅ NAR 2025 Server Import (Canadian electoral data)
|
- ✅ Automated Pangolin Setup (one-command tunnel deployment)
|
||||||
- ✅ Media Manager Integration (dual API architecture)
|
- ✅ 3 Security Audits Complete (Feb 2025 + Mar 22/27/30 2026)
|
||||||
- ✅ Email Templates System
|
- ✅ Social Connections + Calendar (friendship, shared views, availability finder)
|
||||||
- ✅ Data Quality Dashboard
|
- ✅ Payments + Ticketed Events (Stripe integration, check-in scanner)
|
||||||
- ✅ Observability Dashboard
|
- ✅ Meeting Planner + Straw Polls (scheduling, voting)
|
||||||
- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026)
|
- ✅ SMS Campaign Connector (Termux Android bridge)
|
||||||
- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026)
|
- ✅ Docs CMS (blog authoring, access policies, collaboration, version history)
|
||||||
- ✅ **Migration Drift Fixed** (Baseline catch-up migration, 14 migrations cover full schema, Feb 2026)
|
- ✅ User Provisioning Framework (Gitea, Vaultwarden, Listmonk)
|
||||||
|
- ✅ Granular Admin Roles (9 admin roles + module-specific RBAC)
|
||||||
|
- ✅ Collaborative Docs Editing (Y.js CRDT + Hocuspocus)
|
||||||
|
- ✅ Engagement Scoring + EventBus + Gitea SSO
|
||||||
|
- ✅ MCP Server (Claude Code integration, 27 core + 6 on-demand packs (~65 tools))
|
||||||
- 🚧 Phase 15 (Testing + Polish) - Next
|
- 🚧 Phase 15 (Testing + Polish) - Next
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -59,10 +63,9 @@ 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 # 30+ models: User, Campaign, Location, Shift, etc.
|
│ │ ├── schema.prisma # 192 models: User, Campaign, Location, Shift, Payment, Social, etc.
|
||||||
│ │ ├── migrations/ # Prisma migration history
|
│ │ ├── migrations/ # 50 Prisma migrations (full schema 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)
|
||||||
@ -70,10 +73,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/
|
│ ├── modules/ # 44 modules total
|
||||||
│ │ ├── 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
|
│ │ ├── settings/ # Site settings singleton (20+ feature flags)
|
||||||
│ │ ├── services/ # Service health checks
|
│ │ ├── services/ # Service health checks
|
||||||
│ │ ├── influence/
|
│ │ ├── influence/
|
||||||
│ │ │ ├── campaigns/ # Campaign CRUD + public routes
|
│ │ │ ├── campaigns/ # Campaign CRUD + public routes
|
||||||
@ -90,16 +93,39 @@ 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/
|
│ │ ├── pages/ # Landing page CRUD + block library + public renderer
|
||||||
│ │ │ ├── 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)
|
│ │ ├── media/ # Fastify media API (videos, reactions, jobs, analytics)
|
||||||
|
│ │ ├── social/ # Friendships, challenges, spotlights, referrals
|
||||||
|
│ │ ├── calendar/ # Calendar layers, items, shared views, availability
|
||||||
|
│ │ ├── payments/ # Stripe products, donations, subscriptions
|
||||||
|
│ │ ├── ticketed-events/ # Event ticketing, tiers, check-in
|
||||||
|
│ │ ├── sms/ # SMS campaigns via Termux Android bridge
|
||||||
|
│ │ ├── meeting-planner/ # Meeting scheduling with polls
|
||||||
|
│ │ ├── meetings/ # Meeting agendas, minutes, action items
|
||||||
|
│ │ ├── polls/ # Straw polls with comments + voting
|
||||||
|
│ │ ├── docs/ # MkDocs health checks + export routes
|
||||||
|
│ │ ├── docs-analytics/ # Docs page view tracking
|
||||||
|
│ │ ├── docs-comments/ # Gitea-backed comments on docs
|
||||||
|
│ │ ├── people/ # CRM people module
|
||||||
|
│ │ ├── events/ # Gancio event integration
|
||||||
|
│ │ ├── newsletter/ # Newsletter management
|
||||||
│ │ ├── listmonk/ # Newsletter sync admin routes
|
│ │ ├── listmonk/ # Newsletter sync admin routes
|
||||||
│ │ ├── pangolin/ # Tunnel management (Newt integration)
|
│ │ ├── pangolin/ # Tunnel management (Newt integration)
|
||||||
│ │ ├── docs/ # MkDocs + Code Server health checks
|
│ │ ├── rocketchat/ # Rocket.Chat integration
|
||||||
|
│ │ ├── jitsi/ # Jitsi video conferencing auth
|
||||||
|
│ │ ├── registry/ # Docker image registry management
|
||||||
|
│ │ ├── upgrade/ # Auto-upgrade checks + deployment
|
||||||
|
│ │ ├── gitea-setup/ # Gitea SSO + API token management
|
||||||
|
│ │ ├── volunteer-invite/ # Invite codes + setup workflows
|
||||||
|
│ │ ├── gallery-ads/ # Media gallery ads
|
||||||
|
│ │ ├── homepage/ # Homepage stats + dashboard
|
||||||
|
│ │ ├── search/ # Cross-module search
|
||||||
|
│ │ ├── reports/ # Analytics + reporting
|
||||||
|
│ │ ├── og/ # Open Graph metadata
|
||||||
│ │ ├── 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)
|
||||||
@ -119,34 +145,50 @@ 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/
|
│ ├── pages/ # 52 root pages + 8 subdirectories
|
||||||
│ │ ├── auth/ # LoginPage
|
│ │ ├── influence/ # Campaign moderation, effectiveness, impact stories, straw polls
|
||||||
│ │ ├── influence/ # CampaignsPage, ResponsesPage, RepresentativesPage, EmailQueuePage
|
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard
|
||||||
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboardPage
|
│ │ ├── media/ # Library, Playlists, Analytics, Gallery Ads, Comment Moderation
|
||||||
│ │ ├── volunteer/ # VolunteerMapPage, VolunteerShiftsPage, MyActivityPage, MyRoutesPage
|
│ │ ├── payments/ # Dashboard, Products, Plans, Donations, Subscribers, Settings
|
||||||
│ │ ├── public/ # CampaignsListPage, CampaignPage, ResponseWallPage, MapPage, ShiftsPage, LandingPage, MediaGalleryPage, MediaViewerPage
|
│ │ ├── social/ # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges
|
||||||
│ │ ├── media/ # LibraryPage, SharedMediaPage, MediaJobsPage, AnalyticsDashboardPage
|
│ │ ├── sms/ # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup
|
||||||
│ │ ├── services/ # MiniQRPage, MailHogPage, CodeEditorPage, N8nPage, GiteaPage, NocoDBPage
|
│ │ ├── events/ # Ticketed Events, Event Detail, Check-in Scanner
|
||||||
│ │ └── (root) # DashboardPage, UsersPage, SettingsPage, CanvassDashboardPage, WalkSheetPage, CutExportPage, LandingPagesPage, PageEditorPage, EmailTemplatesPage, ListmonkPage, PangolinPage, ObservabilityPage
|
│ │ ├── volunteer/ # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements
|
||||||
│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand)
|
│ │ ├── public/ # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet
|
||||||
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios)
|
│ │ └── (root) # Dashboard, Users, Settings, Docs*, MeetingPlanner, Observability, etc.
|
||||||
|
│ ├── stores/ # 9 Zustand stores (auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking)
|
||||||
|
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts, nav-defaults.ts, service-url.ts, y-textarea.ts
|
||||||
│ ├── hooks/ # useDebounce, useLocalStorage
|
│ ├── hooks/ # useDebounce, useLocalStorage
|
||||||
│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces)
|
│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces)
|
||||||
│
|
│
|
||||||
├── media-manager/ # Legacy media manager (reference)
|
├── mcp-server/ # Claude Code MCP server (27 core + 6 on-demand packs (~65 tools))
|
||||||
├── nginx/ # Reverse proxy config (subdomain routing + CSP)
|
├── nginx/ # Reverse proxy config (subdomain routing + CSP)
|
||||||
├── configs/ # Prometheus, Grafana, Alertmanager configs
|
├── configs/ # Prometheus, Grafana, Alertmanager, Pangolin 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
|
||||||
│ └── backup.sh # PostgreSQL + Listmonk + uploads backup
|
│ ├── update-env.sh # Merge new variables from .env.example into existing .env
|
||||||
├── docker-compose.yml # V2 orchestration (20+ services)
|
│ ├── backup.sh / restore.sh # PostgreSQL + Listmonk + uploads backup/restore
|
||||||
├── docker-compose.v1.yml # V1 backup (reference)
|
│ ├── validate-env.sh # Required env variable validation
|
||||||
|
│ ├── validate-compose-parity.sh # Check docker-compose.yml ↔ docker-compose.prod.yml parity
|
||||||
|
│ ├── test-deployment.sh # Post-deploy smoke tests (auth, services, health)
|
||||||
|
│ ├── register-with-ccp.sh # Register instance with a Control Panel via invite code
|
||||||
|
│ ├── ccp-deregister.sh # Deregister instance from its CCP
|
||||||
|
│ ├── pangolin-teardown.sh # Delete Pangolin resources/sites (dry-run by default)
|
||||||
|
│ ├── gitea-init.sh # Bootstrap Gitea admin user + SSO app
|
||||||
|
│ ├── nocodb-init.sh # Bootstrap NocoDB project + base connection
|
||||||
|
│ ├── mkdocs-entrypoint.sh # MkDocs container entrypoint (live + built modes)
|
||||||
|
│ ├── mkdocs-build-trigger.py # Trigger MkDocs rebuild from API hooks
|
||||||
|
│ ├── legacy/ # Archived Cloudflare tunnel configs (pre-Pangolin)
|
||||||
|
│ └── systemd/ # Systemd unit files (backup timer, upgrade watcher)
|
||||||
|
├── docker-compose.yml # V2 orchestration (40+ services)
|
||||||
|
├── docker-compose.prod.yml # Production (image-only, no source mounts)
|
||||||
├── .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
|
||||||
```
|
```
|
||||||
@ -238,27 +280,35 @@ 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
|
||||||
|
|
||||||
Enable optional features in `.env`:
|
Most features are toggled via **SiteSettings** in the database (admin Settings page). Some also have `.env` overrides:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Media Manager
|
# .env feature flags (env-level)
|
||||||
ENABLE_MEDIA_FEATURES=true
|
ENABLE_MEDIA_FEATURES=true # Media manager
|
||||||
|
ENABLE_HLS_TRANSCODE=true # HLS adaptive bitrate transcoding (off by default)
|
||||||
# Listmonk Newsletter Sync
|
ENABLE_PAYMENTS=true # Stripe integration
|
||||||
LISTMONK_SYNC_ENABLED=true
|
ENABLE_SMS=true # SMS campaigns
|
||||||
|
ENABLE_CHAT=true # Rocket.Chat
|
||||||
# Email Test Mode (sends to MailHog instead of SMTP)
|
ENABLE_MEET=true # Jitsi meetings
|
||||||
EMAIL_TEST_MODE=true
|
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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
@ -272,7 +322,6 @@ 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
|
||||||
@ -295,7 +344,6 @@ 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
|
||||||
@ -442,9 +490,13 @@ 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, video analytics service
|
- `api/src/modules/media/services/` — FFprobe, thumbnail, **HLS transcode** services
|
||||||
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload
|
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload, **HLS streaming**
|
||||||
- `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
|
||||||
@ -452,7 +504,15 @@ 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
|
**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts, **HLS adaptive bitrate streaming (360p/720p/1080p, MP4 fallback)**.
|
||||||
|
|
||||||
|
**HLS adaptive bitrate streaming:**
|
||||||
|
- On upload, a BullMQ `hls-transcode` job runs FFmpeg to produce a master playlist + 3 keyframe-aligned variants under `/media/local/hls/{videoId}/`. Concurrency is 1; the worker runs in-process with the media-api Fastify server.
|
||||||
|
- Player prefers HLS over MP4 when `Video.hlsStatus === 'READY'`. MP4 streaming routes stay as the always-on fallback for un-transcoded videos and for hover-preview cards (where 200ms hls.js init defeats the UX — `PublicVideoCard` stays MP4).
|
||||||
|
- `useHls()` hook lazy-imports hls.js (~75 KB gzipped, never enters main bundle), uses native HLS on Safari/iOS, gives up after 2 NETWORK_ERROR retries so the MP4 fallback can kick in.
|
||||||
|
- Manifest URLs are HMAC-signed (`?sig=&exp=&uid=`) per existing `signMediaPath()` pattern. Variant playlists rewrite their segment URIs server-side at fetch time so each segment carries a fresh signature.
|
||||||
|
- Feature flag: `ENABLE_HLS_TRANSCODE` (default `false`). When off, uploads are tagged `SKIPPED` and the player falls back to MP4 — fully reversible. The worker stays registered so existing `PENDING` jobs still process if the flag flips back on.
|
||||||
|
- Backfill: `docker compose exec api npm run backfill:hls` enqueues all `hlsStatus IS NULL` videos. Bypasses the flag (operator opt-in). At ~2 min per 1080p video, throughput is ~30/hour.
|
||||||
|
|
||||||
**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`
|
||||||
@ -471,10 +531,12 @@ 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/pangolin-setup.sh` — CLI wrapper for automated setup
|
- `scripts/register-with-ccp.sh` — Register this instance with a Control Panel (CCP) using an invite code
|
||||||
|
- `scripts/pangolin-teardown.sh` — Delete all Pangolin resources/sites for an org (dry-run by default, idempotent)
|
||||||
|
- `scripts/ccp-deregister.sh` — Deregister instance from its CCP
|
||||||
- `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 (creates site, updates .env, restarts Newt)
|
- **Automated setup:** One-command deployment via CCP registration (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:**
|
||||||
@ -513,20 +575,25 @@ 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 (Drizzle) |
|
| 4100 | Fastify Media API | Video library (Prisma) |
|
||||||
| 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 |
|
| 3030 | Gitea | Git hosting + SSO |
|
||||||
|
| 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 |
|
||||||
@ -551,11 +618,17 @@ 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 |
|
| `git.cmlite.org` | Gitea (3030) | Git hosting + SSO |
|
||||||
| `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.
|
||||||
@ -564,7 +637,7 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
|
|||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
|
|
||||||
**Note:** See `MEMORY.md` for comprehensive development patterns, gotchas, and lessons learned. Below are V2-specific patterns only.
|
**Note:** Below are the key development patterns for this project.
|
||||||
|
|
||||||
### API Router Structure
|
### API Router Structure
|
||||||
- Service layer (`*.service.ts`) — business logic, database queries
|
- Service layer (`*.service.ts`) — business logic, database queries
|
||||||
@ -579,47 +652,57 @@ 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/` (AppLayout)
|
- Admin pages: `admin/src/pages/` + subdirs (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: `auth.store.ts`, `canvass.store.ts`
|
- Zustand stores (9): auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking
|
||||||
- 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 ORMs
|
### Database ORM
|
||||||
- **Prisma** (main API): Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays
|
- **Prisma** (both APIs): 192 models in single `schema.prisma`. 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:** 14 migrations in `api/prisma/migrations/` fully cover the schema (baseline catch-up applied Feb 2026)
|
- **Migration history:** 50 migrations in `api/prisma/migrations/` fully cover the schema
|
||||||
- **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`)
|
||||||
|
|
||||||
### V2-Specific Gotchas
|
### Key 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`
|
||||||
- See MEMORY.md "Common Gotchas" for additional gotchas (ports, volumes, media upload, registry, etc.)
|
- **`!` in passwords** triggers bash history expansion — use Write tool to write JSON to file, then `curl -d @file`
|
||||||
|
- **Port mappings:** API container 4000 → host 4002, Admin container 3000 → host 3002
|
||||||
|
- **BullMQ** needs its own Redis connections (pass URL string, not shared ioredis instance)
|
||||||
|
- **Public pages** use `axios` directly (no auth interceptor), admin pages use `{ api }` from lib
|
||||||
|
- **Prisma JSON fields:** typed arrays need `as unknown as Prisma.InputJsonValue` cast
|
||||||
|
- **nginx conf.d files** have `.template` counterparts used by envsubst at startup
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security & Configuration
|
## Security & Configuration
|
||||||
|
|
||||||
### Security Audit
|
### Security Audits
|
||||||
Comprehensive security audit completed 2025-02-11, addressing 13 findings. See `SECURITY_AUDIT_2025-02-11.md` for full report.
|
Four security audits completed. See audit reports for full details:
|
||||||
|
- **Feb 2025:** 13 findings (password policy, rate limits, token rotation, XSS prevention). `SECURITY_AUDIT_2025-02-11.md`
|
||||||
|
- **Mar 22 2026:** JWT algorithm lockdown, invite secret separation, webhook hardening, CSV injection, QR DoS
|
||||||
|
- **Mar 27 2026:** 33 findings (30 fixed) — IDOR, XSS, path traversal, MongoDB auth, SSTI, open redirect
|
||||||
|
- **Mar 30 2026:** 19 findings — IDOR action items/ticketed events, nginx rate limit, JWT secret reuse
|
||||||
|
|
||||||
**Key improvements:**
|
**Key security features:**
|
||||||
- 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)
|
- Rate limits on auth endpoints (10/min per IP) + nginx rate limiting
|
||||||
- Refresh token rotation (atomic transaction)
|
- Refresh token rotation (atomic Prisma 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)
|
- XSS/injection prevention (HTML escaping, DOMPurify, SSTI protection)
|
||||||
- Path traversal protection
|
- Path traversal protection (resolve + startsWith checks)
|
||||||
- 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)
|
- Nginx security headers (HSTS, Permissions-Policy, CSP, X-Forwarded-For)
|
||||||
|
- 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:
|
||||||
@ -642,8 +725,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 betteredmonton.org
|
# Example for cmlite.org
|
||||||
CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost
|
CORS_ORIGINS=http://app.cmlite.org,https://app.cmlite.org,http://localhost:3000,http://localhost
|
||||||
|
|
||||||
# Also set production mode
|
# Also set production mode
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@ -672,18 +755,16 @@ docker compose restart api
|
|||||||
4. Save changes
|
4. Save changes
|
||||||
|
|
||||||
**Critical resources to fix first:**
|
**Critical resources to fix first:**
|
||||||
- `api.betteredmonton.org` - Main API (all endpoints fail without this)
|
- `api.${DOMAIN}` - Main API (all endpoints fail without this)
|
||||||
- `app.betteredmonton.org` - Admin GUI + public pages
|
- `app.${DOMAIN}` - Admin GUI + public pages
|
||||||
- `media.betteredmonton.org` - Media API
|
- `media.${DOMAIN}` - Media API
|
||||||
|
|
||||||
**Verification:**
|
**Verification:**
|
||||||
```bash
|
```bash
|
||||||
# Should return JSON, NOT a 302 redirect
|
# Should return JSON, NOT a 302 redirect
|
||||||
curl https://api.betteredmonton.org/api/health
|
curl https://api.cmlite.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.
|
||||||
@ -702,29 +783,46 @@ 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 archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two independent Express apps using NocoDB REST API. See individual README files for V1 documentation:
|
V1 code has been removed from the repo. History preserved as `v1-archive` git tag. `docker-compose.v1.yml` remains as reference only.
|
||||||
- `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, 20+ services)
|
- `docker-compose.yml` — Development orchestration (build blocks + source mounts, 40+ 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 (30+ Prisma models)
|
- `api/prisma/schema.prisma` — Main schema (192 Prisma models)
|
||||||
- `api/prisma/migrations/` — 14 migration files (fully cover schema as of Feb 2026)
|
- `api/prisma/migrations/` — 50 migration files (full schema history)
|
||||||
- `api/drizzle.config.ts` — Drizzle config for media tables
|
|
||||||
- `api/prisma/seed.ts` — Database seeding
|
- `api/prisma/seed.ts` — Database seeding
|
||||||
|
|
||||||
### Nginx
|
### Nginx
|
||||||
@ -742,5 +840,5 @@ V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two indep
|
|||||||
### 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` — Security audit report
|
- `SECURITY_AUDIT_2025-02-11.md` — Initial security audit report
|
||||||
- `MEMORY.md` — Development patterns and gotchas
|
- `.mcp.json` — MCP server configuration for Claude Code
|
||||||
|
|||||||
@ -33,8 +33,9 @@ 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 4 production images, pushes to Gitea registry │
|
│ Builds 5 production images, pushes to Gitea registry │
|
||||||
│ (api, admin, media-api, nginx) tagged :SHA + :latest │
|
│ (api, admin, media-api, nginx, ccp-agent) │
|
||||||
|
│ 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 │
|
||||||
@ -43,7 +44,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──────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────┴───────────┐
|
┌───────────┴───────────┐
|
||||||
▼ ▼
|
▼ ▼
|
||||||
@ -98,7 +99,7 @@ After code changes are tested locally:
|
|||||||
./scripts/build-and-push.sh
|
./scripts/build-and-push.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
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}`:
|
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}`:
|
||||||
|
|
||||||
| Service | Dockerfile | What it produces |
|
| Service | Dockerfile | What it produces |
|
||||||
|---------|-----------|-----------------|
|
|---------|-----------|-----------------|
|
||||||
@ -106,6 +107,7 @@ This builds **4 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
|
||||||
@ -157,8 +159,17 @@ 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)
|
||||||
@ -260,9 +271,10 @@ 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 4 images
|
./scripts/build-and-push.sh # Build + push 5 images
|
||||||
./scripts/mirror-images.sh # Mirror 36 third-party images
|
./scripts/mirror-images.sh # Mirror 36 third-party images
|
||||||
./scripts/build-release.sh --tag v2.2.0 --upload # Package + upload release
|
git tag --sort=-v:refname | head -3 # Check latest version tags
|
||||||
|
./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)
|
||||||
@ -276,13 +288,46 @@ 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 `v2` branch
|
1. [ ] All code changes committed and pushed to `main` branch
|
||||||
2. [ ] `docker compose up -d` works locally (smoke test)
|
2. [ ] `docker compose up -d` works locally (smoke test)
|
||||||
3. [ ] `./scripts/build-and-push.sh` — builds and pushes 4 production images
|
3. [ ] **Determine version tag:**
|
||||||
4. [ ] `./scripts/mirror-images.sh` — only if third-party versions changed
|
```bash
|
||||||
5. [ ] `./scripts/build-release.sh --tag vX.Y.Z --upload` — packages and uploads tarball
|
# Check the latest existing tag to pick the next version
|
||||||
6. [ ] Test clean install: `tar xzf ... && cd changemaker-lite && bash config.sh && docker compose up -d`
|
git tag --sort=-v:refname | head -5
|
||||||
7. [ ] Test upgrade: `./scripts/upgrade.sh` on an existing installation
|
# Check commits since the last tag
|
||||||
8. [ ] Verify: `curl http://localhost:4000/api/health` returns `{"status":"ok"}`
|
git log $(git tag --sort=-v:refname | head -1)..HEAD --oneline
|
||||||
|
```
|
||||||
|
4. [ ] `./scripts/build-and-push.sh` — builds and pushes 5 production images
|
||||||
|
5. [ ] `./scripts/mirror-images.sh` — only if third-party versions changed
|
||||||
|
6. [ ] `./scripts/build-release.sh --tag vX.Y.Z --upload` — packages and uploads tarball
|
||||||
|
7. [ ] **Add release notes** (via Gitea web UI or API):
|
||||||
|
```bash
|
||||||
|
# Update release body via API (use GITEA_REGISTRY_API_TOKEN, not GITEA_API_TOKEN)
|
||||||
|
GITEA_TOKEN=$(grep -oP 'GITEA_REGISTRY_API_TOKEN=\K.*' .env)
|
||||||
|
# Find release ID
|
||||||
|
curl -s "https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases?limit=1" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" | python3 -c "import sys,json; r=json.load(sys.stdin)[0]; print(f'ID: {r[\"id\"]}, Tag: {r[\"tag_name\"]}')"
|
||||||
|
# Update with release notes (write JSON body to /tmp/release-notes.json first)
|
||||||
|
curl -s -X PATCH "https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases/RELEASE_ID" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @/tmp/release-notes.json
|
||||||
|
```
|
||||||
|
8. [ ] Test clean install: `tar xzf ... && cd changemaker-lite && bash config.sh && docker compose up -d`
|
||||||
|
9. [ ] Test upgrade: `./scripts/upgrade.sh` on an existing installation
|
||||||
|
10. [ ] Verify: `curl http://localhost:4000/api/health` returns `{"status":"ok"}`
|
||||||
|
|||||||
@ -1,257 +0,0 @@
|
|||||||
# 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
|
|
||||||
24
README.md
24
README.md
@ -103,15 +103,22 @@ Send SMS campaigns via an Android bridge, sync subscribers to Listmonk for newsl
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Production (pre-built images)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One-command install (downloads pre-built images, runs config wizard)
|
# 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
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
|
|
||||||
cd ~/changemaker.lite
|
# 2. Start services (first pull ~3 min + ~90s stabilization)
|
||||||
docker compose up -d
|
cd ~/changemaker.lite && docker compose up -d
|
||||||
|
|
||||||
|
# 3. Verify the install
|
||||||
|
bash scripts/test-deployment.sh --wait 60
|
||||||
```
|
```
|
||||||
|
|
||||||
Or clone and build from source:
|
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
|
||||||
git clone <repo-url> changemaker.lite
|
git clone <repo-url> changemaker.lite
|
||||||
@ -127,6 +134,15 @@ 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/validate-env.sh # re-check .env + host ports
|
||||||
|
bash scripts/test-deployment.sh # full deployment health sweep
|
||||||
|
bash scripts/pangolin-teardown.sh # wipe tunnel org before reinstall (dry-run by default)
|
||||||
|
bash scripts/ccp-deregister.sh # deregister from Changemaker Control Panel (dry-run by default)
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
**Full documentation is available at [cmlite.org/docs/getting-started](https://cmlite.org/docs/getting-started/).**
|
**Full documentation is available at [cmlite.org/docs/getting-started](https://cmlite.org/docs/getting-started/).**
|
||||||
|
|||||||
@ -1,161 +0,0 @@
|
|||||||
# Service Integrations — EventBus Architecture
|
|
||||||
|
|
||||||
Tracking document for the platform-wide EventBus and service integration work.
|
|
||||||
|
|
||||||
**Started:** 2026-03-30
|
|
||||||
**Branch:** v2
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Changemaker Lite has 30+ services but most operate as isolated tools. The EventBus provides a centralized, typed, in-process pub/sub system that decouples event producers from consumers.
|
|
||||||
|
|
||||||
```
|
|
||||||
Service Handler (shift created, donation completed, etc.)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
eventBus.publish('shift.created', payload)
|
|
||||||
|
|
|
||||||
+-- ListmonkListener (newsletter sync)
|
|
||||||
+-- RocketChatListener (team notifications)
|
|
||||||
+-- CrmActivityListener (contact timeline)
|
|
||||||
+-- CalendarSyncListener (unified calendar)
|
|
||||||
+-- N8nWebhookListener (external automation)
|
|
||||||
+-- GancioSyncListener (public event calendar)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why In-Process EventEmitter (not Redis PubSub)
|
|
||||||
|
|
||||||
- Single Express process — no distributed coordination needed
|
|
||||||
- Zero serialization overhead (pass JS objects directly)
|
|
||||||
- Data already persisted in DB — events are ephemeral notifications
|
|
||||||
- Matches the existing fire-and-forget pattern used by Listmonk/RC services
|
|
||||||
- Can be swapped to Redis PubSub later if we go multi-process
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `api/src/types/events.ts` | Typed event catalog (all event names + payloads) |
|
|
||||||
| `api/src/services/event-bus.service.ts` | Core EventBus (publish/subscribe/stats) |
|
|
||||||
| `api/src/services/event-listeners/listmonk.listener.ts` | Listmonk newsletter sync |
|
|
||||||
| `api/src/services/event-listeners/rocketchat.listener.ts` | Rocket.Chat notifications |
|
|
||||||
| `api/src/services/event-listeners/crm-activity.listener.ts` | CRM ContactActivity writer |
|
|
||||||
| `api/src/services/event-listeners/calendar-sync.listener.ts` | Calendar unification |
|
|
||||||
| `api/src/services/event-listeners/n8n-webhook.listener.ts` | n8n automation bridge |
|
|
||||||
| `api/src/services/event-listeners/gancio.listener.ts` | Gancio event sync (shifts + ticketed events) |
|
|
||||||
| `api/src/services/event-listeners/engagement-scoring.listener.ts` | Contact engagement scores (Redis ZSET) |
|
|
||||||
| `api/src/services/event-listeners/homepage-stats.listener.ts` | Homepage real-time counters + cache invalidation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Progress Tracker
|
|
||||||
|
|
||||||
### Phase 1: Core Infrastructure
|
|
||||||
- [x] Explore existing event patterns (Listmonk, RC, Gancio, provisioning)
|
|
||||||
- [x] Design EventBus architecture
|
|
||||||
- [x] Implement EventBus service (`api/src/services/event-bus.service.ts`)
|
|
||||||
- [x] Define typed event catalog (`api/src/types/events.ts` — 46 events across 14 modules)
|
|
||||||
- [x] Register EventBus in server.ts startup
|
|
||||||
- [x] Add EventBus stats endpoint (`GET /api/observability/event-bus`)
|
|
||||||
|
|
||||||
### Phase 2: Migrate Existing Integrations
|
|
||||||
- [x] Listmonk event sync → EventBus listener (9 event subscriptions)
|
|
||||||
- [x] Rocket.Chat webhook service → EventBus listener (4 event subscriptions)
|
|
||||||
- [x] Gancio shift/event sync → EventBus listener (3 event subscriptions)
|
|
||||||
|
|
||||||
### Phase 3: New Listeners
|
|
||||||
- [x] CRM Activity auto-generation listener (11 event subscriptions)
|
|
||||||
- [x] Calendar sync listener (8 event subscriptions)
|
|
||||||
- [x] n8n webhook emitter listener (wildcard subscription, forwards all events)
|
|
||||||
- [x] Listmonk webhook receiver (inbound: open, click, bounce, unsubscribe → EventBus)
|
|
||||||
|
|
||||||
### Phase 4: Wire Up Publishers (migrated from inline calls)
|
|
||||||
- [x] Shift CRUD + signup (shift.created/updated/deleted, shift.signup.created/cancelled)
|
|
||||||
- [x] Canvass session complete + visits (canvass.session.completed, contact.address.updated)
|
|
||||||
- [x] Response submit (response.submitted)
|
|
||||||
- [x] Campaign email sent (campaign.email.sent)
|
|
||||||
- [x] Payment/donation/subscription events (3 event types)
|
|
||||||
- [x] Contact tag changes (contact.tags.changed — 3 call sites)
|
|
||||||
- [x] Reengagement sent (reengagement.sent)
|
|
||||||
- [x] Campaign CRUD + publish + moderation (campaign.created/updated/deleted/published/status.changed)
|
|
||||||
- [x] User create/update/delete/approve (user.created/updated/deleted/approved)
|
|
||||||
- [x] SMS campaign start/complete + message send/receive (4 event types)
|
|
||||||
- [x] Media video publish/unpublish/view (3 event types)
|
|
||||||
- [x] Ticketed event publish/cancel (EventBus publishes alongside existing Gancio calls)
|
|
||||||
- [x] Impact story publish (social.impact-story.published)
|
|
||||||
- [x] Meeting create/delete (jitsi.routes.ts — meeting.created, meeting.deleted)
|
|
||||||
|
|
||||||
### Phase 4b: Extended Listeners (2026-03-31)
|
|
||||||
- [x] RC listener: +7 subscriptions (campaign.published, donations, subscriptions, SMS escalation, user.approved, video.published, ticketed-event.published)
|
|
||||||
- [x] CRM listener: +2 subscriptions (subscription activated, email bounced)
|
|
||||||
- [x] RC webhook service: +7 new formatter methods
|
|
||||||
- [x] Prisma migration: SHIFT, MEETING, TICKETED_EVENT added to CalendarItemSource enum
|
|
||||||
- [x] Calendar sync listener: uses proper source types (SHIFT, MEETING, TICKETED_EVENT)
|
|
||||||
|
|
||||||
### Phase 4c: New Data Listeners (2026-03-31)
|
|
||||||
- [x] Engagement scoring listener (11 subscriptions, Redis ZSET leaderboard)
|
|
||||||
- [x] Homepage stats listener (12 subscriptions, Redis counters + recent activity)
|
|
||||||
- [x] GET /api/homepage/live-stats endpoint (public, real-time counters + recent)
|
|
||||||
- [x] GET /api/observability/engagement-leaderboard endpoint (admin, top contacts)
|
|
||||||
|
|
||||||
### Phase 5: Future
|
|
||||||
- [ ] Migrate meeting-planner Gancio calls to EventBus (blocked: synchronous return value needed)
|
|
||||||
- [ ] Homepage service: swap COUNT queries for Redis counters in getStats()
|
|
||||||
- [ ] Engagement score materialization: periodic job to denormalize scores to Contact model
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Event Catalog
|
|
||||||
|
|
||||||
### Currently Wired (11 event points, 3 consumers)
|
|
||||||
|
|
||||||
| Event | Listmonk | Rocket.Chat | Gancio |
|
|
||||||
|-------|----------|-------------|--------|
|
|
||||||
| shift.signup | yes | yes | - |
|
|
||||||
| shift.signup.cancelled | - | yes | - |
|
|
||||||
| shift.created | - | - | yes |
|
|
||||||
| shift.updated | - | - | yes |
|
|
||||||
| shift.deleted | - | - | yes |
|
|
||||||
| canvass.session.completed | yes | yes | - |
|
|
||||||
| canvass.address.updated | yes | - | - |
|
|
||||||
| campaign.email.sent | yes | - | - |
|
|
||||||
| response.submitted | - | yes | - |
|
|
||||||
| subscription.activated | yes | - | - |
|
|
||||||
| donation.completed | yes | - | - |
|
|
||||||
| product.purchased | yes | - | - |
|
|
||||||
| contact.tags.changed | yes | - | - |
|
|
||||||
| reengagement.sent | yes | - | - |
|
|
||||||
|
|
||||||
### New Events (49+ handlers need publishers)
|
|
||||||
|
|
||||||
| Event | CRM Activity | Calendar | RC | n8n |
|
|
||||||
|-------|-------------|----------|-----|-----|
|
|
||||||
| campaign.created | - | - | - | yes |
|
|
||||||
| campaign.published | - | - | yes | yes |
|
|
||||||
| campaign.status.changed | - | - | yes | yes |
|
|
||||||
| user.approved | - | - | yes | yes |
|
|
||||||
| user.created | - | - | - | yes |
|
|
||||||
| video.published | - | - | yes | yes |
|
|
||||||
| video.viewed | yes | - | - | - |
|
|
||||||
| sms.message.received | yes | - | yes* | yes |
|
|
||||||
| sms.campaign.completed | - | - | yes | yes |
|
|
||||||
| ticketed-event.published | - | yes | - | yes |
|
|
||||||
| meeting.created | - | yes | - | - |
|
|
||||||
| impact-story.published | - | - | yes | yes |
|
|
||||||
| shift.created | - | yes | - | yes |
|
|
||||||
| donation.completed | yes | - | yes | yes |
|
|
||||||
| subscription.activated | yes | - | - | yes |
|
|
||||||
|
|
||||||
*SMS escalations (QUESTION/NEGATIVE sentiment) to relevant RC channel
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Decisions
|
|
||||||
|
|
||||||
1. **Listeners self-guard**: Each listener checks its own feature flag (ENABLE_CHAT, LISTMONK_SYNC_ENABLED, etc.) — the EventBus doesn't filter
|
|
||||||
2. **Error isolation**: Each listener wraps its handler in try-catch; one listener failing doesn't affect others
|
|
||||||
3. **No persistence**: Events are ephemeral — if the server restarts mid-event, it's lost (data is already in DB)
|
|
||||||
4. **Stats tracking**: EventBus tracks per-event emission counts + per-listener execution counts for observability
|
|
||||||
5. **Wildcard subscriptions**: Listeners can subscribe to `shift.*` to catch all shift events
|
|
||||||
8
admin/.vite/deps/_metadata.json
Normal file
8
admin/.vite/deps/_metadata.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "46070c3d",
|
||||||
|
"configHash": "70922fab",
|
||||||
|
"lockfileHash": "ee36a2d0",
|
||||||
|
"browserHash": "5aa32ba6",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
admin/.vite/deps/package.json
Normal file
3
admin/.vite/deps/package.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
13
admin/package-lock.json
generated
13
admin/package-lock.json
generated
@ -33,9 +33,11 @@
|
|||||||
"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",
|
||||||
@ -2633,6 +2635,12 @@
|
|||||||
"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",
|
||||||
@ -2722,6 +2730,11 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -34,9 +34,11 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -43,8 +43,13 @@ 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 DocsMetadataPage from '@/pages/DocsMetadataPage';
|
||||||
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
||||||
@ -62,6 +67,11 @@ 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';
|
||||||
@ -100,6 +110,7 @@ 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,
|
||||||
@ -113,6 +124,7 @@ import {
|
|||||||
SOCIAL_ROLES,
|
SOCIAL_ROLES,
|
||||||
SYSTEM_ROLES,
|
SYSTEM_ROLES,
|
||||||
POLLS_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';
|
||||||
@ -134,6 +146,9 @@ 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 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';
|
||||||
@ -172,7 +187,7 @@ function RoleAwareRedirect() {
|
|||||||
|
|
||||||
function NavigateToCutMap() {
|
function NavigateToCutMap() {
|
||||||
const { cutId } = useParams<{ cutId: string }>();
|
const { cutId } = useParams<{ cutId: string }>();
|
||||||
return <Navigate to={`/volunteer?cutId=${cutId}`} replace />;
|
return <Navigate to={`/volunteer/map?cutId=${cutId}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@ -240,6 +255,12 @@ 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>
|
||||||
@ -352,9 +373,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, default landing page */}
|
{/* Volunteer map — full-screen (moved from /volunteer to /volunteer/map) */}
|
||||||
<Route
|
<Route
|
||||||
path="/volunteer"
|
path="/volunteer/map"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<VolunteerMapPage />
|
<VolunteerMapPage />
|
||||||
@ -380,6 +401,7 @@ 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 />} />
|
||||||
@ -399,6 +421,7 @@ 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>
|
||||||
|
|
||||||
@ -574,6 +597,30 @@ 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
|
<Route
|
||||||
path="influence/straw-polls"
|
path="influence/straw-polls"
|
||||||
element={
|
element={
|
||||||
@ -582,6 +629,30 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</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={
|
||||||
@ -807,6 +878,14 @@ 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={
|
||||||
@ -815,6 +894,46 @@ 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={
|
||||||
|
|||||||
@ -72,6 +72,7 @@ import {
|
|||||||
PAYMENTS_ROLES,
|
PAYMENTS_ROLES,
|
||||||
SOCIAL_ROLES,
|
SOCIAL_ROLES,
|
||||||
POLLS_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';
|
||||||
@ -186,8 +187,13 @@ 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' }] : []),
|
...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -326,6 +332,20 @@ 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 />,
|
||||||
@ -333,6 +353,7 @@ 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' },
|
||||||
@ -638,7 +659,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);
|
||||||
@ -647,11 +668,14 @@ 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');
|
window.open(resolveNavUrl(item.path), '_blank', 'noopener,noreferrer');
|
||||||
} else if (item.external && item.id === 'home') {
|
} else if (item.external && item.id === 'home') {
|
||||||
window.open(buildHomeUrl(), '_blank');
|
window.open(buildHomeUrl(), '_blank', 'noopener,noreferrer');
|
||||||
} else if (item.external) {
|
} else if (item.external) {
|
||||||
window.open(item.path, '_blank');
|
// Only open http/https URLs to prevent javascript: URI injection
|
||||||
|
if (item.path.startsWith('http://') || item.path.startsWith('https://')) {
|
||||||
|
window.open(item.path, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
navigate(item.path);
|
navigate(item.path);
|
||||||
}
|
}
|
||||||
@ -673,7 +697,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)}>
|
||||||
{!isMobile && !collapsed && item.label}
|
{!collapsed && item.label}
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
@ -686,23 +710,25 @@ export default function AppLayout() {
|
|||||||
icon={getIcon(item.icon)}
|
icon={getIcon(item.icon)}
|
||||||
onClick={() => handleItemClick(item)}
|
onClick={() => handleItemClick(item)}
|
||||||
>
|
>
|
||||||
{!isMobile && !collapsed && item.label}
|
{!collapsed && item.label}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
})()}
|
})()}
|
||||||
{/* Volunteer Portal button — always visible for quick switching */}
|
{/* Volunteer Portal button — always visible for quick switching */}
|
||||||
<Tooltip title="Switch to Volunteer Portal">
|
{!isMobile && (
|
||||||
<Button
|
<Tooltip title="Switch to Volunteer Portal">
|
||||||
type="text"
|
<Button
|
||||||
size="small"
|
type="text"
|
||||||
icon={<TeamOutlined />}
|
size="small"
|
||||||
onClick={() => navigate('/volunteer')}
|
icon={<TeamOutlined />}
|
||||||
>
|
onClick={() => navigate('/volunteer')}
|
||||||
{!isMobile && !collapsed && 'Volunteer'}
|
>
|
||||||
</Button>
|
{!collapsed && 'Volunteer'}
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</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 && (
|
||||||
|
|||||||
@ -22,11 +22,13 @@ 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',
|
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' | 'enablePolls'>;
|
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePetitions' | 'enablePolls' | 'enableAnalytics'>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
admin/src/components/MobilePageHeader.tsx
Normal file
47
admin/src/components/MobilePageHeader.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ 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,
|
||||||
@ -15,7 +16,8 @@ import {
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
const BASE_NAV_ITEMS = [
|
const BASE_NAV_ITEMS = [
|
||||||
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' },
|
{ key: '/volunteer', icon: HomeOutlined, label: 'Home' },
|
||||||
|
{ 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' },
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
HomeOutlined,
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
@ -14,6 +15,7 @@ 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';
|
||||||
@ -48,7 +50,8 @@ 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: <EnvironmentOutlined />, label: 'Map' },
|
{ key: '/volunteer', icon: <HomeOutlined />, label: 'Home' },
|
||||||
|
{ 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' },
|
||||||
@ -65,6 +68,9 @@ 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]);
|
||||||
|
|
||||||
@ -97,7 +103,7 @@ export default function VolunteerLayout() {
|
|||||||
|
|
||||||
<Content
|
<Content
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 800,
|
maxWidth: location.pathname === '/volunteer' ? 1280 : 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',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
@ -169,13 +169,20 @@ export default function CalendarItemModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onCancel}
|
onClose={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}
|
||||||
@ -454,26 +461,18 @@ export default function CalendarItemModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
|
{isEditing && onDelete && (
|
||||||
<div>
|
<div style={{ marginTop: 8 }}>
|
||||||
{isEditing && onDelete && (
|
<Button
|
||||||
<Button
|
danger
|
||||||
danger
|
icon={<DeleteOutlined />}
|
||||||
icon={<DeleteOutlined />}
|
onClick={onDelete}
|
||||||
onClick={onDelete}
|
>
|
||||||
>
|
Delete
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Space>
|
|
||||||
<Button onClick={onCancel}>Cancel</Button>
|
|
||||||
<Button type="primary" htmlType="submit" loading={loading}>
|
|
||||||
{isEditing ? 'Save Changes' : 'Create'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal, Form, Select, Checkbox, Slider, DatePicker, Switch,
|
Drawer, Form, Select, Checkbox, Slider, DatePicker, Switch,
|
||||||
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid,
|
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid, Space,
|
||||||
} 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,32 +152,34 @@ export default function ExportContactsModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title="Export Canvass Contacts to Campaign"
|
title="Export Canvass Contacts to Campaign"
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onClose={onClose}
|
||||||
width={isMobile ? '95vw' : 640}
|
width={isMobile ? '95vw' : 640}
|
||||||
footer={[
|
placement="right"
|
||||||
<Button key="cancel" onClick={onClose}>Cancel</Button>,
|
mask={false}
|
||||||
<Button
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
key="preview"
|
extra={
|
||||||
icon={<EyeOutlined />}
|
<Space>
|
||||||
onClick={handlePreview}
|
<Button
|
||||||
loading={previewing}
|
icon={<EyeOutlined />}
|
||||||
>
|
onClick={handlePreview}
|
||||||
Preview
|
loading={previewing}
|
||||||
</Button>,
|
>
|
||||||
<Button
|
Preview
|
||||||
key="export"
|
</Button>
|
||||||
type="primary"
|
<Button
|
||||||
icon={<ExportOutlined />}
|
type="primary"
|
||||||
onClick={handleExport}
|
icon={<ExportOutlined />}
|
||||||
loading={exporting}
|
onClick={handleExport}
|
||||||
disabled={!preview || preview.contactsWithEmail === 0}
|
loading={exporting}
|
||||||
>
|
disabled={!preview || preview.contactsWithEmail === 0}
|
||||||
Export
|
>
|
||||||
</Button>,
|
Export
|
||||||
]}
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" size="small">
|
<Form form={form} layout="vertical" size="small">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@ -294,6 +296,6 @@ export default function ExportContactsModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Drawer,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Space,
|
Space,
|
||||||
@ -150,7 +150,7 @@ export function AuthorsManagementModal({
|
|||||||
const authorEntries = Object.entries(localAuthors);
|
const authorEntries = Object.entries(localAuthors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<span>
|
<span>
|
||||||
<UserOutlined style={{ marginRight: 8 }} />
|
<UserOutlined style={{ marginRight: 8 }} />
|
||||||
@ -158,23 +158,23 @@ export function AuthorsManagementModal({
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onClose={onClose}
|
||||||
footer={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={onClose}>Close</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
onClick={handleSaveAll}
|
|
||||||
loading={saving}
|
|
||||||
disabled={!dirty}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
destroyOnHidden
|
|
||||||
width={560}
|
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}
|
{contextHolder}
|
||||||
|
|
||||||
@ -236,7 +236,7 @@ export function AuthorsManagementModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
admin/src/components/docs/DocsEditorToolbar.tsx
Normal file
129
admin/src/components/docs/DocsEditorToolbar.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@ 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';
|
||||||
@ -52,6 +53,7 @@ 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';
|
||||||
@ -259,6 +261,8 @@ 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,
|
||||||
@ -287,6 +291,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
|||||||
onContentChange,
|
onContentChange,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleModalOk,
|
handleModalOk,
|
||||||
|
handleMoveFile,
|
||||||
handleNewFileRoot,
|
handleNewFileRoot,
|
||||||
handleNewFolderRoot,
|
handleNewFolderRoot,
|
||||||
refreshTree,
|
refreshTree,
|
||||||
@ -430,6 +435,7 @@ 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;
|
||||||
@ -910,6 +916,14 @@ 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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
156
admin/src/components/docs/MoveToModal.tsx
Normal file
156
admin/src/components/docs/MoveToModal.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Modal, Form, Input, DatePicker, Select, Switch, message, theme } from 'antd';
|
import { Drawer, Form, Input, DatePicker, Select, Switch, Button, message, theme } from 'antd';
|
||||||
import { FileMarkdownOutlined } from '@ant-design/icons';
|
import { FileMarkdownOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -84,7 +84,7 @@ export function NewBlogPostModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<span>
|
<span>
|
||||||
<FileMarkdownOutlined style={{ marginRight: 8 }} />
|
<FileMarkdownOutlined style={{ marginRight: 8 }} />
|
||||||
@ -92,12 +92,17 @@ export function NewBlogPostModal({
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={handleClose}
|
onClose={handleClose}
|
||||||
onOk={handleSubmit}
|
|
||||||
okText="Create"
|
|
||||||
confirmLoading={submitting}
|
|
||||||
destroyOnHidden
|
|
||||||
width={480}
|
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}
|
{contextHolder}
|
||||||
<Form
|
<Form
|
||||||
@ -160,6 +165,6 @@ export function NewBlogPostModal({
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { Modal, Input, List, theme, Typography, Tag } from 'antd';
|
import { Drawer, 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,13 +62,15 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title="Insert Wiki Link"
|
title="Insert Wiki Link"
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={() => { onClose(); setSearch(''); }}
|
onClose={() => { 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..."
|
||||||
@ -148,6 +150,6 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
117
admin/src/components/docs/mkdocs-snippets.ts
Normal file
117
admin/src/components/docs/mkdocs-snippets.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
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: '' },
|
||||||
|
{ 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();
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography, Grid } from 'antd';
|
import { Drawer, 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 (
|
||||||
<Modal
|
<Drawer
|
||||||
title={`Send Test Email: ${template.name}`}
|
title={`Send Test Email: ${template.name}`}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onClose={onClose}
|
||||||
width={isMobile ? '95vw' : 900}
|
width={isMobile ? '95vw' : 900}
|
||||||
footer={[
|
placement="right"
|
||||||
<Button key="cancel" onClick={onClose}>
|
mask={false}
|
||||||
Cancel
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
</Button>,
|
extra={
|
||||||
<Button key="send" type="primary" loading={sending} onClick={handleSend}>
|
<Button 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>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
|
import { Drawer, 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,13 +152,19 @@ export default function AddToPlaylistModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title="Add to Playlist"
|
title="Add to Playlist"
|
||||||
open={open}
|
open={open}
|
||||||
onOk={handleSave}
|
onClose={onClose}
|
||||||
onCancel={onClose}
|
width={480}
|
||||||
confirmLoading={saving}
|
placement="right"
|
||||||
okText="Save"
|
mask={false}
|
||||||
|
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 }}>
|
||||||
@ -238,6 +244,6 @@ export default function AddToPlaylistModal({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,8 @@
|
|||||||
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 { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
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;
|
||||||
@ -19,6 +10,7 @@ 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
|
||||||
@ -35,9 +27,9 @@ export default function AlbumCard({ album, onClick }: AlbumCardProps) {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{coverUrl ? (
|
{coverUrl && signedCoverUrl ? (
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedUrl(coverUrl)}
|
src={signedCoverUrl}
|
||||||
alt={album.title}
|
alt={album.title}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@ -7,16 +7,26 @@ import {
|
|||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
|
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
|
||||||
|
|
||||||
/** Append JWT access token as query param for <img> src URLs */
|
function PhotoThumbnail({ url, alt }: { url: string; alt: string }) {
|
||||||
function getAuthenticatedUrl(url: string): string {
|
const signed = useSignedMediaUrl(url);
|
||||||
const { getAccessToken } = getAuthCallbacks();
|
if (!signed) {
|
||||||
const accessToken = getAccessToken();
|
return (
|
||||||
if (!accessToken) return url;
|
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4 }} aria-label={alt} />
|
||||||
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 {
|
||||||
@ -200,13 +210,7 @@ export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }:
|
|||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
photo.thumbnailUrl ? (
|
photo.thumbnailUrl ? (
|
||||||
<Image
|
<PhotoThumbnail url={photo.thumbnailUrl} alt={photo.title || photo.originalFilename || ''} />
|
||||||
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' }} />
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, Select, message } from 'antd';
|
import { Drawer, Select, Button, 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,13 +37,19 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
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}
|
||||||
onOk={handleOk}
|
onClose={onClose}
|
||||||
onCancel={onClose}
|
width={480}
|
||||||
confirmLoading={loading}
|
placement="right"
|
||||||
okText="Apply"
|
mask={false}
|
||||||
|
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
|
||||||
@ -54,6 +60,6 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
|
|||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
|
import { Drawer, 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,14 +113,19 @@ export default function BulkAddToPlaylistModal({
|
|||||||
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
|
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
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}
|
||||||
onOk={handleAdd}
|
onClose={onClose}
|
||||||
onCancel={onClose}
|
width={480}
|
||||||
confirmLoading={saving}
|
placement="right"
|
||||||
okText="Add"
|
mask={false}
|
||||||
okButtonProps={{ disabled: !selectedPlaylistId }}
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
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 }}>
|
||||||
@ -184,6 +189,6 @@ export default function BulkAddToPlaylistModal({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Modal, Form, Input, message } from 'antd';
|
import { Drawer, Form, Input, Button, message } from 'antd';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@ -41,13 +41,19 @@ export default function CreateAlbumModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title="Create Album"
|
title="Create Album"
|
||||||
open={open}
|
open={open}
|
||||||
onOk={handleCreate}
|
onClose={onClose}
|
||||||
onCancel={onClose}
|
width={480}
|
||||||
confirmLoading={loading}
|
placement="right"
|
||||||
okText="Create"
|
mask={false}
|
||||||
|
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' }]}>
|
||||||
@ -62,6 +68,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>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd';
|
import { Drawer, Form, Input, Switch, Button, message } from 'antd';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@ -53,17 +53,12 @@ export default function CreatePlaylistModal({
|
|||||||
}}
|
}}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={420}
|
width={420}
|
||||||
style={{ top: 64 }}
|
mask={false}
|
||||||
styles={{ body: { paddingTop: 24 } }}
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
||||||
<Button onClick={() => { form.resetFields(); onClose(); }}>
|
Create
|
||||||
Cancel
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
|
|||||||
@ -130,7 +130,8 @@ export default function EditPlaylistModal({
|
|||||||
}}
|
}}
|
||||||
placement="right"
|
placement="right"
|
||||||
width={isMobile ? '100%' : 520}
|
width={isMobile ? '100%' : 520}
|
||||||
style={{ top: 64 }}
|
mask={false}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@ -148,15 +148,9 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
|
|||||||
|
|
||||||
const connectSSE = async () => {
|
const connectSSE = async () => {
|
||||||
try {
|
try {
|
||||||
// Get auth token from localStorage
|
// Get auth token from in-memory store (not localStorage)
|
||||||
const stored = localStorage.getItem('auth-storage');
|
const { useAuthStore } = await import('@/stores/auth.store');
|
||||||
let token = '';
|
const token = useAuthStore.getState().accessToken || '';
|
||||||
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: {
|
||||||
|
|||||||
@ -8,18 +8,9 @@ import {
|
|||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
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;
|
||||||
@ -50,6 +41,7 @@ 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
|
||||||
@ -112,9 +104,9 @@ export default function PhotoCard({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{thumbnailUrl ? (
|
{thumbnailUrl && signedThumbnailUrl ? (
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedUrl(thumbnailUrl)}
|
src={signedThumbnailUrl}
|
||||||
alt={photo.title || photo.filename}
|
alt={photo.title || photo.filename}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@ -1,17 +1,8 @@
|
|||||||
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 { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
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;
|
||||||
@ -22,9 +13,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;
|
||||||
|
|
||||||
if (!photo) return null;
|
const adminImageUrl = photo ? `/media/photos/${photo.id}/image?size=large` : null;
|
||||||
|
const signedImageUrl = useSignedMediaUrl(adminImageUrl);
|
||||||
|
|
||||||
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`;
|
if (!photo) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -48,7 +40,7 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedUrl(adminImageUrl)}
|
src={signedImageUrl}
|
||||||
alt={photo.title || photo.filename}
|
alt={photo.title || photo.filename}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, DatePicker, Select, Space, Alert, Switch, message, Grid } from 'antd';
|
import { Drawer, DatePicker, Select, Space, Alert, Switch, Button, 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 (
|
||||||
<Modal
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<ClockCircleOutlined />
|
<ClockCircleOutlined />
|
||||||
@ -160,15 +160,16 @@ export default function SchedulePublishModal({
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onClose={onClose}
|
||||||
onOk={handleSchedule}
|
|
||||||
okText={publishNow ? 'Publish Now' : 'Schedule'}
|
|
||||||
confirmLoading={loading}
|
|
||||||
width={isMobile ? '95vw' : 600}
|
width={isMobile ? '95vw' : 600}
|
||||||
style={{ top: 20 }}
|
placement="right"
|
||||||
styles={{
|
mask={false}
|
||||||
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
}}
|
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 && (
|
||||||
@ -302,6 +303,6 @@ export default function SchedulePublishModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,19 +2,10 @@ 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 { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
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;
|
||||||
@ -48,6 +39,7 @@ 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);
|
||||||
@ -76,10 +68,10 @@ export default function VideoCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Thumbnail image or fallback */}
|
{/* Thumbnail image or fallback */}
|
||||||
{video.thumbnailUrl && !thumbnailError ? (
|
{video.thumbnailUrl && !thumbnailError && signedThumbnailUrl ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedUrl(video.thumbnailUrl)}
|
src={signedThumbnailUrl}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@ -2,6 +2,8 @@ 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;
|
||||||
@ -14,6 +16,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +71,13 @@ 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: () => {
|
||||||
@ -122,15 +133,6 @@ 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);
|
||||||
@ -157,10 +159,13 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// For admin, append token to stream/thumbnail URLs so <video>/<img> can access them
|
// For admin previews of unpublished media, sign stream/thumbnail URLs
|
||||||
|
// (the legacy ?token=<JWT> path was removed 2026-04-12). The HLS
|
||||||
|
// manifest URL is already signed server-side by the metadata route, so
|
||||||
|
// we leave it untouched.
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
if (data.streamUrl) data.streamUrl = appendToken(data.streamUrl);
|
if (data.streamUrl) data.streamUrl = await signedMediaUrl(data.streamUrl);
|
||||||
if (data.thumbnailUrl) data.thumbnailUrl = appendToken(data.thumbnailUrl);
|
if (data.thumbnailUrl) data.thumbnailUrl = await signedMediaUrl(data.thumbnailUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMetadata(data);
|
setMetadata(data);
|
||||||
@ -219,6 +224,10 @@ 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={{
|
||||||
@ -231,7 +240,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={metadata.streamUrl}
|
src={useMp4Src ? metadata.streamUrl : undefined}
|
||||||
poster={poster || metadata.thumbnailUrl || undefined}
|
poster={poster || metadata.thumbnailUrl || undefined}
|
||||||
autoPlay={autoplay}
|
autoPlay={autoplay}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
|
|||||||
@ -2,16 +2,8 @@ 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 { getAuthCallbacks } from '@/lib/api';
|
import { useSignedMediaUrl } from '@/lib/media-url';
|
||||||
|
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;
|
||||||
@ -24,6 +16,17 @@ 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) {
|
||||||
@ -175,7 +178,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
|
|||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)}
|
src={useMp4Src ? streamUrl : undefined}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Radio, InputNumber, Spin, Typography, Space, theme, Grid } from 'antd';
|
import { Drawer, Radio, InputNumber, Spin, Typography, Space, Button, 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,14 +80,23 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title="Insert Donate Block"
|
title="Insert Donate Block"
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onClose={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.
|
||||||
@ -176,6 +185,6 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
|
|||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Grid } from 'antd';
|
import { Drawer, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Button, 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')
|
axios.get('/api/payments/products', { params: { limit: 50 } })
|
||||||
.then(({ data }) => setProducts(data))
|
.then(({ data }) => setProducts(data.products))
|
||||||
.catch(() => setError('Failed to load products'))
|
.catch(() => setError('Failed to load products'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
@ -60,14 +60,19 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title="Insert Product Card"
|
title="Insert Product Card"
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onClose={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.
|
||||||
@ -148,6 +153,6 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
|
|||||||
})}
|
})}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,9 +21,9 @@ export function ProductWidget({ productSlug, buttonText = 'Buy Now' }: ProductWi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
axios.get('/api/payments/products')
|
axios.get('/api/payments/products', { params: { limit: 50 } })
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
const found = (data as Product[]).find(p => p.slug === productSlug);
|
const found = (data.products as Product[]).find(p => p.slug === productSlug);
|
||||||
if (found) {
|
if (found) {
|
||||||
setProduct(found);
|
setProduct(found);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Modal, Form, Input, Select, Switch, Button, Typography, message, Space } from 'antd';
|
import { Drawer, 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,13 +76,20 @@ export default function CreateUserFromContactModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title="Create User Account"
|
title="Create User Account"
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={() => { form.resetFields(); onClose(); }}
|
onClose={() => { 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>
|
||||||
@ -123,17 +130,12 @@ export default function CreateUserFromContactModal({
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
<Form.Item style={{ marginBottom: 0, display: 'none' }}>
|
||||||
<Space>
|
<Button type="primary" htmlType="submit">
|
||||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
Create Account
|
||||||
Create Account
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button onClick={() => { form.resetFields(); onClose(); }}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Drawer,
|
||||||
Select,
|
Select,
|
||||||
Typography,
|
Typography,
|
||||||
Radio,
|
Radio,
|
||||||
@ -155,7 +155,7 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<SwapOutlined />
|
<SwapOutlined />
|
||||||
@ -163,14 +163,13 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={handleClose}
|
onClose={handleClose}
|
||||||
width={700}
|
width={700}
|
||||||
footer={[
|
placement="right"
|
||||||
<Button key="cancel" onClick={handleClose}>
|
mask={false}
|
||||||
Cancel
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
</Button>,
|
extra={
|
||||||
<Button
|
<Button
|
||||||
key="merge"
|
|
||||||
type="primary"
|
type="primary"
|
||||||
danger
|
danger
|
||||||
onClick={handleMerge}
|
onClick={handleMerge}
|
||||||
@ -178,8 +177,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 }}>
|
||||||
@ -301,6 +300,6 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const roleColors: Record<UserRole, string> = {
|
|||||||
EVENTS_ADMIN: 'cyan',
|
EVENTS_ADMIN: 'cyan',
|
||||||
SOCIAL_ADMIN: 'magenta',
|
SOCIAL_ADMIN: 'magenta',
|
||||||
POLLS_ADMIN: 'geekblue',
|
POLLS_ADMIN: 'geekblue',
|
||||||
|
ANALYTICS_ADMIN: 'processing',
|
||||||
USER: 'blue',
|
USER: 'blue',
|
||||||
TEMP: 'default',
|
TEMP: 'default',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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');
|
window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank', 'noopener,noreferrer');
|
||||||
} 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 {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Tabs, Table, Button, Input, Tag, Form, Select, DatePicker, TimePicker, Space, Spin, Typography, message } from 'antd';
|
import { Drawer, Tabs, Table, Button, Input, Tag, Form, Select, DatePicker, TimePicker, Space, Spin, Typography, message } from 'antd';
|
||||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
|
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
|
||||||
@ -130,12 +130,14 @@ export function PollInsertModal({ open, onCancel, onInsert }: PollInsertModalPro
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onCancel}
|
onClose={onCancel}
|
||||||
title="Insert Scheduling Poll"
|
title="Insert Scheduling Poll"
|
||||||
footer={null}
|
|
||||||
width={700}
|
width={700}
|
||||||
|
placement="right"
|
||||||
|
mask={false}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
@ -232,6 +234,6 @@ export function PollInsertModal({ open, onCancel, onInsert }: PollInsertModalPro
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Modal, Input, Select, Typography, message, Space } from 'antd';
|
import { Drawer, Input, Select, Typography, message, Space, Button } from 'antd';
|
||||||
import { VideoCameraOutlined } from '@ant-design/icons';
|
import { VideoCameraOutlined } from '@ant-design/icons';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
@ -100,14 +100,19 @@ export default function RecommendVideoModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Drawer
|
||||||
title="Recommend a Video"
|
title="Recommend a Video"
|
||||||
open={open}
|
open={open}
|
||||||
onOk={handleSend}
|
onClose={onClose}
|
||||||
onCancel={onClose}
|
width={480}
|
||||||
okText="Send"
|
placement="right"
|
||||||
confirmLoading={sending}
|
mask={false}
|
||||||
okButtonProps={{ disabled: !selectedFriendId || !selectedVideoId }}
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={handleSend} loading={sending} disabled={!selectedFriendId || !selectedVideoId}>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<div>
|
<div>
|
||||||
@ -165,6 +170,6 @@ export default function RecommendVideoModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,81 @@
|
|||||||
|
import { Card, Progress, Typography, Tag, Alert } from 'antd';
|
||||||
|
import { TrophyOutlined } from '@ant-design/icons';
|
||||||
|
import type { DashboardActionCampaign } from './types';
|
||||||
|
|
||||||
|
interface ActionCampaignCardProps {
|
||||||
|
campaign: DashboardActionCampaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionCampaignCard({ campaign }: ActionCampaignCardProps) {
|
||||||
|
const percent = campaign.totalSteps > 0
|
||||||
|
? Math.round((campaign.completedSteps / campaign.totalSteps) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const nextStep = [...campaign.steps]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.find((s) => !s.completed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
styles={{
|
||||||
|
header: { borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '12px 20px', minHeight: 'auto' },
|
||||||
|
body: { padding: '16px 20px' },
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600 }}>
|
||||||
|
<TrophyOutlined style={{ marginRight: 8, color: '#faad14' }} />
|
||||||
|
Your Goal
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Title level={5} style={{ margin: '0 0 4px' }}>
|
||||||
|
{campaign.title}
|
||||||
|
</Typography.Title>
|
||||||
|
{campaign.description && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13, display: 'block', marginBottom: 12 }}>
|
||||||
|
{campaign.description}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{campaign.completedSteps} of {campaign.totalSteps} complete
|
||||||
|
</Typography.Text>
|
||||||
|
{campaign.rewardEarned && <Tag color="gold" style={{ margin: 0 }}>Reward Earned!</Tag>}
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={percent}
|
||||||
|
status={campaign.rewardEarned ? 'success' : 'active'}
|
||||||
|
showInfo={false}
|
||||||
|
strokeWidth={10}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{campaign.rewardEarned && campaign.rewardText && (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
message="You've earned your reward!"
|
||||||
|
description={campaign.rewardText}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!campaign.rewardEarned && campaign.rewardText && (
|
||||||
|
<Typography.Text style={{ fontSize: 12, display: 'block', marginTop: 10, color: 'rgba(255,255,255,0.5)' }}>
|
||||||
|
<TrophyOutlined style={{ marginRight: 4 }} />
|
||||||
|
{campaign.rewardText}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextStep && !campaign.rewardEarned && (
|
||||||
|
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<Typography.Text style={{ fontSize: 13 }}>
|
||||||
|
<strong>Next:</strong>{' '}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>{nextStep.label}</Typography.Text>
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
admin/src/components/volunteer/dashboard/ActionStepsList.tsx
Normal file
304
admin/src/components/volunteer/dashboard/ActionStepsList.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, Button, Typography, Tag, Space, App } from 'antd';
|
||||||
|
import {
|
||||||
|
VideoCameraOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
CheckSquareOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
RightOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { DashboardActionCampaign, DashboardActionStep, ActionStepKind } from './types';
|
||||||
|
|
||||||
|
interface ActionStepsListProps {
|
||||||
|
campaign: DashboardActionCampaign;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_ICONS: Record<ActionStepKind, React.ReactNode> = {
|
||||||
|
WATCH_VIDEO: <VideoCameraOutlined />,
|
||||||
|
SUBMIT_INFLUENCE: <MailOutlined />,
|
||||||
|
SIGN_PETITION: <FileTextOutlined />,
|
||||||
|
RSVP_EVENT: <CalendarOutlined />,
|
||||||
|
SIGNUP_SHIFT: <EnvironmentOutlined />,
|
||||||
|
JOIN_CHALLENGE: <TeamOutlined />,
|
||||||
|
VISIT_LINK: <LinkOutlined />,
|
||||||
|
CUSTOM: <CheckSquareOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<ActionStepKind, string> = {
|
||||||
|
WATCH_VIDEO: 'Watch',
|
||||||
|
SUBMIT_INFLUENCE: 'Email',
|
||||||
|
SIGN_PETITION: 'Sign',
|
||||||
|
RSVP_EVENT: 'RSVP',
|
||||||
|
SIGNUP_SHIFT: 'Shift',
|
||||||
|
JOIN_CHALLENGE: 'Join',
|
||||||
|
VISIT_LINK: 'Visit',
|
||||||
|
CUSTOM: 'Action',
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveStepLink(step: DashboardActionStep): { to: string; external: boolean } | null {
|
||||||
|
if (step.targetUrl) {
|
||||||
|
const external = /^https?:\/\//i.test(step.targetUrl);
|
||||||
|
return { to: step.targetUrl, external };
|
||||||
|
}
|
||||||
|
if (!step.targetId) return null;
|
||||||
|
switch (step.kind) {
|
||||||
|
case 'WATCH_VIDEO':
|
||||||
|
return { to: `/gallery/watch/${step.targetId}`, external: false };
|
||||||
|
case 'SUBMIT_INFLUENCE':
|
||||||
|
return { to: `/campaign/${step.targetId}`, external: false };
|
||||||
|
case 'SIGN_PETITION':
|
||||||
|
return { to: `/petition/${step.targetId}`, external: false };
|
||||||
|
case 'RSVP_EVENT':
|
||||||
|
return { to: `/event/${step.targetId}`, external: false };
|
||||||
|
case 'SIGNUP_SHIFT':
|
||||||
|
return { to: `/volunteer/shifts?shiftId=${step.targetId}`, external: false };
|
||||||
|
case 'JOIN_CHALLENGE':
|
||||||
|
return { to: `/volunteer/challenges/${step.targetId}`, external: false };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighlightedStep({
|
||||||
|
step,
|
||||||
|
onNavigate,
|
||||||
|
onSelfReport,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
step: DashboardActionStep;
|
||||||
|
onNavigate: (step: DashboardActionStep) => void;
|
||||||
|
onSelfReport: (step: DashboardActionStep) => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK';
|
||||||
|
const canNavigate = resolveStepLink(step) !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(52,152,219,0.25) 0%, rgba(41,128,185,0.15) 100%)',
|
||||||
|
border: '1px solid rgba(52,152,219,0.3)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '16px 20px',
|
||||||
|
margin: '0 0 2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||||
|
<ThunderboltOutlined style={{ fontSize: 12, color: '#3498db' }} />
|
||||||
|
<Typography.Text strong style={{ fontSize: 12, color: '#3498db', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Next Up
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(52,152,219,0.25)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#3498db',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{KIND_ICONS[step.kind]}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 15, display: 'block' }}>
|
||||||
|
{step.label}
|
||||||
|
</Typography.Text>
|
||||||
|
{step.description && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 2 }}>
|
||||||
|
{step.description}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
|
{isSelfReport ? (
|
||||||
|
<>
|
||||||
|
{canNavigate && (
|
||||||
|
<Button size="middle" onClick={() => onNavigate(step)} icon={<RightOutlined />}>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="middle"
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => onSelfReport(step)}
|
||||||
|
>
|
||||||
|
Mark as done
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="middle"
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={() => onNavigate(step)}
|
||||||
|
disabled={!canNavigate}
|
||||||
|
>
|
||||||
|
Take Action
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActionStepsList({ campaign, onRefresh }: ActionStepsListProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [completingStepId, setCompletingStepId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSelfReport = async (step: DashboardActionStep) => {
|
||||||
|
setCompletingStepId(step.id);
|
||||||
|
try {
|
||||||
|
await api.post(`/action-campaigns/${campaign.slug}/steps/${step.id}/complete`);
|
||||||
|
message.success('Step marked as done');
|
||||||
|
onRefresh();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to mark step as done');
|
||||||
|
} finally {
|
||||||
|
setCompletingStepId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (step: DashboardActionStep) => {
|
||||||
|
const link = resolveStepLink(step);
|
||||||
|
if (!link) return;
|
||||||
|
if (link.external) {
|
||||||
|
window.open(link.to, '_blank', 'noopener,noreferrer');
|
||||||
|
} else {
|
||||||
|
navigate(link.to);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedSteps = [...campaign.steps].sort((a, b) => a.order - b.order);
|
||||||
|
const highlightedStep = sortedSteps.find((s) => !s.completed);
|
||||||
|
const remainingSteps = sortedSteps.filter((s) => s.id !== highlightedStep?.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
styles={{
|
||||||
|
header: { borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '12px 20px', minHeight: 'auto' },
|
||||||
|
body: { padding: 0 },
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
|
||||||
|
{campaign.completedSteps} of {campaign.totalSteps} Actions
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{highlightedStep && (
|
||||||
|
<div style={{ padding: '12px 12px 0' }}>
|
||||||
|
<HighlightedStep
|
||||||
|
step={highlightedStep}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onSelfReport={handleSelfReport}
|
||||||
|
loading={completingStepId === highlightedStep.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{remainingSteps.map((step, i) => {
|
||||||
|
const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK';
|
||||||
|
const canNavigate = resolveStepLink(step) !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderTop: (highlightedStep || i > 0) ? '1px solid rgba(255,255,255,0.04)' : undefined,
|
||||||
|
opacity: step.completed ? 0.55 : 1,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: step.completed ? '#52c41a' : 'rgba(255,255,255,0.06)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: step.completed ? '#fff' : 'rgba(255,255,255,0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.completed ? <CheckCircleFilled /> : KIND_ICONS[step.kind]}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', display: 'block', lineHeight: 1 }}>
|
||||||
|
{KIND_LABELS[step.kind]}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textDecoration: step.completed ? 'line-through' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
{step.completed ? (
|
||||||
|
<Tag color="success" style={{ margin: 0, fontSize: 11 }}>Done</Tag>
|
||||||
|
) : isSelfReport ? (
|
||||||
|
<Space size={4}>
|
||||||
|
{canNavigate && (
|
||||||
|
<Button size="small" type="text" onClick={() => handleNavigate(step)}>Open</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
loading={completingStepId === step.id}
|
||||||
|
onClick={() => handleSelfReport(step)}
|
||||||
|
>
|
||||||
|
Mark done
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
onClick={() => handleNavigate(step)}
|
||||||
|
disabled={!canNavigate}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Take Action
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
admin/src/components/volunteer/dashboard/ActivityCard.tsx
Normal file
44
admin/src/components/volunteer/dashboard/ActivityCard.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Card, Typography } from 'antd';
|
||||||
|
import { TrophyOutlined, StarFilled } from '@ant-design/icons';
|
||||||
|
import type { DashboardPoints } from './types';
|
||||||
|
|
||||||
|
interface ActivityCardProps {
|
||||||
|
points: DashboardPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityCard({ points }: ActivityCardProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
styles={{
|
||||||
|
header: { borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '12px 20px', minHeight: 'auto' },
|
||||||
|
body: { padding: '20px', textAlign: 'center' },
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600 }}>Activity</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Typography.Text type="warning" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{points.total} pts
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: 4, marginBottom: 4 }}>
|
||||||
|
<TrophyOutlined style={{ fontSize: 24, color: '#faad14' }} />
|
||||||
|
<Typography.Title level={2} style={{ margin: 0, color: '#faad14', fontWeight: 700 }}>
|
||||||
|
{points.total}
|
||||||
|
</Typography.Title>
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
points earned
|
||||||
|
</Typography.Text>
|
||||||
|
{points.achievementCount > 0 && (
|
||||||
|
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<StarFilled style={{ color: '#f5222d', marginRight: 4 }} />
|
||||||
|
<Typography.Text style={{ fontSize: 13 }}>
|
||||||
|
{points.achievementCount} achievement{points.achievementCount !== 1 ? 's' : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user