Compare commits
129 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 | |||
| 91db29402c | |||
| 9321aeb263 | |||
| 5d15b4cffa | |||
| 902adce646 | |||
| 68434c51a6 | |||
| 075a7c8c4a | |||
| 0c2ffe754e | |||
| 3de1d3fca5 | |||
| a436c494fd | |||
| 078bb6e313 | |||
| eb16815f91 | |||
| 8b9ab93856 | |||
| 0fc9ea80bf | |||
| 776aa6fbac | |||
| b215cda018 | |||
| 82a66a97d0 | |||
| 1bf19fff0e | |||
| 39d74e7b85 | |||
| 0c634e100f | |||
| f2284a9cdf | |||
| 7287328148 | |||
| 3262d92065 | |||
| 204e90dd3b | |||
| 81026b38db | |||
| abdfd50cb8 | |||
| 63e05adcee | |||
| a56f8446f7 | |||
| a5a83f2d04 | |||
| c701f77237 | |||
| 44931260c4 | |||
| e0fd4fd7b7 | |||
| 0090bd2f54 | |||
| 68ba45a689 | |||
| bb1935027d | |||
| a71ba20176 | |||
| 8e6f0996de | |||
| f550423c3f | |||
| be2fa5d80b | |||
| e6e324262f | |||
| 647efffdc4 |
140
.env.example
140
.env.example
@ -11,6 +11,21 @@
|
|||||||
# - NEVER commit .env to version control
|
# - NEVER commit .env to version control
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# MINIMUM VIABLE SETUP (required — change these before deploying)
|
||||||
|
# ==============================================================================
|
||||||
|
# 1. V2_POSTGRES_PASSWORD — database password (8+ chars)
|
||||||
|
# 2. REDIS_PASSWORD — cache password (8+ chars)
|
||||||
|
# 3. JWT_ACCESS_SECRET — openssl rand -hex 32
|
||||||
|
# 4. JWT_REFRESH_SECRET — openssl rand -hex 32 (different from above)
|
||||||
|
# 5. JWT_INVITE_SECRET — openssl rand -hex 32 (different from above)
|
||||||
|
# 6. ENCRYPTION_KEY — openssl rand -hex 32 (different from JWT secrets)
|
||||||
|
# 7. INITIAL_ADMIN_PASSWORD — 12+ chars, uppercase + lowercase + digit
|
||||||
|
# 8. DOMAIN — your deployment domain (default: cmlite.org)
|
||||||
|
#
|
||||||
|
# Everything below these 8 values works with defaults for development.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
# --- General ---
|
# --- General ---
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
# Root domain serves MkDocs documentation site only
|
# Root domain serves MkDocs documentation site only
|
||||||
@ -29,14 +44,30 @@ V2_POSTGRES_PORT=5433
|
|||||||
# --- JWT Auth ---
|
# --- JWT Auth ---
|
||||||
JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
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_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
|
||||||
|
|
||||||
|
# BREAKING CHANGE (2026-04-12): both GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
|
||||||
|
# are now REQUIRED (min 32 chars). The previous fallback to JWT_ACCESS_SECRET
|
||||||
|
# has been removed — a JWT leak must not compromise SSO cookies or service
|
||||||
|
# account passwords. Both values must be distinct from each other and from
|
||||||
|
# all JWT_* secrets. Generate with: openssl rand -hex 32
|
||||||
|
|
||||||
|
# Gitea SSO cookie signing secret (required, ≥32 chars, distinct from JWT secrets)
|
||||||
|
GITEA_SSO_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat).
|
||||||
|
# Required, ≥32 chars, distinct from all other secrets.
|
||||||
|
SERVICE_PASSWORD_SALT=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
|
||||||
# --- Initial Super Admin User (auto-created during database seeding) ---
|
# --- Initial Super Admin User (auto-created during database seeding) ---
|
||||||
# These credentials are used to create the initial super admin account
|
# These credentials are used to create the initial super admin account
|
||||||
# Change these before running the seed script in production
|
# Change these before running the seed script in production
|
||||||
@ -57,6 +88,32 @@ ADMIN_URL=http://localhost:3000
|
|||||||
NGINX_HTTP_PORT=80
|
NGINX_HTTP_PORT=80
|
||||||
NGINX_HTTPS_PORT=443
|
NGINX_HTTPS_PORT=443
|
||||||
|
|
||||||
|
# --- Embed Proxy Ports ---
|
||||||
|
# Dedicated nginx ports for iframe embedding without DNS/subdomain.
|
||||||
|
# Change these to avoid port conflicts when running multiple instances on one host.
|
||||||
|
NOCODB_EMBED_PORT=8881
|
||||||
|
N8N_EMBED_PORT=8882
|
||||||
|
GITEA_EMBED_PORT=8883
|
||||||
|
MAILHOG_EMBED_PORT=8884
|
||||||
|
MINI_QR_EMBED_PORT=8885
|
||||||
|
EXCALIDRAW_EMBED_PORT=8886
|
||||||
|
HOMEPAGE_EMBED_PORT=8887
|
||||||
|
VAULTWARDEN_EMBED_PORT=8890
|
||||||
|
ROCKETCHAT_EMBED_PORT=8891
|
||||||
|
GANCIO_EMBED_PORT=8892
|
||||||
|
JITSI_EMBED_PORT=8893
|
||||||
|
GRAFANA_EMBED_PORT=8894
|
||||||
|
ALERTMANAGER_EMBED_PORT=8895
|
||||||
|
|
||||||
|
# --- Docker / Container Management ---
|
||||||
|
# Docker network name (used by dashboard to auto-discover containers)
|
||||||
|
DOCKER_NETWORK_NAME=changemaker-lite
|
||||||
|
# Docker socket proxy URL (read-only container inspection)
|
||||||
|
DOCKER_PROXY_URL=http://docker-socket-proxy:2375
|
||||||
|
# Newt tunnel container (for Pangolin restart/status checks)
|
||||||
|
NEWT_CONTAINER_NAME=newt-changemaker
|
||||||
|
NEWT_COMPOSE_SERVICE=newt
|
||||||
|
|
||||||
# --- SMTP / Email ---
|
# --- SMTP / Email ---
|
||||||
SMTP_HOST=mailhog-changemaker
|
SMTP_HOST=mailhog-changemaker
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
@ -79,6 +136,8 @@ LISTMONK_WEB_ADMIN_USER=admin
|
|||||||
LISTMONK_WEB_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
LISTMONK_WEB_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
# API user (auto-created by listmonk-init container, used by V2 API for sync)
|
# API user (auto-created by listmonk-init container, used by V2 API for sync)
|
||||||
# Generate token: openssl rand -hex 16
|
# Generate token: openssl rand -hex 16
|
||||||
|
# NOTE: LISTMONK_ADMIN_USER/PASSWORD are what the V2 API uses to connect.
|
||||||
|
# They MUST match LISTMONK_API_USER/TOKEN (same credentials, different var names).
|
||||||
LISTMONK_API_USER=v2-api
|
LISTMONK_API_USER=v2-api
|
||||||
LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16
|
LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16
|
||||||
LISTMONK_ADMIN_USER=v2-api
|
LISTMONK_ADMIN_USER=v2-api
|
||||||
@ -129,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
|
||||||
@ -146,11 +212,35 @@ VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
|
|||||||
# Preview Links (Feb 2026)
|
# Preview Links (Feb 2026)
|
||||||
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
|
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
|
||||||
|
|
||||||
# --- Gitea ---
|
# --- Container Registry ---
|
||||||
|
# Gitea registry for pre-built production images.
|
||||||
|
# Set IMAGE_TAG to a commit SHA (or 'latest') to pull pre-built images instead of building from source.
|
||||||
|
# Leave IMAGE_TAG blank/unset (defaults to 'local') to build locally from source.
|
||||||
|
GITEA_REGISTRY=gitea.bnkops.com/admin
|
||||||
|
IMAGE_TAG=
|
||||||
|
|
||||||
|
# Docker Compose profiles — set to 'monitoring' to include Prometheus/Grafana/Alertmanager
|
||||||
|
# in every 'docker compose up -d'. Leave blank to start monitoring separately.
|
||||||
|
COMPOSE_PROFILES=
|
||||||
|
# Credentials used by the registry status API endpoint (GET /api/registry/status)
|
||||||
|
# For docker push/pull, run: docker login gitea.bnkops.com
|
||||||
|
GITEA_REGISTRY_USER=admin
|
||||||
|
GITEA_REGISTRY_PASS=
|
||||||
|
# API token for the REMOTE registry (gitea.bnkops.com) — used by build-release.sh --upload
|
||||||
|
# Create at: https://gitea.bnkops.com/user/settings/applications
|
||||||
|
# This is NOT the same as GITEA_API_TOKEN (which is for the local platform Gitea below)
|
||||||
|
GITEA_REGISTRY_API_TOKEN=
|
||||||
|
|
||||||
|
# --- Gitea (Local Platform Instance) ---
|
||||||
GITEA_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
|
||||||
@ -163,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=
|
||||||
@ -195,29 +287,25 @@ MKDOCS_DOCS_PATH=/mkdocs/docs
|
|||||||
|
|
||||||
# --- Code Server ---
|
# --- Code Server ---
|
||||||
CODE_SERVER_PORT=8888
|
CODE_SERVER_PORT=8888
|
||||||
CODE_SERVER_URL=http://code-server:8080
|
CODE_SERVER_URL=http://code-server-changemaker:8443
|
||||||
USER_NAME=coder
|
USER_NAME=coder
|
||||||
|
|
||||||
# --- Homepage ---
|
# --- Homepage ---
|
||||||
HOMEPAGE_PORT=3010
|
HOMEPAGE_PORT=3010
|
||||||
HOMEPAGE_EMBED_PORT=8887
|
|
||||||
HOMEPAGE_VAR_BASE_URL=http://localhost
|
HOMEPAGE_VAR_BASE_URL=http://localhost
|
||||||
|
|
||||||
# --- Mini QR ---
|
# --- Mini QR ---
|
||||||
MINI_QR_PORT=8089
|
MINI_QR_PORT=8089
|
||||||
MINI_QR_URL=http://mini-qr:8080
|
MINI_QR_URL=http://mini-qr:8080
|
||||||
MINI_QR_EMBED_PORT=8885
|
|
||||||
|
|
||||||
# --- Excalidraw (Collaborative Whiteboard) ---
|
# --- Excalidraw (Collaborative Whiteboard) ---
|
||||||
EXCALIDRAW_PORT=8090
|
EXCALIDRAW_PORT=8090
|
||||||
EXCALIDRAW_URL=http://excalidraw-changemaker:80
|
EXCALIDRAW_URL=http://excalidraw-changemaker:80
|
||||||
EXCALIDRAW_EMBED_PORT=8886
|
|
||||||
EXCALIDRAW_WS_URL=wss://draw.cmlite.org
|
EXCALIDRAW_WS_URL=wss://draw.cmlite.org
|
||||||
|
|
||||||
# --- Vaultwarden (Password Manager) ---
|
# --- Vaultwarden (Password Manager) ---
|
||||||
VAULTWARDEN_PORT=8445
|
VAULTWARDEN_PORT=8445
|
||||||
VAULTWARDEN_URL=http://vaultwarden-changemaker:80
|
VAULTWARDEN_URL=http://vaultwarden-changemaker:80
|
||||||
VAULTWARDEN_EMBED_PORT=8890
|
|
||||||
# Admin panel token (access at /admin) — generate with: openssl rand -hex 32
|
# Admin panel token (access at /admin) — generate with: openssl rand -hex 32
|
||||||
VAULTWARDEN_ADMIN_TOKEN=
|
VAULTWARDEN_ADMIN_TOKEN=
|
||||||
# MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation
|
# MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation
|
||||||
@ -290,13 +378,14 @@ ENABLE_CHAT=false
|
|||||||
ROCKETCHAT_ADMIN_USER=rcadmin
|
ROCKETCHAT_ADMIN_USER=rcadmin
|
||||||
ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
ROCKETCHAT_URL=http://rocketchat-changemaker:3000
|
ROCKETCHAT_URL=http://rocketchat-changemaker:3000
|
||||||
ROCKETCHAT_EMBED_PORT=8891
|
# MongoDB credentials for Rocket.Chat (required — MongoDB runs with --auth)
|
||||||
|
MONGO_ROOT_USER=rocketchat
|
||||||
|
MONGO_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
|
||||||
# --- Gancio (Event Management) ---
|
# --- Gancio (Event Management) ---
|
||||||
# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh)
|
# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh)
|
||||||
GANCIO_PORT=8092
|
GANCIO_PORT=8092
|
||||||
GANCIO_URL=http://gancio-changemaker:13120
|
GANCIO_URL=http://gancio-changemaker:13120
|
||||||
GANCIO_EMBED_PORT=8892
|
|
||||||
GANCIO_BASE_URL=https://events.cmlite.org
|
GANCIO_BASE_URL=https://events.cmlite.org
|
||||||
# Gancio admin credentials for shift-to-event sync (OAuth login)
|
# Gancio admin credentials for shift-to-event sync (OAuth login)
|
||||||
GANCIO_ADMIN_USER=admin
|
GANCIO_ADMIN_USER=admin
|
||||||
@ -316,18 +405,12 @@ JITSI_APP_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
|||||||
# Generate each with: openssl rand -hex 16
|
# Generate each with: openssl rand -hex 16
|
||||||
JITSI_JICOFO_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
|
JITSI_JICOFO_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
|
||||||
JITSI_JVB_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
|
JITSI_JVB_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
|
||||||
# Embed port for admin iframe
|
|
||||||
JITSI_EMBED_PORT=8893
|
|
||||||
JITSI_URL=http://jitsi-web-changemaker:80
|
JITSI_URL=http://jitsi-web-changemaker:80
|
||||||
# JVB public IP (required for NAT traversal — set to server's public IP in production)
|
# JVB public IP (required for NAT traversal — set to server's public IP in production)
|
||||||
JVB_ADVERTISE_IP=
|
JVB_ADVERTISE_IP=
|
||||||
# JVB UDP port for media traffic (must be open in firewall)
|
# JVB UDP port for media traffic (must be open in firewall)
|
||||||
JVB_PORT=10000
|
JVB_PORT=10000
|
||||||
|
|
||||||
# --- Monitoring Embed Ports (iframe embedding) ---
|
|
||||||
GRAFANA_EMBED_PORT=8894
|
|
||||||
ALERTMANAGER_EMBED_PORT=8895
|
|
||||||
|
|
||||||
# --- SMS Campaigns (Termux Android Bridge) ---
|
# --- SMS Campaigns (Termux Android Bridge) ---
|
||||||
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
|
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
|
||||||
# URL + API key are typically managed via admin Settings page (DB overrides env)
|
# URL + API key are typically managed via admin Settings page (DB overrides env)
|
||||||
@ -340,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
|
||||||
@ -357,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)
|
||||||
|
|||||||
38
.gitignore
vendored
38
.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/
|
||||||
@ -51,14 +55,44 @@ docker-compose.override.yml
|
|||||||
core.*
|
core.*
|
||||||
*/core.*
|
*/core.*
|
||||||
|
|
||||||
# MkDocs core binary
|
# MkDocs core binary and container-generated assets (owned by root, not stashable)
|
||||||
/mkdocs/core
|
/mkdocs/core
|
||||||
|
/mkdocs/assets/
|
||||||
|
|
||||||
# Upgrade artifacts
|
# Upgrade artifacts
|
||||||
/logs/
|
/logs/
|
||||||
/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)
|
||||||
|
/releases/
|
||||||
|
|
||||||
|
# API compiled output (generated by tsc, baked into Docker images)
|
||||||
|
/api/dist/
|
||||||
|
|
||||||
|
# TypeScript incremental build cache (machine-specific)
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Control Panel runtime data (managed deployments + backups)
|
# Control Panel runtime data (managed deployments + backups)
|
||||||
/changemaker-control-panel/instances/
|
/changemaker-control-panel/instances/
|
||||||
/changemaker-control-panel/backups/
|
/changemaker-control-panel/backups/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Playwright MCP browser automation logs
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
/docs
|
||||||
|
|
||||||
|
# MkDocs build cache (regenerated each build)
|
||||||
|
/mkdocs/.cache/
|
||||||
|
|
||||||
|
# Claude scheduler lock file
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# Old release zip archive (no longer tracked, see chore: gitignore hygiene)
|
||||||
|
/archive/
|
||||||
@ -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,194 +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
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB |
453
CLAUDE.md
453
CLAUDE.md
@ -6,19 +6,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
|
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
|
||||||
|
|
||||||
**Current state:** V2 rebuild substantially complete on the `v2` branch. Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
|
**Current state:** V2 rebuild substantially complete (merged to `main`). Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
|
||||||
|
|
||||||
**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
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -45,15 +49,7 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
|
|||||||
- **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware; `SUPER_ADMIN` implicitly bypasses all role checks
|
- **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware; `SUPER_ADMIN` implicitly bypasses all role checks
|
||||||
- **Module-specific role groups** (defined in `api/src/utils/roles.ts`): `INFLUENCE_ROLES`, `MAP_ROLES`, `BROADCAST_ROLES`, `CONTENT_ROLES`, `MEDIA_ROLES`, `PAYMENTS_ROLES`, `EVENTS_ROLES`, `SOCIAL_ROLES`, `SYSTEM_ROLES`, `SCHEDULING_ROLES`
|
- **Module-specific role groups** (defined in `api/src/utils/roles.ts`): `INFLUENCE_ROLES`, `MAP_ROLES`, `BROADCAST_ROLES`, `CONTENT_ROLES`, `MEDIA_ROLES`, `PAYMENTS_ROLES`, `EVENTS_ROLES`, `SOCIAL_ROLES`, `SYSTEM_ROLES`, `SCHEDULING_ROLES`
|
||||||
- **User management:** `SUPER_ADMIN` always; other admins need `permissions.canManageUsers: true` for write operations
|
- **User management:** `SUPER_ADMIN` always; other admins need `permissions.canManageUsers: true` for write operations
|
||||||
- **Security features:**
|
- **Security:** See Security & Configuration section below + `SECURITY_AUDIT_2025-02-11.md`
|
||||||
- Refresh token rotation (atomic transaction)
|
|
||||||
- User enumeration prevention (401 not 404)
|
|
||||||
- Rate limiting on auth endpoints (10/min)
|
|
||||||
- Redis authentication required
|
|
||||||
- XSS/injection prevention (HTML escaping)
|
|
||||||
- Path traversal protection
|
|
||||||
- Encryption key for DB secrets (ENCRYPTION_KEY env var)
|
|
||||||
- Security audit complete (13 findings addressed, see `SECURITY_AUDIT_2025-02-11.md`)
|
|
||||||
|
|
||||||
### Email Systems
|
### Email Systems
|
||||||
|
|
||||||
@ -67,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)
|
||||||
@ -78,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
|
||||||
@ -98,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)
|
||||||
@ -127,70 +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
|
||||||
│ │ ├── DashboardPage.tsx # Admin dashboard
|
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard
|
||||||
│ │ ├── UsersPage.tsx # User CRUD
|
│ │ ├── media/ # Library, Playlists, Analytics, Gallery Ads, Comment Moderation
|
||||||
│ │ ├── SettingsPage.tsx # Global site settings
|
│ │ ├── payments/ # Dashboard, Products, Plans, Donations, Subscribers, Settings
|
||||||
│ │ ├── influence/
|
│ │ ├── social/ # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges
|
||||||
│ │ │ ├── CampaignsPage.tsx # Campaign management
|
│ │ ├── sms/ # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup
|
||||||
│ │ │ ├── ResponsesPage.tsx # Response moderation
|
│ │ ├── events/ # Ticketed Events, Event Detail, Check-in Scanner
|
||||||
│ │ │ ├── RepresentativesPage.tsx # Rep cache admin
|
│ │ ├── volunteer/ # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements
|
||||||
│ │ │ └── EmailQueuePage.tsx # Queue monitoring
|
│ │ ├── public/ # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet
|
||||||
│ │ ├── map/
|
│ │ └── (root) # Dashboard, Users, Settings, Docs*, MeetingPlanner, Observability, etc.
|
||||||
│ │ │ ├── LocationsPage.tsx # Location CRUD + CSV + geocoding
|
│ ├── stores/ # 9 Zustand stores (auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking)
|
||||||
│ │ │ ├── CutsPage.tsx # Cut table + map drawing editor
|
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts, nav-defaults.ts, service-url.ts, y-textarea.ts
|
||||||
│ │ │ ├── ShiftsPage.tsx # Shift CRUD + signups drawer
|
|
||||||
│ │ │ ├── MapSettingsPage.tsx # Map settings
|
|
||||||
│ │ │ └── DataQualityDashboardPage.tsx # Geocoding quality metrics
|
|
||||||
│ │ ├── CanvassDashboardPage.tsx # Admin canvass overview
|
|
||||||
│ │ ├── WalkSheetPage.tsx # Printable walk sheet
|
|
||||||
│ │ ├── CutExportPage.tsx # Printable location report
|
|
||||||
│ │ ├── volunteer/
|
|
||||||
│ │ │ ├── VolunteerMapPage.tsx # Full-screen GPS canvass map
|
|
||||||
│ │ │ ├── VolunteerShiftsPage.tsx # Assigned shifts
|
|
||||||
│ │ │ ├── MyActivityPage.tsx # Visit history + outcomes
|
|
||||||
│ │ │ └── MyRoutesPage.tsx # Route history
|
|
||||||
│ │ ├── public/
|
|
||||||
│ │ │ ├── CampaignsListPage.tsx # Public campaign listing
|
|
||||||
│ │ │ ├── CampaignPage.tsx # Campaign detail + email form
|
|
||||||
│ │ │ ├── ResponseWallPage.tsx # Public response wall
|
|
||||||
│ │ │ ├── MapPage.tsx # Public Leaflet map
|
|
||||||
│ │ │ ├── ShiftsPage.tsx # Public shift signup
|
|
||||||
│ │ │ ├── LandingPage.tsx # Rendered landing page (/p/:slug)
|
|
||||||
│ │ │ ├── MediaGalleryPage.tsx # Public video gallery
|
|
||||||
│ │ │ └── MediaViewerPage.tsx # Video detail page
|
|
||||||
│ │ ├── media/
|
|
||||||
│ │ │ ├── LibraryPage.tsx # Video library management
|
|
||||||
│ │ │ ├── SharedMediaPage.tsx # Public gallery admin
|
|
||||||
│ │ │ └── MediaJobsPage.tsx # Job queue monitoring
|
|
||||||
│ │ ├── LandingPagesPage.tsx # Landing page manager
|
|
||||||
│ │ ├── PageEditorPage.tsx # Full-screen GrapesJS editor
|
|
||||||
│ │ ├── EmailTemplatesPage.tsx # Email template CRUD
|
|
||||||
│ │ ├── EmailTemplateEditorPage.tsx # Email template editor
|
|
||||||
│ │ ├── ListmonkPage.tsx # Newsletter sync management
|
|
||||||
│ │ ├── PangolinPage.tsx # Tunnel setup wizard
|
|
||||||
│ │ ├── DocsPage.tsx # MkDocs export management
|
|
||||||
│ │ ├── MkDocsSettingsPage.tsx # Documentation config
|
|
||||||
│ │ ├── ObservabilityPage.tsx # Monitoring dashboard
|
|
||||||
│ │ └── services/
|
|
||||||
│ │ ├── MiniQRPage.tsx # Mini QR iframe
|
|
||||||
│ │ ├── MailHogPage.tsx # Email capture UI
|
|
||||||
│ │ ├── CodeEditorPage.tsx # Code Server management
|
|
||||||
│ │ ├── N8nPage.tsx # Workflow automation
|
|
||||||
│ │ ├── GiteaPage.tsx # Git repository hosting
|
|
||||||
│ │ └── NocoDBPage.tsx # Data browser management
|
|
||||||
│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand)
|
|
||||||
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios)
|
|
||||||
│ ├── 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/ # backup.sh, legacy Cloudflare scripts
|
├── scripts/ # Deployment, backup, upgrade, registry scripts
|
||||||
├── docker-compose.yml # V2 orchestration (20+ services)
|
│ ├── install.sh # Curl-friendly installer (downloads tarball + runs config.sh)
|
||||||
├── docker-compose.v1.yml # V1 backup (reference)
|
│ ├── uninstall.sh # Remove containers, volumes, and install dir
|
||||||
|
│ ├── build-and-push.sh # Build production images → push to Gitea registry
|
||||||
|
│ ├── build-release.sh # Package runtime files into release tarball
|
||||||
|
│ ├── mirror-images.sh # Mirror third-party images to Gitea
|
||||||
|
│ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode)
|
||||||
|
│ ├── upgrade-check.sh # Check for updates (git or Gitea API)
|
||||||
|
│ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades
|
||||||
|
│ ├── update-env.sh # Merge new variables from .env.example into existing .env
|
||||||
|
│ ├── backup.sh / restore.sh # PostgreSQL + Listmonk + uploads backup/restore
|
||||||
|
│ ├── validate-env.sh # Required env variable validation
|
||||||
|
│ ├── validate-compose-parity.sh # Check docker-compose.yml ↔ docker-compose.prod.yml parity
|
||||||
|
│ ├── test-deployment.sh # Post-deploy smoke tests (auth, services, health)
|
||||||
|
│ ├── register-with-ccp.sh # Register instance with a Control Panel via invite code
|
||||||
|
│ ├── ccp-deregister.sh # Deregister instance from its CCP
|
||||||
|
│ ├── pangolin-teardown.sh # Delete Pangolin resources/sites (dry-run by default)
|
||||||
|
│ ├── gitea-init.sh # Bootstrap Gitea admin user + SSO app
|
||||||
|
│ ├── nocodb-init.sh # Bootstrap NocoDB project + base connection
|
||||||
|
│ ├── mkdocs-entrypoint.sh # MkDocs container entrypoint (live + built modes)
|
||||||
|
│ ├── mkdocs-build-trigger.py # Trigger MkDocs rebuild from API hooks
|
||||||
|
│ ├── legacy/ # Archived Cloudflare tunnel configs (pre-Pangolin)
|
||||||
|
│ └── systemd/ # Systemd unit files (backup timer, upgrade watcher)
|
||||||
|
├── docker-compose.yml # V2 orchestration (40+ services)
|
||||||
|
├── docker-compose.prod.yml # Production (image-only, no source mounts)
|
||||||
├── .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
|
||||||
```
|
```
|
||||||
@ -199,13 +197,28 @@ changemaker.lite/
|
|||||||
|
|
||||||
## Quick Start Guide
|
## Quick Start Guide
|
||||||
|
|
||||||
### Initial Setup (First Time)
|
### Pre-built Install (Production — Recommended)
|
||||||
|
|
||||||
1. **Clone repository and checkout v2 branch:**
|
The fastest way to deploy. No source code, no compilation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads a ~9MB release tarball, runs the config wizard, and sets `IMAGE_TAG=latest`. Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/changemaker.lite && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-built images are pulled from `gitea.bnkops.com/admin` (~2 min). Database migrations and seeding run automatically via the API entrypoint. Access the admin GUI at http://localhost:3000.
|
||||||
|
|
||||||
|
### Source Install (Development)
|
||||||
|
|
||||||
|
1. **Clone repository:**
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> changemaker.lite
|
git clone <repo-url> changemaker.lite
|
||||||
cd changemaker.lite
|
cd changemaker.lite
|
||||||
git checkout v2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Create environment file:**
|
2. **Create environment file:**
|
||||||
@ -267,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
|
||||||
@ -301,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
|
||||||
@ -324,12 +344,41 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Registry & Release Operations
|
||||||
|
```bash
|
||||||
|
# Build production images and push to Gitea registry
|
||||||
|
./scripts/build-and-push.sh --services api,admin,media-api,nginx
|
||||||
|
./scripts/build-and-push.sh --no-push # Build only, no push (verify)
|
||||||
|
|
||||||
|
# Mirror third-party images to Gitea
|
||||||
|
./scripts/mirror-images.sh # Core images (postgres, redis, etc.)
|
||||||
|
./scripts/mirror-images.sh --all # Include heavy images (RC, Jitsi, n8n)
|
||||||
|
|
||||||
|
# Build release tarball (for pre-built installs — run AFTER build-and-push)
|
||||||
|
./scripts/build-release.sh --tag v2.1.0 # Creates releases/changemaker-lite-v2.1.0.tar.gz
|
||||||
|
./scripts/build-release.sh --tag v2.1.0 --upload # Also upload to Gitea Releases API
|
||||||
|
./scripts/build-release.sh --dry-run # Preview tarball contents
|
||||||
|
|
||||||
|
# Use registry images in upgrade (source installs)
|
||||||
|
./scripts/upgrade.sh --use-registry --force --skip-backup
|
||||||
|
|
||||||
|
# Install from tarball (end-user one-liner)
|
||||||
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Two compose files:**
|
||||||
|
- `docker-compose.yml` — Development: includes `build:` blocks and `./api:/app` source mounts
|
||||||
|
- `docker-compose.prod.yml` — Production: `image:` only, no source mounts, `IMAGE_TAG:-latest`
|
||||||
|
|
||||||
|
Release tarballs ship `docker-compose.prod.yml` as the compose file. Source installs use `docker-compose.yml`.
|
||||||
|
|
||||||
|
**Note:** gitea.bnkops.com must use Pangolin tunnel (not Cloudflare proxy) for large image layers (>100MB). See `docs/REGISTRY_GUIDE.md`.
|
||||||
|
|
||||||
### Testing & Backup
|
### Testing & Backup
|
||||||
```bash
|
```bash
|
||||||
# Media API tests
|
# Media API tests
|
||||||
@ -441,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
|
||||||
@ -451,25 +504,20 @@ 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:**
|
**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)**.
|
||||||
- **Video CRUD:** Upload with FFprobe metadata extraction (duration, dimensions, orientation, quality), bulk operations
|
|
||||||
- **Quick Actions** (Feb 2026): Edit, preview, analytics, schedule, duplicate, preview links (24h JWT), reset analytics
|
**HLS adaptive bitrate streaming:**
|
||||||
- **Scheduled Publishing** (Feb 2026): BullMQ job queue, timezone support (11 zones), calendar view, publish/unpublish automation
|
- 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.
|
||||||
- **Analytics** (Feb 2026): Views, watch time, completion rate, traffic sources, registered viewers, GDPR-compliant (IP hashing, 90-day retention)
|
- 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).
|
||||||
- **Tracking:** Public endpoints for view/event recording, 10s heartbeat, navigator.sendBeacon for reliability
|
- `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.
|
||||||
- **UI Features:** Keyboard shortcuts (E/P/A/S), hover overlays, skeleton loading, error handling, mobile responsive
|
- 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`
|
||||||
- Public: `/gallery` (public video gallery), `/gallery/watch/:id` (video viewer), `/media/:id` (backwards compatible viewer route)
|
- Public: `/gallery`, `/gallery/watch/:id`, `/media/:id` (legacy)
|
||||||
- Tracking (public): `/track/view`, `/track/event`, `/track/heartbeat`
|
- Public gallery uses `MediaPublicLayout` (purple theme, optional auth)
|
||||||
|
|
||||||
**Note:** The public gallery is served at `/gallery` via the admin app using `MediaPublicLayout`. This provides a unified purple interface for both authenticated and unauthenticated users. The gallery supports optional authentication (session-based upvoting/commenting for anonymous users).
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
- [Media Admin Features Guide](./docs/MEDIA_ADMIN_FEATURES.md) — Complete feature documentation
|
|
||||||
- [Video Analytics Guide](./docs/VIDEO_ANALYTICS_GUIDE.md) — Analytics setup and interpretation
|
|
||||||
- [Media API README](./api/src/modules/media/README.md) — Architecture overview
|
|
||||||
|
|
||||||
### Services & Integrations
|
### Services & Integrations
|
||||||
|
|
||||||
@ -483,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:**
|
||||||
@ -525,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 |
|
||||||
@ -563,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.
|
||||||
@ -576,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
|
||||||
@ -591,59 +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:** If `db push` was used and migrations are out of sync:
|
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`)
|
||||||
1. Drop any stray indexes/objects in DB not in schema: `DROP INDEX IF EXISTS <name>;`
|
|
||||||
2. Create a temp shadow DB: `docker compose exec -T v2-postgres createdb -U changemaker prisma_shadow_diff`
|
|
||||||
3. Generate catch-up SQL: `docker compose exec -T api npx prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --shadow-database-url "postgresql://..." --script`
|
|
||||||
4. Save to `api/prisma/migrations/<timestamp>_<name>/migration.sql`
|
|
||||||
5. Mark as applied: `docker compose exec -T api npx prisma migrate resolve --applied <migration_name>`
|
|
||||||
6. Verify: `docker compose exec -T api npx prisma migrate status` → "Database schema is up to date!"
|
|
||||||
7. Clean up: `docker compose exec -T v2-postgres dropdb -U changemaker prisma_shadow_diff`
|
|
||||||
- **Gotcha:** `--from-migrations` replays all migration files on a shadow DB. If a migration references tables created by `db push` (no migration file), it will fail. Fix: temporarily move the dependent migration aside, generate the catch-up (which includes the missing tables), then remove the old migration
|
|
||||||
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`) — it applies pending migrations without creating a shadow DB
|
|
||||||
|
|
||||||
### V2-Specific Gotchas
|
### Key Gotchas
|
||||||
- **Prisma migrations:** Never use `db push` on the v2 branch — always use `migrate dev` to keep migration history in sync. The baseline catch-up migration (`20260224100000_baseline_catchup`) covers all schema changes from Feb 18–24 that were previously applied via `db push`
|
- **Prisma migrations:** Never use `db push` — always `migrate dev` to keep history in sync
|
||||||
- Fastify media API on port 4100, separate from Express on 4000 (same DB, different ORM)
|
|
||||||
- Volunteer page naming: `VolunteerShiftsPage.tsx` (not "MyAssignmentsPage")
|
|
||||||
- Tracking module: `api/src/modules/map/tracking/` (volunteer + admin routes)
|
|
||||||
- Pages module: 3 route files (pages-admin, pages-public, blocks)
|
|
||||||
- Vite proxy: `VITE_API_URL`, `VITE_MKDOCS_URL` env vars (Docker sets to container hostnames)
|
|
||||||
- Nginx media API block must come BEFORE general API block
|
- Nginx media API block must come BEFORE general API block
|
||||||
- MkDocs port 4003 (was 4000, conflicted with API)
|
- `IMAGE_TAG=local` (default) never pulls from registry; set to SHA or `latest` for pre-built images
|
||||||
- Media upload: requires separate RW volume mount for inbox directory (`:rw` on `/media/local/inbox`), library remains read-only
|
- **Release vs source installs:** Detected by `VERSION` file + absence of `.git/`; release uses `docker-compose.prod.yml`, source uses `docker-compose.yml`
|
||||||
- FFmpeg/FFprobe: installed in media-api container (Alpine `apk add --no-cache ffmpeg`), used for metadata extraction
|
- **`api/dist/` is gitignored** — never commit; if root-owned from container builds, fix with `chown`
|
||||||
|
- **`!` in passwords** triggers bash history expansion — use Write tool to write JSON to file, then `curl -d @file`
|
||||||
|
- **Port mappings:** API container 4000 → host 4002, Admin container 3000 → host 3002
|
||||||
|
- **BullMQ** needs its own Redis connections (pass URL string, not shared ioredis instance)
|
||||||
|
- **Public pages** use `axios` directly (no auth interceptor), admin pages use `{ api }` from lib
|
||||||
|
- **Prisma JSON fields:** typed arrays need `as unknown as Prisma.InputJsonValue` cast
|
||||||
|
- **nginx conf.d files** have `.template` counterparts used by envsubst at startup
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 production)
|
- 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:
|
||||||
@ -666,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
|
||||||
@ -696,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.
|
||||||
@ -723,47 +780,49 @@ Check in order:
|
|||||||
4. **Pangolin resources configured:** All resources set to "Not Protected"
|
4. **Pangolin resources configured:** All resources set to "Not Protected"
|
||||||
5. **Nginx running:** `docker compose ps nginx`
|
5. **Nginx running:** `docker compose ps nginx`
|
||||||
|
|
||||||
### Database 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`.
|
||||||
|
|
||||||
**Symptom:** API logs show database connection errors.
|
### 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.
|
||||||
|
|
||||||
**Fix:**
|
Check in order:
|
||||||
1. Check PostgreSQL container: `docker compose ps v2-postgres`
|
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:
|
||||||
2. Verify `DATABASE_URL` in `.env` matches container name and port
|
```
|
||||||
3. Check PostgreSQL logs: `docker compose logs v2-postgres --tail 50`
|
docker compose exec -u 0 media-api chown -R 1000:1000 /media/local/hls
|
||||||
4. Test connection: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`
|
```
|
||||||
|
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`.
|
||||||
|
|
||||||
### Redis Connection Failures
|
To force a re-transcode of a failed video, set `hlsStatus = NULL` in the DB and run `npm run backfill:hls`.
|
||||||
|
|
||||||
**Symptom:** API logs show Redis connection errors, rate limiting doesn't work.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
1. Check Redis container: `docker compose ps redis-changemaker`
|
|
||||||
2. Verify `REDIS_PASSWORD` matches in `.env` and `REDIS_URL` format
|
|
||||||
3. Check Redis logs: `docker compose logs redis-changemaker --tail 50`
|
|
||||||
4. Test connection: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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` — V2 orchestration (20+ services, monitoring profile)
|
- `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`)
|
||||||
- `.env` / `.env.example` — Environment variables (100+ vars)
|
- `.env` / `.env.example` — Environment variables (100+ vars)
|
||||||
|
- `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
|
||||||
@ -781,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
|
||||||
|
|||||||
333
DEV_WORKFLOW.md
Normal file
333
DEV_WORKFLOW.md
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
# Development & Release Workflow
|
||||||
|
|
||||||
|
How code changes move from development to production deployments across all installation methods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
There are **three ways** Changemaker Lite gets deployed:
|
||||||
|
|
||||||
|
| Method | Who uses it | Images from | Compose file |
|
||||||
|
|--------|------------|-------------|--------------|
|
||||||
|
| **Source install** | Developers, contributors | Built locally from source | `docker-compose.yml` |
|
||||||
|
| **Release install** | Production servers, evaluators | Gitea registry (pre-built) | `docker-compose.prod.yml` (ships as `docker-compose.yml` in tarball) |
|
||||||
|
| **CCP provisioned** | Fleet operators (Control Panel) | Gitea registry (pre-built) | Rendered from `templates/docker-compose.yml.hbs` |
|
||||||
|
|
||||||
|
All three methods share the same Gitea container registry at `gitea.bnkops.com/admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DEVELOPMENT (your machine) │
|
||||||
|
│ │
|
||||||
|
│ Edit code → docker compose up -d → test locally │
|
||||||
|
│ Uses: docker-compose.yml (build: blocks + ./api:/app mounts) │
|
||||||
|
└──────────────────┬───────────────────────────────────────────────┘
|
||||||
|
│ git push
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BUILD & PUBLISH │
|
||||||
|
│ │
|
||||||
|
│ Step 1: ./scripts/build-and-push.sh │
|
||||||
|
│ Builds 5 production images, pushes to Gitea registry │
|
||||||
|
│ (api, admin, media-api, nginx, ccp-agent) │
|
||||||
|
│ tagged :SHA + :latest │
|
||||||
|
│ │
|
||||||
|
│ Step 2: ./scripts/mirror-images.sh (run once/rarely) │
|
||||||
|
│ Mirrors 36 third-party images to Gitea registry │
|
||||||
|
│ (postgres, redis, nocodb, jitsi, grafana, etc.) │
|
||||||
|
│ │
|
||||||
|
│ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │
|
||||||
|
│ Packages runtime files into ~9MB tarball, uploads to │
|
||||||
|
│ Gitea Releases │
|
||||||
|
└──────────────────┬─────────────────100.90.78.47──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌──────────────────┐
|
||||||
|
│ RELEASE INSTALL │ │ CCP PROVISIONED │
|
||||||
|
│ │ │ │
|
||||||
|
│ curl installer │ │ Control Panel │
|
||||||
|
│ or manual tarball│ │ creates instance │
|
||||||
|
│ → config.sh │ │ via web UI │
|
||||||
|
│ → docker compose │ │ → renders config │
|
||||||
|
│ up -d │ │ → docker compose │
|
||||||
|
│ │ │ up -d │
|
||||||
|
└─────────────────┘ └──────────────────┘
|
||||||
|
│ │
|
||||||
|
└───────────┬───────────┘
|
||||||
|
▼
|
||||||
|
All images pulled from
|
||||||
|
gitea.bnkops.com/admin
|
||||||
|
(zero external dependencies)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-Step
|
||||||
|
|
||||||
|
### 1. Local Development
|
||||||
|
|
||||||
|
Standard Docker Compose workflow with hot-reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start core services
|
||||||
|
docker compose up -d v2-postgres redis api admin
|
||||||
|
|
||||||
|
# API logs (watch for errors)
|
||||||
|
docker compose logs -f api
|
||||||
|
|
||||||
|
# Run with media API
|
||||||
|
docker compose up -d media-api
|
||||||
|
|
||||||
|
# Run with monitoring stack
|
||||||
|
docker compose --profile monitoring up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key:** `docker-compose.yml` uses `build:` blocks to compile TypeScript from source and mounts `./api:/app` for live code changes. This is the only compose file that builds from source.
|
||||||
|
|
||||||
|
### 2. Build & Push Production Images
|
||||||
|
|
||||||
|
After code changes are tested locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build production images and push to Gitea registry
|
||||||
|
./scripts/build-and-push.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds **5 services** with multi-stage Dockerfiles (production target, no dev dependencies), tags each image with `:SHA` and `:latest`, and pushes to `gitea.bnkops.com/admin/changemaker-{service}`:
|
||||||
|
|
||||||
|
| Service | Dockerfile | What it produces |
|
||||||
|
|---------|-----------|-----------------|
|
||||||
|
| `api` | `api/Dockerfile` | Express + Prisma (compiled JS, no TS) |
|
||||||
|
| `admin` | `admin/Dockerfile` | Nginx serving React build output |
|
||||||
|
| `media-api` | `api/Dockerfile.media` | Fastify + FFmpeg (compiled JS) |
|
||||||
|
| `nginx` | `nginx/Dockerfile` | Nginx with `envsubst` domain templating |
|
||||||
|
| `ccp-agent` | `../changemaker-control-panel/agent/Dockerfile` | Remote management agent (sibling repo) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build specific services only
|
||||||
|
./scripts/build-and-push.sh --services api,admin
|
||||||
|
|
||||||
|
# Build without pushing (verify first)
|
||||||
|
./scripts/build-and-push.sh --no-push
|
||||||
|
|
||||||
|
# Include code-server (~9GB, only when Dockerfile changes)
|
||||||
|
./scripts/build-and-push.sh --include-code-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Mirror Third-Party Images (Run Once / On Version Bumps)
|
||||||
|
|
||||||
|
Copies all third-party Docker images used by the platform to the Gitea registry, so deployments never depend on Docker Hub, GHCR, LSCR, or GCR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mirror all 36 images (core + platform + comms + monitoring)
|
||||||
|
./scripts/mirror-images.sh
|
||||||
|
|
||||||
|
# Mirror only essential infrastructure (postgres, redis, alpine)
|
||||||
|
./scripts/mirror-images.sh --core-only
|
||||||
|
|
||||||
|
# Preview without executing
|
||||||
|
./scripts/mirror-images.sh --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to re-run:** Only when upgrading a third-party image version. The script has explicit version pins — update the version in `mirror-images.sh`, then re-run.
|
||||||
|
|
||||||
|
Images are organized into 4 groups:
|
||||||
|
|
||||||
|
| Group | Count | Examples |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| Core Infrastructure | 5 | postgres:16-alpine, redis:7-alpine, alpine:3 |
|
||||||
|
| Platform Services | 16 | nocodb, listmonk, gitea, n8n, vaultwarden, nginx, code-server |
|
||||||
|
| Communication | 8 | rocket.chat, mongo, nats, gancio, jitsi (4 containers) |
|
||||||
|
| Monitoring | 7 | prometheus, grafana, alertmanager, cadvisor, exporters, gotify |
|
||||||
|
|
||||||
|
### 4. Build Release Tarball
|
||||||
|
|
||||||
|
Packages only runtime files (~9 MB) — no source code, no node_modules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build tarball
|
||||||
|
./scripts/build-release.sh --tag v2.2.0
|
||||||
|
|
||||||
|
# Build and upload to Gitea Releases
|
||||||
|
./scripts/build-release.sh --tag v2.2.0 --upload
|
||||||
|
|
||||||
|
# Preview contents without creating tarball
|
||||||
|
./scripts/build-release.sh --dry-run
|
||||||
|
|
||||||
|
# --upload refuses to overwrite an existing tag. To deliberately replace
|
||||||
|
# a release (destructive — users on that tag see no upgrade signal):
|
||||||
|
./scripts/build-release.sh --tag v2.2.0 --upload --replace
|
||||||
|
```
|
||||||
|
|
||||||
|
**Version hygiene:** bump the tag when changing release contents. Overwriting
|
||||||
|
an existing release silently breaks upgrade checks for users already on that
|
||||||
|
version — they see "no update available" even though the tarball they'd
|
||||||
|
download differs.
|
||||||
|
|
||||||
|
The tarball contains:
|
||||||
|
- `docker-compose.yml` (copy of `docker-compose.prod.yml` — image-only, no build blocks)
|
||||||
|
- `.env.example`, `config.sh` (configuration wizard)
|
||||||
|
- `scripts/` (init scripts, backup, upgrade, systemd units)
|
||||||
|
- `configs/` (prometheus, grafana, alertmanager, homepage, pangolin)
|
||||||
|
- `nginx/conf.d/` (templates for reference)
|
||||||
|
- `mkdocs/` (starter documentation)
|
||||||
|
- Empty data directories
|
||||||
|
|
||||||
|
### 5. Deploying
|
||||||
|
|
||||||
|
#### New Release Install (End Users)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-liner
|
||||||
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
|
|
||||||
|
# Or manual
|
||||||
|
curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz
|
||||||
|
tar xzf changemaker-lite-latest.tar.gz
|
||||||
|
cd changemaker-lite
|
||||||
|
bash config.sh
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
All images (custom + third-party) pull from `gitea.bnkops.com/admin`. No external registry access needed.
|
||||||
|
|
||||||
|
#### New CCP Instance (Fleet Operators)
|
||||||
|
|
||||||
|
The Control Panel provisions instances via its web UI:
|
||||||
|
|
||||||
|
1. Operator fills in the Create Instance wizard (domain, features, email, tunnel)
|
||||||
|
2. CCP copies source files, renders templates (Handlebars), generates secrets
|
||||||
|
3. With `USE_REGISTRY_IMAGES=true` (default): pulls pre-built images from Gitea (~2 min)
|
||||||
|
4. With `USE_REGISTRY_IMAGES=false`: builds from source (~10+ min)
|
||||||
|
5. Starts infrastructure → runs migrations → starts all services
|
||||||
|
|
||||||
|
CCP registry settings (in `changemaker-control-panel/.env`):
|
||||||
|
```bash
|
||||||
|
GITEA_REGISTRY=gitea.bnkops.com/admin # Registry URL for all images
|
||||||
|
USE_REGISTRY_IMAGES=true # true = pull pre-built, false = build from source
|
||||||
|
IMAGE_TAG=latest # Tag for custom images (api, admin, media-api)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Upgrading Existing Installations
|
||||||
|
|
||||||
|
#### Source Installs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/upgrade.sh # Standard: git pull + rebuild from source
|
||||||
|
./scripts/upgrade.sh --use-registry # Fast: pull pre-built images instead of rebuilding
|
||||||
|
./scripts/upgrade.sh --dry-run # Preview changes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Release Installs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/upgrade.sh # Auto-detects release mode, downloads latest tarball
|
||||||
|
```
|
||||||
|
|
||||||
|
Release installs are detected by the presence of a `VERSION` file and absence of `.git/`. The upgrade script automatically downloads the latest tarball from Gitea instead of running `git pull`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Naming Conventions
|
||||||
|
|
||||||
|
All images live under `gitea.bnkops.com/admin/`:
|
||||||
|
|
||||||
|
| Type | Naming Pattern | Example |
|
||||||
|
|------|---------------|---------|
|
||||||
|
| Custom services | `changemaker-{service}:{sha\|latest}` | `changemaker-api:latest` |
|
||||||
|
| Simple names | Same as upstream | `postgres:16-alpine`, `redis:7-alpine` |
|
||||||
|
| Namespaced → short | Org removed | `nocodb/nocodb` → `nocodb:0.301.3` |
|
||||||
|
| Conflict resolution | Explicit short name | `gotify/server` → `gotify`, `vaultwarden/server` → `vaultwarden` |
|
||||||
|
| Jitsi suite | `jitsi-{component}` | `jitsi-web:stable-9823`, `jitsi-prosody:stable-9823` |
|
||||||
|
| LinuxServer nginx | `ls-nginx` (avoids nginx conflict) | `ls-nginx:1.28.2` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two Compose Files
|
||||||
|
|
||||||
|
| File | Purpose | Build? | Source mounts? | Image source |
|
||||||
|
|------|---------|--------|---------------|-------------|
|
||||||
|
| `docker-compose.yml` | Development | Yes (`build:` blocks) | Yes (`./api:/app`) | Built locally |
|
||||||
|
| `docker-compose.prod.yml` | Production | No | No | `${GITEA_REGISTRY:-gitea.bnkops.com/admin}/...` |
|
||||||
|
|
||||||
|
Release tarballs ship `docker-compose.prod.yml` renamed as `docker-compose.yml`.
|
||||||
|
|
||||||
|
The CCP template (`templates/docker-compose.yml.hbs`) generates a compose file that works like `docker-compose.prod.yml` when `USE_REGISTRY_IMAGES=true`, or like `docker-compose.yml` when `false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── Development ──
|
||||||
|
docker compose up -d v2-postgres redis api admin # Start dev stack
|
||||||
|
docker compose logs -f api # Watch API logs
|
||||||
|
docker compose exec api npx prisma migrate dev # Create migration
|
||||||
|
|
||||||
|
# ── Build & Publish ──
|
||||||
|
./scripts/build-and-push.sh # Build + push 5 images
|
||||||
|
./scripts/mirror-images.sh # Mirror 36 third-party images
|
||||||
|
git tag --sort=-v:refname | head -3 # Check latest version tags
|
||||||
|
./scripts/build-release.sh --tag vX.Y.Z --upload # Package + upload release
|
||||||
|
|
||||||
|
# ── Deploy ──
|
||||||
|
curl -fsSL .../install.sh | bash # New install (release)
|
||||||
|
./scripts/upgrade.sh # Upgrade existing install
|
||||||
|
./scripts/upgrade.sh --use-registry # Fast upgrade (registry images)
|
||||||
|
|
||||||
|
# ── Verify ──
|
||||||
|
curl -s http://localhost:4000/api/health # API health check
|
||||||
|
docker compose ps # Container status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gitea API Tokens
|
||||||
|
|
||||||
|
There are **two separate Gitea tokens** with different purposes. Using the wrong one is a common mistake:
|
||||||
|
|
||||||
|
| Variable | Target | Used by | Create at |
|
||||||
|
|----------|--------|---------|-----------|
|
||||||
|
| `GITEA_REGISTRY_API_TOKEN` | Remote registry (`gitea.bnkops.com`) | `build-release.sh --upload`, release API calls | `https://gitea.bnkops.com/user/settings/applications` |
|
||||||
|
| `GITEA_API_TOKEN` | Local Gitea instance | Docs comments, user provisioning, SSO | `http://localhost:3030/user/settings/applications` |
|
||||||
|
|
||||||
|
**Key:** Release uploads and the Gitea Releases API require `GITEA_REGISTRY_API_TOKEN`. If you get `"user does not exist"` from the API, you're using the wrong token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist: Cutting a New Release
|
||||||
|
|
||||||
|
1. [ ] All code changes committed and pushed to `main` branch
|
||||||
|
2. [ ] `docker compose up -d` works locally (smoke test)
|
||||||
|
3. [ ] **Determine version tag:**
|
||||||
|
```bash
|
||||||
|
# Check the latest existing tag to pick the next version
|
||||||
|
git tag --sort=-v:refname | head -5
|
||||||
|
# Check commits since the last tag
|
||||||
|
git log $(git tag --sort=-v:refname | head -1)..HEAD --oneline
|
||||||
|
```
|
||||||
|
4. [ ] `./scripts/build-and-push.sh` — builds and pushes 5 production images
|
||||||
|
5. [ ] `./scripts/mirror-images.sh` — only if third-party versions changed
|
||||||
|
6. [ ] `./scripts/build-release.sh --tag vX.Y.Z --upload` — packages and uploads tarball
|
||||||
|
7. [ ] **Add release notes** (via Gitea web UI or API):
|
||||||
|
```bash
|
||||||
|
# Update release body via API (use GITEA_REGISTRY_API_TOKEN, not GITEA_API_TOKEN)
|
||||||
|
GITEA_TOKEN=$(grep -oP 'GITEA_REGISTRY_API_TOKEN=\K.*' .env)
|
||||||
|
# Find release ID
|
||||||
|
curl -s "https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases?limit=1" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" | python3 -c "import sys,json; r=json.load(sys.stdin)[0]; print(f'ID: {r[\"id\"]}, Tag: {r[\"tag_name\"]}')"
|
||||||
|
# Update with release notes (write JSON body to /tmp/release-notes.json first)
|
||||||
|
curl -s -X PATCH "https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases/RELEASE_ID" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @/tmp/release-notes.json
|
||||||
|
```
|
||||||
|
8. [ ] Test clean install: `tar xzf ... && cd changemaker-lite && bash config.sh && docker compose up -d`
|
||||||
|
9. [ ] Test upgrade: `./scripts/upgrade.sh` on an existing installation
|
||||||
|
10. [ ] Verify: `curl http://localhost:4000/api/health` returns `{"status":"ok"}`
|
||||||
@ -2,17 +2,13 @@ FROM codercom/code-server:latest
|
|||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
# Install Node.js 18+ and npm
|
# Install Node.js (for npm/claude-code — code-server bundles its own node but doesn't expose it)
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
RUN apt-get update && apt-get install -y nodejs npm --no-install-recommends \
|
||||||
&& apt-get install -y nodejs
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Claude Code globally as root
|
# Install Claude Code globally
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
# Install Ollama (needs zstd for extraction)
|
|
||||||
RUN apt-get update && apt-get install -y zstd && rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& curl -fsSL https://ollama.com/install.sh | sh
|
|
||||||
|
|
||||||
# Install Python and dependencies
|
# Install Python and dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
python3 \
|
python3 \
|
||||||
|
|||||||
@ -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
|
|
||||||
192
README.md
192
README.md
@ -1,84 +1,172 @@
|
|||||||
# Changemaker Lite
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/logo.png" alt="Changemaker Lite" width="120" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A self-hosted political campaign platform that consolidates advocacy email campaigns, geographic mapping, volunteer canvassing, media management, and administration into a single TypeScript stack. Built for organizers who want to own their data.
|
<h1 align="center">Changemaker Lite</h1>
|
||||||
|
|
||||||
## What Is This?
|
<p align="center">
|
||||||
|
A self-hosted campaign platform for community organizers who want to own their data.
|
||||||
|
</p>
|
||||||
|
|
||||||
Changemaker Lite gives community organizers the tools they need to:
|
<p align="center">
|
||||||
|
<a href="https://cmlite.org/docs/getting-started/">Documentation</a> ·
|
||||||
|
<a href="https://cmlite.org">Website</a> ·
|
||||||
|
<a href="https://opensource.org/license/apache-2-0">Apache 2.0 License</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
- **Run advocacy campaigns** — let supporters look up their elected representatives by postal code and send emails in a few clicks
|
---
|
||||||
- **Manage canvassing** — map locations, draw canvassing areas, schedule volunteer shifts, and track door-to-door visits with GPS
|
|
||||||
- **Host media** — upload campaign videos, share them publicly, and track engagement analytics
|
|
||||||
- **Build landing pages** — drag-and-drop page builder for campaign microsites
|
|
||||||
- **Send newsletters** — integrated with Listmonk for opt-in mailing lists
|
|
||||||
- **Monitor everything** — Prometheus + Grafana observability stack included
|
|
||||||
|
|
||||||
The entire platform runs on Docker Compose with a single `.env` file for configuration.
|
Changemaker Lite consolidates advocacy campaigns, geographic mapping, volunteer canvassing, media management, newsletters, and administration into a single Docker Compose stack. One `.env` file, one command to start, everything under your control.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/admin-dashboard.png" alt="Admin Dashboard" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Why Changemaker Lite?
|
||||||
|
|
||||||
|
Most campaign tools are SaaS platforms that lock you into monthly subscriptions, hold your data hostage, and disappear when funding dries up. Changemaker Lite is different:
|
||||||
|
|
||||||
|
- **Self-hosted** -- runs on any machine with Docker. Your server, your data.
|
||||||
|
- **All-in-one** -- replaces 5-10 separate tools with a single integrated platform.
|
||||||
|
- **Free and open source** -- Apache 2.0 licensed. Fork it, modify it, make it yours.
|
||||||
|
- **Privacy-first** -- no telemetry, no third-party analytics, no data leaving your server.
|
||||||
|
|
||||||
|
## What's Inside
|
||||||
|
|
||||||
|
### Advocacy Campaigns
|
||||||
|
|
||||||
|
Let supporters look up their elected representatives by postal code and send advocacy emails in a few clicks. Track responses, moderate a public response wall, and monitor email delivery.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/public-campaigns.png" alt="Public Campaign Page" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/influence-campaigns.png" alt="Campaign Management" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Interactive Map & Canvassing
|
||||||
|
|
||||||
|
Import thousands of addresses, draw canvassing areas, schedule volunteer shifts, and track door-to-door visits with GPS. Volunteers get a full-screen mobile map with real-time location tracking and visit recording.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/public-map.png" alt="Public Map" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/canvass-dashboard.png" alt="Canvass Dashboard" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Volunteer Portal
|
||||||
|
|
||||||
|
Volunteers get their own portal with shift sign-ups, canvassing assignments, activity tracking, a social calendar, and a friends system to stay connected with their team.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/volunteer-dashboard.png" alt="Volunteer Map" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/volunteer-calendar.png" alt="Volunteer Calendar" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Media Library & Public Gallery
|
||||||
|
|
||||||
|
Upload campaign videos, manage metadata, schedule publishing, and share them through a public gallery. Includes GDPR-compliant analytics.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/media-library.png" alt="Media Library" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/public-gallery.png" alt="Public Gallery" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Landing Pages & Email Templates
|
||||||
|
|
||||||
|
Build campaign microsites with a drag-and-drop GrapesJS editor. Design email templates for consistent campaign communications.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/landing-pages.png" alt="Landing Page Builder" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### SMS Campaigns, Newsletters & More
|
||||||
|
|
||||||
|
Send SMS campaigns via an Android bridge, sync subscribers to Listmonk for newsletters, recognize volunteers on a Wall of Fame leaderboard, and monitor everything with built-in Prometheus + Grafana observability.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/sms-dashboard.png" alt="SMS Dashboard" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="mkdocs/docs/assets/images/screenshots/features/public-wall-of-fame.png" alt="Wall of Fame" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Production (pre-built images)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. One-command install: checks host ports, downloads tarball, runs config wizard
|
||||||
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
|
|
||||||
|
# 2. Start services (first pull ~3 min + ~90s stabilization)
|
||||||
|
cd ~/changemaker.lite && docker compose up -d
|
||||||
|
|
||||||
|
# 3. Verify the install
|
||||||
|
bash scripts/test-deployment.sh --wait 60
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer checks your host's port availability before extracting — no more half-started stacks from cockpit on `:9090` or other surprises. The generated admin password is printed to stdout **and** saved to `data/admin-credentials.txt` (mode 0600). See [Prerequisites](https://cmlite.org/docs/getting-started/prerequisites/) for what you need lined up first.
|
||||||
|
|
||||||
|
### Development (source)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and switch to the v2 branch
|
|
||||||
git clone <repo-url> changemaker.lite
|
git clone <repo-url> changemaker.lite
|
||||||
cd changemaker.lite
|
cd changemaker.lite
|
||||||
git checkout v2
|
|
||||||
|
|
||||||
# Create your environment file
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env — at minimum set:
|
# Edit .env -- set passwords, JWT secrets, admin credentials
|
||||||
# V2_POSTGRES_PASSWORD, REDIS_PASSWORD,
|
|
||||||
# JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY
|
|
||||||
# INITIAL_ADMIN_EMAIL, INITIAL_ADMIN_PASSWORD
|
|
||||||
|
|
||||||
# Start core services
|
|
||||||
docker compose up -d v2-postgres redis api admin
|
docker compose up -d v2-postgres redis api admin
|
||||||
|
|
||||||
# Run database migrations and seed
|
|
||||||
docker compose exec api npx prisma migrate deploy
|
docker compose exec api npx prisma migrate deploy
|
||||||
docker compose exec api npx prisma db seed
|
docker compose exec api npx prisma db seed
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open **http://localhost:3000** and log in with the admin credentials from your `.env`.
|
Then open **http://localhost:3000** and log in with the admin credentials from your `.env`.
|
||||||
|
|
||||||
## Architecture
|
### Useful tools
|
||||||
|
|
||||||
| Component | Technology | Port |
|
|
||||||
|-----------|-----------|------|
|
|
||||||
| **API** | Express.js + Prisma + PostgreSQL | 4000 |
|
|
||||||
| **Media API** | Fastify + Prisma (shared DB) | 4100 |
|
|
||||||
| **Admin GUI** | React + Vite + Ant Design + Zustand | 3000 |
|
|
||||||
| **Reverse Proxy** | Nginx (subdomain routing) | 80 |
|
|
||||||
| **Database** | PostgreSQL 16 | 5433 |
|
|
||||||
| **Cache / Queue** | Redis + BullMQ | 6379 |
|
|
||||||
| **Newsletter** | Listmonk | 9001 |
|
|
||||||
| **Monitoring** | Prometheus + Grafana + Alertmanager | 9090, 3001 |
|
|
||||||
|
|
||||||
See `CLAUDE.md` for comprehensive architecture documentation, module reference, and troubleshooting.
|
|
||||||
|
|
||||||
## Feature Flags
|
|
||||||
|
|
||||||
Enable optional modules in `.env`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ENABLE_MEDIA_FEATURES=true # Video library + gallery
|
bash scripts/validate-env.sh # re-check .env + host ports
|
||||||
LISTMONK_SYNC_ENABLED=true # Newsletter subscriber sync
|
bash scripts/test-deployment.sh # full deployment health sweep
|
||||||
EMAIL_TEST_MODE=true # Route emails to MailHog (dev)
|
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
Changemaker Lite uses [Pangolin](https://github.com/fosrl/pangolin) tunnels for production access (Cloudflare alternative). See the Tunnel page in the admin panel (`/app/tunnel`) for setup instructions.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- **`CLAUDE.md`** — Full project reference (architecture, modules, ports, patterns)
|
**Full documentation is available at [cmlite.org/docs/getting-started](https://cmlite.org/docs/getting-started/).**
|
||||||
- **`V2_PLAN.md`** — Development roadmap (Phases 1-14 complete)
|
|
||||||
- **`SECURITY_AUDIT_2025-02-11.md`** — Security audit findings and remediations
|
|
||||||
- **`.env.example`** — All 100+ environment variables with descriptions
|
|
||||||
|
|
||||||
## Licensing
|
The docs site covers installation, configuration, all features, architecture details, production deployment with Pangolin tunnels, and troubleshooting. It is the authoritative and up-to-date reference for Changemaker Lite.
|
||||||
|
|
||||||
This project is licensed under the [Apache License 2.0](https://opensource.org/license/apache-2-0).
|
## Architecture at a Glance
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| API | Express.js + Prisma + PostgreSQL 16 |
|
||||||
|
| Media API | Fastify + Prisma (shared DB) |
|
||||||
|
| Frontend | React + Vite + Ant Design + Zustand |
|
||||||
|
| Reverse Proxy | Nginx (subdomain routing) |
|
||||||
|
| Cache & Queue | Redis + BullMQ |
|
||||||
|
| Newsletter | Listmonk |
|
||||||
|
| Monitoring | Prometheus + Grafana + Alertmanager |
|
||||||
|
| Tunneling | Pangolin (self-hosted Cloudflare alternative) |
|
||||||
|
|
||||||
|
The entire stack runs on Docker Compose. Enable optional modules (media, newsletters, SMS, monitoring) with feature flags in `.env`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[Apache License 2.0](https://opensource.org/license/apache-2-0)
|
||||||
|
|
||||||
## AI Disclaimer
|
## AI Disclaimer
|
||||||
|
|
||||||
|
|||||||
@ -1,569 +0,0 @@
|
|||||||
# Social Calendar Feature Plan
|
|
||||||
|
|
||||||
**Created:** 2026-03-06
|
|
||||||
**Status:** Planning Complete — Ready for Phase A Implementation
|
|
||||||
**Branch:** v2
|
|
||||||
**Feature Flag:** `enableSocialCalendar` (new, under SiteSettings)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A layered personal and social calendar system. Each user gets their own calendar with multiple layers (system-populated, user-created, external feeds). Calendars can be shared between users at the item, category (layer), or whole-calendar level. Shared views allow multiple users' events to appear on a merged, color-coded calendar. Admin shared views can auto-include users by role.
|
|
||||||
|
|
||||||
### Design Principles
|
|
||||||
|
|
||||||
- **Layers are the core abstraction** — every event belongs to a layer, layers control visibility and sharing
|
|
||||||
- **System layers are virtual** — shifts, tickets, polls are queried live from source tables, not duplicated
|
|
||||||
- **Recurrence uses materialization** — consistent with existing ShiftSeries pattern (generate DB rows, allow exceptions)
|
|
||||||
- **Social-first** — friend relationships gate sharing; admin views are separate and only expose system data
|
|
||||||
- **Privacy by default** — layers default to PRIVATE; users explicitly opt into sharing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### CalendarLayer
|
|
||||||
|
|
||||||
Each user has multiple layers. System layers are auto-created on first calendar access.
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | String (cuid) | PK |
|
|
||||||
| userId | String | FK to User |
|
|
||||||
| name | String | "Personal", "Gym", "Google Cal", etc. |
|
|
||||||
| layerType | Enum | SYSTEM, USER, EXTERNAL |
|
|
||||||
| systemType | Enum? | SHIFTS, TICKETS, POLLS, PUBLIC_EVENTS (for SYSTEM layers only) |
|
|
||||||
| color | String | Hex color (#1890ff) |
|
|
||||||
| visibility | Enum | PRIVATE, FRIENDS, PUBLIC |
|
|
||||||
| isEnabled | Boolean | User can toggle layers on/off for themselves |
|
|
||||||
| sortOrder | Int | Display ordering |
|
|
||||||
| createdAt | DateTime | |
|
|
||||||
| updatedAt | DateTime | |
|
|
||||||
|
|
||||||
**System layers (auto-created per user):**
|
|
||||||
- My Shifts — from ShiftSignup records
|
|
||||||
- My Tickets — from EventTicket records
|
|
||||||
- My Polls — from SchedulingPollVote records
|
|
||||||
- Public Events — the existing Gancio/platform feed (togglable)
|
|
||||||
|
|
||||||
System layers are **virtual** — no CalendarItem rows are created. The API queries source tables directly and maps to the CalendarItem shape at response time.
|
|
||||||
|
|
||||||
### CalendarItem
|
|
||||||
|
|
||||||
User-created events, time blocks, and cached .ics feed entries.
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | String (cuid) | PK |
|
|
||||||
| userId | String | FK to User (owner) |
|
|
||||||
| layerId | String | FK to CalendarLayer |
|
|
||||||
| title | String | |
|
|
||||||
| description | String? | Text |
|
|
||||||
| date | DateTime | Date of this occurrence |
|
|
||||||
| startTime | String | HH:MM |
|
|
||||||
| endTime | String | HH:MM |
|
|
||||||
| isAllDay | Boolean | Default false |
|
|
||||||
| itemType | Enum | EVENT, TIME_BLOCK, REMINDER |
|
|
||||||
| location | String? | |
|
|
||||||
| color | String? | Override (null = inherit layer color) |
|
|
||||||
| visibility | Enum? | PRIVATE, FRIENDS, PUBLIC (null = inherit from layer) |
|
|
||||||
| busyStatus | Enum | BUSY, TENTATIVE, FREE (default BUSY) |
|
|
||||||
| showDetailsTo | Enum | NOBODY, FRIENDS, EVERYONE (default FRIENDS) |
|
|
||||||
| recurrenceRule | Json? | See Recurrence section |
|
|
||||||
| recurrenceEnd | DateTime? | When series stops |
|
|
||||||
| seriesId | String? | Groups recurring instances |
|
|
||||||
| isException | Boolean | Edited instance that broke from pattern |
|
|
||||||
| sourceType | Enum | MANUAL, ICS_FEED |
|
|
||||||
| sourceId | String? | External reference (ics UID, etc.) |
|
|
||||||
| createdAt | DateTime | |
|
|
||||||
| updatedAt | DateTime | |
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- System-layer items (shifts, tickets, polls) are NOT stored as CalendarItem rows — they're virtual
|
|
||||||
- .ics feed items ARE stored as CalendarItem rows (cached from external source, read-only to user)
|
|
||||||
- MANUAL items are user-created freeform events
|
|
||||||
|
|
||||||
### CalendarFeed (.ics import)
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | String (cuid) | PK |
|
|
||||||
| userId | String | FK to User |
|
|
||||||
| name | String | "Google Calendar", "Work" |
|
|
||||||
| url | String | .ics URL |
|
|
||||||
| layerId | String | FK to auto-created CalendarLayer |
|
|
||||||
| refreshInterval | Enum | FIFTEEN_MIN, HOURLY, SIX_HOUR, DAILY |
|
|
||||||
| lastFetchedAt | DateTime? | |
|
|
||||||
| lastStatus | Enum | OK, ERROR, PENDING |
|
|
||||||
| lastError | String? | Error message if failed |
|
|
||||||
| itemCount | Int | How many items imported |
|
|
||||||
| createdAt | DateTime | |
|
|
||||||
| updatedAt | DateTime | |
|
|
||||||
|
|
||||||
### SharedCalendarView
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | String (cuid) | PK |
|
|
||||||
| name | String | "Weekend Crew", "All Shift Admins" |
|
|
||||||
| description | String? | |
|
|
||||||
| ownerId | String | FK to User (creator) |
|
|
||||||
| viewType | Enum | MANUAL, ROLE_BASED |
|
|
||||||
| autoIncludeRoles | Json? | ["MAP_ADMIN", "USER"] (for ROLE_BASED) |
|
|
||||||
| includedLayerTypes | Json | ["shifts", "tickets", "personal-public"] |
|
|
||||||
| shareScope | Enum | MEMBERS, PUBLIC |
|
|
||||||
| shareToken | String? | Unique token for public share URL |
|
|
||||||
| createdAt | DateTime | |
|
|
||||||
| updatedAt | DateTime | |
|
|
||||||
|
|
||||||
**ROLE_BASED views:**
|
|
||||||
- Auto-include users matching specified roles
|
|
||||||
- Only pull system layers (shifts, tickets, polls) — never personal layers
|
|
||||||
- No notifications sent to included users (admin operational tool)
|
|
||||||
- Created/managed by SUPER_ADMIN or MAP_ADMIN
|
|
||||||
|
|
||||||
**MANUAL views:**
|
|
||||||
- Members are explicitly invited via notification system
|
|
||||||
- Can include personal layers (with member consent)
|
|
||||||
- Members can decline/leave
|
|
||||||
|
|
||||||
### SharedCalendarMember
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | String (cuid) | PK |
|
|
||||||
| viewId | String | FK to SharedCalendarView |
|
|
||||||
| userId | String | FK to User |
|
|
||||||
| status | Enum | INVITED, ACCEPTED, DECLINED |
|
|
||||||
| color | String | Auto-assigned from palette |
|
|
||||||
| joinedAt | DateTime? | |
|
|
||||||
| @@unique | [viewId, userId] | |
|
|
||||||
|
|
||||||
**Auto-color palette:**
|
|
||||||
```
|
|
||||||
#1890ff (blue), #52c41a (green), #fa8c16 (orange), #722ed1 (purple),
|
|
||||||
#eb2f96 (pink), #13c2c2 (cyan), #faad14 (gold), #f5222d (red),
|
|
||||||
#2f54eb (geekblue), #a0d911 (lime)
|
|
||||||
```
|
|
||||||
Assigned sequentially as members join: `PALETTE[memberIndex % length]`.
|
|
||||||
Users can override their assigned color per shared view.
|
|
||||||
|
|
||||||
### SharedViewComment
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | String (cuid) | PK |
|
|
||||||
| viewId | String | FK to SharedCalendarView |
|
|
||||||
| userId | String | FK to User |
|
|
||||||
| itemDate | String | YYYY-MM-DD (which date this comment is about) |
|
|
||||||
| itemId | String? | Optional: specific CalendarItem or source item ID |
|
|
||||||
| content | String | Text |
|
|
||||||
| createdAt | DateTime | |
|
|
||||||
|
|
||||||
### SharedViewReaction
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | String (cuid) | PK |
|
|
||||||
| viewId | String | FK to SharedCalendarView |
|
|
||||||
| userId | String | FK to User |
|
|
||||||
| itemId | String | CalendarItem or source item ID (e.g., "shift-abc123") |
|
|
||||||
| emoji | String | Single emoji or shortcode |
|
|
||||||
| createdAt | DateTime | |
|
|
||||||
| @@unique | [viewId, userId, itemId, emoji] | One reaction type per user per item |
|
|
||||||
|
|
||||||
### CalendarExportToken (.ics export)
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | String (cuid) | PK |
|
|
||||||
| userId | String | FK to User |
|
|
||||||
| token | String | Unique, random (for URL auth) |
|
|
||||||
| includePersonal | Boolean | Whether personal events are exported |
|
|
||||||
| includeLayers | Json? | Array of layer IDs (null = all enabled) |
|
|
||||||
| createdAt | DateTime | |
|
|
||||||
|
|
||||||
Export URL: `GET /api/calendar/feed/:userId/:token.ics`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recurrence Model
|
|
||||||
|
|
||||||
Uses **materialization** (consistent with existing ShiftSeries pattern):
|
|
||||||
|
|
||||||
1. User creates a recurring event with a recurrence rule
|
|
||||||
2. System generates CalendarItem rows for the next 3 months
|
|
||||||
3. Background job (BullMQ, daily) extends series forward by 1 month
|
|
||||||
4. Individual instances can be edited (becomes `isException: true`) or deleted
|
|
||||||
5. Editing the series template updates all non-exception future instances
|
|
||||||
|
|
||||||
### Recurrence Rule JSON
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"frequency": "DAILY | WEEKLY | BIWEEKLY | MONTHLY",
|
|
||||||
"daysOfWeek": [1, 3, 5],
|
|
||||||
"dayOfMonth": 15,
|
|
||||||
"interval": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `WEEKLY` + `daysOfWeek: [1,3,5]` = every Mon/Wed/Fri
|
|
||||||
- `MONTHLY` + `dayOfMonth: 15` = 15th of every month
|
|
||||||
- `BIWEEKLY` + `daysOfWeek: [2,4]` = every other Tue/Thu
|
|
||||||
- `interval` for skip patterns (every 2 weeks, every 3 months)
|
|
||||||
|
|
||||||
### Recurrence Edit Options (UI)
|
|
||||||
|
|
||||||
When editing a recurring event instance:
|
|
||||||
- "This event only" — marks as exception, edits the single instance
|
|
||||||
- "This and future events" — updates template + regenerates future non-exception instances
|
|
||||||
- "All events in series" — updates template + all instances (including past, excluding exceptions)
|
|
||||||
|
|
||||||
When deleting:
|
|
||||||
- "This event only" — soft-delete the single instance
|
|
||||||
- "This and future events" — delete future instances, set recurrenceEnd on template
|
|
||||||
- "All events" — delete entire series
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Time Block Visibility (Configurable per item)
|
|
||||||
|
|
||||||
| `showDetailsTo` | Friends see | Public sees |
|
|
||||||
|-----------------|-------------|-------------|
|
|
||||||
| NOBODY | "Busy 2-4pm" | "Busy 2-4pm" |
|
|
||||||
| FRIENDS | "Dentist 2-4pm" | "Busy 2-4pm" |
|
|
||||||
| EVERYONE | "Dentist 2-4pm" | "Dentist 2-4pm" |
|
|
||||||
|
|
||||||
Combined with `busyStatus`:
|
|
||||||
- **BUSY** — solid color block
|
|
||||||
- **TENTATIVE** — dashed/lighter block
|
|
||||||
- **FREE** — no block shown (informational only, e.g., "Available for meetings")
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notification Types (reusing existing system)
|
|
||||||
|
|
||||||
| Type | Message | Trigger |
|
|
||||||
|------|---------|---------|
|
|
||||||
| SHARED_VIEW_INVITE | "Alice invited you to 'Weekend Crew' calendar" | Manual shared view invite |
|
|
||||||
| SHARED_VIEW_ACCEPTED | "Bob accepted your invite to 'Weekend Crew'" | Member accepts |
|
|
||||||
| CALENDAR_EVENT_INVITE | "Alice added you to 'Planning Meeting' on Mar 10" | Phase B: event-level sharing |
|
|
||||||
| CALENDAR_REMINDER | "Reminder: Team standup in 15 minutes" | Future: optional reminders |
|
|
||||||
|
|
||||||
Role-based admin views do NOT trigger notifications (admin operational tool using only system data).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Availability Finder (Phase B)
|
|
||||||
|
|
||||||
A dedicated mode within shared calendar views:
|
|
||||||
|
|
||||||
1. Toggle "Find Available Time" on a shared view
|
|
||||||
2. System overlays all members' BUSY/TENTATIVE time blocks
|
|
||||||
3. Highlights gaps where ALL members are free
|
|
||||||
4. Optional: filter by time range ("only show weekday 9am-5pm slots")
|
|
||||||
5. Click a free slot to create an event and auto-invite all members
|
|
||||||
|
|
||||||
Visual: green highlight on free slots, red/orange on conflicts, member avatars on busy blocks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Routes
|
|
||||||
|
|
||||||
### Phase A (Personal Calendar)
|
|
||||||
|
|
||||||
```
|
|
||||||
# Layers
|
|
||||||
GET /api/calendar/layers — list user's layers
|
|
||||||
POST /api/calendar/layers — create custom layer
|
|
||||||
PATCH /api/calendar/layers/:id — update layer (name, color, visibility, enabled)
|
|
||||||
DELETE /api/calendar/layers/:id — delete custom layer (+ its items)
|
|
||||||
|
|
||||||
# Calendar Items
|
|
||||||
GET /api/calendar/items — list items in date range (all enabled layers merged)
|
|
||||||
POST /api/calendar/items — create item (event, time block, reminder)
|
|
||||||
PATCH /api/calendar/items/:id — update item
|
|
||||||
DELETE /api/calendar/items/:id — delete item
|
|
||||||
|
|
||||||
# Recurrence
|
|
||||||
POST /api/calendar/items/:id/series — edit series (this-only, this-and-future, all)
|
|
||||||
DELETE /api/calendar/items/:id/series — delete series (this-only, this-and-future, all)
|
|
||||||
|
|
||||||
# Unified personal view (merges system layers + user items)
|
|
||||||
GET /api/calendar/my — personal calendar (date range, layer filters)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase B (Sharing + Social)
|
|
||||||
|
|
||||||
```
|
|
||||||
# Shared Views
|
|
||||||
GET /api/calendar/shared — list shared views I own or am a member of
|
|
||||||
POST /api/calendar/shared — create shared view
|
|
||||||
PATCH /api/calendar/shared/:id — update shared view
|
|
||||||
DELETE /api/calendar/shared/:id — delete shared view (owner only)
|
|
||||||
|
|
||||||
# Members
|
|
||||||
POST /api/calendar/shared/:id/invite — invite user(s) to shared view
|
|
||||||
PATCH /api/calendar/shared/:id/respond — accept/decline invite
|
|
||||||
DELETE /api/calendar/shared/:id/leave — leave a shared view
|
|
||||||
GET /api/calendar/shared/:id/members — list members + colors
|
|
||||||
|
|
||||||
# Merged calendar data
|
|
||||||
GET /api/calendar/shared/:id/items — merged items from all members
|
|
||||||
|
|
||||||
# Event-level sharing
|
|
||||||
POST /api/calendar/items/:id/share — share specific item with friend(s)
|
|
||||||
|
|
||||||
# Comments & Reactions (on shared views)
|
|
||||||
GET /api/calendar/shared/:id/comments?date=YYYY-MM-DD
|
|
||||||
POST /api/calendar/shared/:id/comments
|
|
||||||
DELETE /api/calendar/shared/:id/comments/:commentId
|
|
||||||
POST /api/calendar/shared/:id/reactions
|
|
||||||
DELETE /api/calendar/shared/:id/reactions/:reactionId
|
|
||||||
|
|
||||||
# Availability finder
|
|
||||||
GET /api/calendar/shared/:id/availability?start=&end=&dayStart=09:00&dayEnd=17:00
|
|
||||||
|
|
||||||
# Friend's public calendar
|
|
||||||
GET /api/calendar/user/:userId — view a friend's public items
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase C (.ics Integration)
|
|
||||||
|
|
||||||
```
|
|
||||||
# Feeds (import)
|
|
||||||
GET /api/calendar/feeds — list user's subscribed feeds
|
|
||||||
POST /api/calendar/feeds — subscribe to .ics URL
|
|
||||||
PATCH /api/calendar/feeds/:id — update feed settings
|
|
||||||
DELETE /api/calendar/feeds/:id — unsubscribe (deletes layer + cached items)
|
|
||||||
POST /api/calendar/feeds/:id/refresh — force refresh now
|
|
||||||
|
|
||||||
# Export
|
|
||||||
GET /api/calendar/export/token — get or create export token
|
|
||||||
DELETE /api/calendar/export/token — revoke export token
|
|
||||||
GET /api/calendar/feed/:userId/:token.ics — public .ics feed (no auth, token in URL)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase D (Admin Shared Views)
|
|
||||||
|
|
||||||
```
|
|
||||||
# Admin role-based views (requireRole: SUPER_ADMIN, MAP_ADMIN)
|
|
||||||
POST /api/admin/calendar/shared — create role-based shared view
|
|
||||||
PATCH /api/admin/calendar/shared/:id — update
|
|
||||||
DELETE /api/admin/calendar/shared/:id — delete
|
|
||||||
GET /api/admin/calendar/shared/:id/items — merged system-layer data for matching users
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Pages & Components
|
|
||||||
|
|
||||||
### Phase A
|
|
||||||
|
|
||||||
| Component | Location | Description |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| MyCalendarPage | `volunteer/MyCalendarPage.tsx` | Personal calendar (main view) |
|
|
||||||
| CalendarLayerPanel | `components/calendar/CalendarLayerPanel.tsx` | Sidebar: layer list with toggles, colors, visibility |
|
|
||||||
| CalendarItemModal | `components/calendar/CalendarItemModal.tsx` | Create/edit event, time block, or reminder |
|
|
||||||
| RecurrenceEditor | `components/calendar/RecurrenceEditor.tsx` | Recurrence rule builder (frequency, days, end date) |
|
|
||||||
| PersonalCalendarView | `components/calendar/PersonalCalendarView.tsx` | Month/week/day calendar with layer color-coding |
|
|
||||||
| MobileDayView | `components/calendar/MobileDayView.tsx` | Day/3-day swipeable view for mobile |
|
|
||||||
|
|
||||||
**Mobile UX:** Day or 3-day swipeable view (not full month grid). Swipe left/right to navigate days. Layer toggles in a collapsible bottom sheet.
|
|
||||||
|
|
||||||
### Phase B
|
|
||||||
|
|
||||||
| Component | Location | Description |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| SharedCalendarsPage | `volunteer/SharedCalendarsPage.tsx` | List of shared views I'm in |
|
|
||||||
| SharedCalendarView | `components/calendar/SharedCalendarView.tsx` | Merged multi-user calendar with member colors |
|
|
||||||
| SharedViewMembersPanel | `components/calendar/SharedViewMembersPanel.tsx` | Member list, color overrides, invite button |
|
|
||||||
| AvailabilityFinder | `components/calendar/AvailabilityFinder.tsx` | Free/busy overlay with slot highlighting |
|
|
||||||
| CalendarComments | `components/calendar/CalendarComments.tsx` | Comment thread for a date in shared view |
|
|
||||||
| CalendarReactions | `components/calendar/CalendarReactions.tsx` | Emoji reactions on items |
|
|
||||||
| FriendCalendarPage | `volunteer/FriendCalendarPage.tsx` | View a friend's public calendar |
|
|
||||||
|
|
||||||
### Phase C
|
|
||||||
|
|
||||||
| Component | Location | Description |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| CalendarFeedsPanel | `components/calendar/CalendarFeedsPanel.tsx` | Manage .ics subscriptions |
|
|
||||||
| CalendarExportPanel | `components/calendar/CalendarExportPanel.tsx` | Export token management, copy URL |
|
|
||||||
|
|
||||||
### Phase D
|
|
||||||
|
|
||||||
| Component | Location | Description |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| AdminSharedViewsPage | `pages/AdminSharedViewsPage.tsx` | Admin: create/manage role-based views |
|
|
||||||
| AdminCalendarOverview | `components/calendar/AdminCalendarOverview.tsx` | Big shift/event overview for admins |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Navigation & Routing
|
|
||||||
|
|
||||||
### Volunteer Portal
|
|
||||||
- Footer nav: add "Calendar" tab (CalendarOutlined icon)
|
|
||||||
- `/volunteer/calendar` — MyCalendarPage
|
|
||||||
- `/volunteer/calendar/shared` — SharedCalendarsPage
|
|
||||||
- `/volunteer/calendar/shared/:id` — SharedCalendarView
|
|
||||||
- `/volunteer/calendar/friend/:userId` — FriendCalendarPage
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
- Sidebar under existing section: "Calendar Overview"
|
|
||||||
- `/app/calendar/shared` — AdminSharedViewsPage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase Breakdown
|
|
||||||
|
|
||||||
### Phase A: Personal Calendar + Layers + Freeform Events
|
|
||||||
**Scope:**
|
|
||||||
- [x] Prisma models: CalendarLayer, CalendarItem, CalendarFeed, SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction, CalendarExportToken (+ 12 enums)
|
|
||||||
- [x] Auto-create system layers on first calendar access (ensureSystemLayers)
|
|
||||||
- [x] CalendarItem CRUD (create, read, update, delete)
|
|
||||||
- [x] Recurrence: create series (materialize 3 months), edit/delete with scope options (THIS_ONLY/THIS_AND_FUTURE/ALL)
|
|
||||||
- [ ] BullMQ job: extend recurring series daily (add 1 month of future instances)
|
|
||||||
- [x] Personal calendar API: GET /api/calendar/my (merge system layers + user items)
|
|
||||||
- [x] System layer queries: shifts (from ShiftSignup), tickets (from Ticket), polls (from SchedulingPollVote)
|
|
||||||
- [x] Layer CRUD: create custom layers, toggle on/off, set color
|
|
||||||
- [x] Layer visibility settings (PRIVATE/FRIENDS/PUBLIC) — stored but not enforced until Phase B
|
|
||||||
- [x] MyCalendarPage: month view (desktop), day/3-day view (mobile)
|
|
||||||
- [x] CalendarLayerPanel: sidebar with layer toggles, color pickers, inline editing, grouped by type
|
|
||||||
- [x] CalendarItemModal: create/edit form with item type, recurrence, time block settings, scope selector
|
|
||||||
- [x] RecurrenceEditor: frequency/days/interval/end-date with preview text
|
|
||||||
- [x] PersonalCalendarView: desktop month view with layer-colored items
|
|
||||||
- [x] MobileDayView: day view with time grid, current time indicator, floating add button
|
|
||||||
- [x] Volunteer footer nav: "Calendar" tab (gated behind enableSocialCalendar)
|
|
||||||
- [x] Feature flag: enableSocialCalendar in SiteSettings, Zod schema, frontend types, FeatureGate
|
|
||||||
- [x] Settings page toggle added ("Social Calendar" in People & Engagement section)
|
|
||||||
|
|
||||||
### Phase B: Sharing + Social
|
|
||||||
**Scope:**
|
|
||||||
- [ ] Prisma models: SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction
|
|
||||||
- [ ] SharedCalendarView CRUD
|
|
||||||
- [ ] Invite flow: send invite via notification system, accept/decline/leave
|
|
||||||
- [ ] Merged calendar API: query all members' items with layer type filtering
|
|
||||||
- [ ] Auto-color assignment for members
|
|
||||||
- [ ] Layer visibility enforcement (PRIVATE/FRIENDS/PUBLIC filtering based on relationship)
|
|
||||||
- [ ] Event-level sharing: share a specific item with friend(s) via notification
|
|
||||||
- [ ] Comments on shared view dates/items
|
|
||||||
- [ ] Emoji reactions on shared view items
|
|
||||||
- [ ] Availability finder: free/busy overlay, slot highlighting, time range filter
|
|
||||||
- [ ] Friend's public calendar view
|
|
||||||
- [ ] SharedCalendarsPage, SharedCalendarView components
|
|
||||||
- [ ] AvailabilityFinder component
|
|
||||||
- [ ] CalendarComments, CalendarReactions components
|
|
||||||
- [ ] Public share URL (shareToken for unauthenticated view)
|
|
||||||
|
|
||||||
### Phase C: .ics Integration
|
|
||||||
**Scope:**
|
|
||||||
- [x] Prisma models: CalendarFeed, CalendarExportToken (already existed from Phase A migration)
|
|
||||||
- [x] .ics feed parser (node-ical v0.25.5)
|
|
||||||
- [x] BullMQ job: refresh feeds every 15 minutes (calendar-feed-refresh queue)
|
|
||||||
- [x] Feed CRUD: subscribe, update, delete, force refresh
|
|
||||||
- [x] Auto-create EXTERNAL layer per feed, cache items as CalendarItem rows (sourceType: ICS_FEED)
|
|
||||||
- [x] .ics export: generate feed from user's calendar via ical-generator v10, token-authenticated URL
|
|
||||||
- [x] Export token management (create, list, revoke)
|
|
||||||
- [x] CalendarFeedsPanel, CalendarExportPanel components
|
|
||||||
- [x] MyCalendarPage settings Drawer integration (gear icon)
|
|
||||||
|
|
||||||
### Phase D: Admin Shared Views
|
|
||||||
**Scope:**
|
|
||||||
- [ ] Role-based SharedCalendarView (viewType: ROLE_BASED)
|
|
||||||
- [ ] Auto-include users by role(s) — query live, no member rows needed
|
|
||||||
- [ ] Only expose system layers (shifts, tickets, polls) — no personal data
|
|
||||||
- [ ] No notifications to included users
|
|
||||||
- [ ] Admin routes (requireRole: SUPER_ADMIN, MAP_ADMIN)
|
|
||||||
- [ ] AdminSharedViewsPage
|
|
||||||
- [ ] AdminCalendarOverview (big shift/event dashboard)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### Extending UnifiedCalendar
|
|
||||||
|
|
||||||
The existing `UnifiedCalendar` component and `unified-calendar.service.ts` remain as the **public** calendar. The new personal calendar service (`calendar.service.ts`) reuses the same source queries (shifts, Gancio, polls, ticketed events) but filters to the user's own records and merges with their CalendarItem rows.
|
|
||||||
|
|
||||||
### Recurrence Background Job
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// jobs/calendar-recurrence.job.ts
|
|
||||||
// Runs daily via BullMQ repeatable job
|
|
||||||
// 1. Find all CalendarItems with recurrenceRule where latest materialized date < now + 3 months
|
|
||||||
// 2. Generate new instances up to 3 months ahead
|
|
||||||
// 3. Skip dates that already have an instance (idempotent)
|
|
||||||
```
|
|
||||||
|
|
||||||
### .ics Feed Refresh Job
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// jobs/calendar-feed-refresh.job.ts
|
|
||||||
// Runs every 15 minutes via BullMQ repeatable job
|
|
||||||
// 1. Find feeds where lastFetchedAt + refreshInterval < now
|
|
||||||
// 2. Fetch .ics URL, parse events
|
|
||||||
// 3. Upsert CalendarItem rows (match on sourceId = ics UID)
|
|
||||||
// 4. Delete items no longer in feed
|
|
||||||
// 5. Update feed status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Privacy Boundaries
|
|
||||||
|
|
||||||
| Scenario | What's visible |
|
|
||||||
|----------|---------------|
|
|
||||||
| Viewing own calendar | Everything (all layers, all items) |
|
|
||||||
| Friend views your calendar | Items on FRIENDS or PUBLIC visibility layers, plus items with individual FRIENDS/PUBLIC override |
|
|
||||||
| Public profile calendar | Only PUBLIC visibility layers and PUBLIC override items |
|
|
||||||
| Admin role-based view | Only system layers (shifts, tickets, polls) for users matching role filter |
|
|
||||||
| Shared view (MANUAL) | Items from includedLayerTypes on layers with appropriate visibility for the viewer |
|
|
||||||
| Time blocks (BUSY) | Title shown per showDetailsTo setting, always shows busy bar |
|
|
||||||
|
|
||||||
### Performance Considerations
|
|
||||||
|
|
||||||
- CalendarItem table will grow with materialized recurrence — add indexes on (userId, date), (layerId, date), (seriesId)
|
|
||||||
- System layers query source tables directly — leverage existing indexes on ShiftSignup, EventTicket, etc.
|
|
||||||
- .ics feed items are cached — only re-parsed on refresh interval
|
|
||||||
- Shared view queries can be expensive (N members x M layers) — cache merged results in Redis (2min TTL, bust on member change)
|
|
||||||
- Availability finder operates on time blocks only — narrow query scope
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tracking Log
|
|
||||||
|
|
||||||
### 2026-03-06 — Planning Complete
|
|
||||||
- Brainstormed feature across 3 rounds of refinement
|
|
||||||
- Decided on layer-based architecture (system, user, external layers)
|
|
||||||
- Recurrence uses materialization (consistent with ShiftSeries pattern)
|
|
||||||
- Time block visibility is configurable per item (showDetailsTo: NOBODY/FRIENDS/EVERYONE)
|
|
||||||
- Shared views support manual (invite-based) and role-based (admin, system data only)
|
|
||||||
- Availability finder included in Phase B
|
|
||||||
- Comments and reactions on shared view items included in Phase B
|
|
||||||
- .ics import and export in Phase C
|
|
||||||
- Admin role-based views in Phase D (no personal data, no notifications)
|
|
||||||
- Reuse existing notification system for invites
|
|
||||||
- Auto-color assignment for shared view members with user override option
|
|
||||||
- Mobile UX: day/3-day swipeable view instead of month grid
|
|
||||||
|
|
||||||
### 2026-03-06 — Phase A Implementation Complete
|
|
||||||
- Schema: 8 models, 12 enums, migration `20260306203326_social_calendar_layers_items` applied
|
|
||||||
- Fixed pre-existing migration ordering issue (ticketed_events create must come before alter)
|
|
||||||
- Backend: calendar.service.ts (layer mgmt, item CRUD, recurrence materialization, personal calendar merge), calendar.routes.ts (9 endpoints), calendar.schemas.ts (Zod validation)
|
|
||||||
- Frontend: 5 new components (CalendarLayerPanel, CalendarItemModal, RecurrenceEditor, PersonalCalendarView, MobileDayView), MyCalendarPage
|
|
||||||
- Navigation: VolunteerFooterNav Calendar tab, App.tsx route, SettingsPage toggle
|
|
||||||
- Smoke tested: layers auto-create, item CRUD works, recurring events materialize correctly (Weekly Mon/Wed/Fri generated 11 instances through June)
|
|
||||||
- Both API and Admin compile with zero TypeScript errors
|
|
||||||
- Remaining Phase A item: BullMQ job for extending recurring series (not critical for launch, series materializes 3 months on creation)
|
|
||||||
|
|
||||||
### 2026-03-07 — Phase C Implementation Complete
|
|
||||||
- Backend: feed.schemas.ts (3 Zod schemas), feed.service.ts (feed CRUD, ICS parsing, RRULE materialization, export generation), feed.routes.ts (1 public + 8 auth routes), calendar-feed-queue.service.ts (BullMQ 15min repeatable job)
|
|
||||||
- Dependencies: node-ical v0.25.5 (ICS parsing), ical-generator v10.0.0 (ICS output)
|
|
||||||
- Feed import: streaming body read with 5MB limit, 1000 event cap, RRULE materialization via rrule.between(), stale event cleanup, status tracking (OK/ERROR/PENDING)
|
|
||||||
- Feed export: 32-byte random token, configurable layer/personal inclusion, past 1 month + future 3 months, standard iCalendar output with Content-Type: text/calendar
|
|
||||||
- Frontend: CalendarFeedsPanel (add/edit/delete/refresh with status badges), CalendarExportPanel (create/copy/revoke tokens), settings Drawer in MyCalendarPage (gear icon)
|
|
||||||
- Types: CalendarFeed, CalendarExportToken, CalendarFeedStatus, CalendarFeedInterval added to admin/src/types/api.ts
|
|
||||||
- server.ts: feedRoutes mounted before calendarRoutes (public .ics route needs no auth), queue worker started on bootstrap, graceful shutdown
|
|
||||||
- Smoke tested: Google US Holidays feed → 317 events imported with status OK; export token → valid .ics with VEVENT entries; revoke → 404
|
|
||||||
- Docker gotcha: anonymous volume `/app/node_modules` caches old dependencies — must `docker compose rm -sf api` to clear when adding new npm packages
|
|
||||||
- Both API and Admin compile with zero TypeScript errors
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
# Social Connections System — Implementation Plan
|
|
||||||
|
|
||||||
See the full plan in the conversation transcript. This file tracks implementation progress.
|
|
||||||
|
|
||||||
## Phase Status
|
|
||||||
|
|
||||||
| Phase | Description | Status |
|
|
||||||
|-------|-------------|--------|
|
|
||||||
| 1 | Feature Flag + Social Module Skeleton | COMPLETE |
|
|
||||||
| 2 | Friendship API (Send, Accept, Decline, Cancel, Unfriend) | COMPLETE |
|
|
||||||
| 3 | Block/Unblock API + Privacy Settings API | COMPLETE |
|
|
||||||
| 4 | User Social Profile + Volunteer Portal UI Foundation | COMPLETE |
|
|
||||||
| 5 | In-App Notification System (Bell Icon + Dropdown) | COMPLETE |
|
|
||||||
| 6 | Social Activity Feed (Friends' Activities) | COMPLETE |
|
|
||||||
| 7 | CRM Bridge (Auto-Connect + Friend Suggestions) | COMPLETE |
|
|
||||||
| 8 | Poke System + Video Recommendations | COMPLETE |
|
|
||||||
| 9 | Close Friends + Friends Management Page | COMPLETE (merged into Phase 4 UI) |
|
|
||||||
| 10 | Email Digest Notifications | COMPLETE |
|
|
||||||
| 11 | Social Integration with Existing Features | COMPLETE |
|
|
||||||
| 12 | Rocket.Chat DM Integration | COMPLETE |
|
|
||||||
| 13 | Group/Team Features (Shift Teams, Campaign Teams) | COMPLETE |
|
|
||||||
| 14 | Gamification (Achievements, Streaks, Leaderboards) | COMPLETE |
|
|
||||||
| 15 | Real-Time Features (SSE for Live Notifications, Online Status) | COMPLETE |
|
|
||||||
|
|
||||||
## Files Created/Modified
|
|
||||||
|
|
||||||
### Backend (API)
|
|
||||||
- `api/prisma/schema.prisma` — added `enableSocial` to SiteSettings
|
|
||||||
- `api/prisma/migrations/20260224215259_add_enable_social/` — migration
|
|
||||||
- `api/src/modules/social/` — new module directory
|
|
||||||
- `social.routes.ts` — main router mounting sub-routers
|
|
||||||
- `social.schemas.ts` — Zod schemas (friendship, privacy, notification)
|
|
||||||
- `social.middleware.ts` — `checkSocialEnabled` feature gate
|
|
||||||
- `social.rate-limits.ts` — rate limiters (friend request, social action)
|
|
||||||
- `friendship.service.ts` — full friendship CRUD + notifications
|
|
||||||
- `friendship.routes.ts` — 10 friendship endpoints
|
|
||||||
- `block.service.ts` — block/unblock with auto-unfriend
|
|
||||||
- `block.routes.ts` — 3 block endpoints
|
|
||||||
- `privacy.service.ts` — privacy settings get/update (auto-create defaults)
|
|
||||||
- `privacy.routes.ts` — 2 privacy endpoints
|
|
||||||
- `notification.service.ts` — notification CRUD + preferences (respects opt-outs)
|
|
||||||
- `notification.routes.ts` — 7 notification endpoints
|
|
||||||
- `profile.routes.ts` — user profile view (own + other, privacy-filtered)
|
|
||||||
- `api/src/modules/settings/settings.schemas.ts` — added `enableSocial`
|
|
||||||
- `api/src/server.ts` — mounted socialRouter at `/api/social`
|
|
||||||
|
|
||||||
### Frontend (Admin)
|
|
||||||
- `admin/src/types/social.ts` — TypeScript interfaces
|
|
||||||
- `admin/src/stores/social.store.ts` — Zustand social store
|
|
||||||
- `admin/src/components/social/` — new directory
|
|
||||||
- `UserAvatar.tsx` — initials avatar with userId-based color
|
|
||||||
- `FriendButton.tsx` — context-aware friend action button
|
|
||||||
- `NotificationBell.tsx` — bell icon + dropdown (30s polling)
|
|
||||||
- `admin/src/pages/volunteer/` — new pages
|
|
||||||
- `SocialProfilePage.tsx` — own + other user profile
|
|
||||||
- `FriendsPage.tsx` — friends management (tabs: friends, requests, sent, blocked)
|
|
||||||
- `NotificationsPage.tsx` — full notification list + preferences
|
|
||||||
- `admin/src/components/VolunteerLayout.tsx` — added NotificationBell
|
|
||||||
- `admin/src/components/VolunteerFooterNav.tsx` — added "Friends" nav item
|
|
||||||
- `admin/src/components/FeatureGate.tsx` — added `enableSocial`
|
|
||||||
- `admin/src/types/api.ts` — added `enableSocial` to SiteSettings
|
|
||||||
- `admin/src/pages/SettingsPage.tsx` — added toggle
|
|
||||||
- `admin/src/App.tsx` — added 6 new volunteer routes
|
|
||||||
|
|
||||||
### Phase 6 — Social Activity Feed
|
|
||||||
- `api/src/modules/social/feed.service.ts` — aggregates 4 activity types + Redis cache (2-min TTL)
|
|
||||||
- `api/src/modules/social/feed.routes.ts` — GET `/` (friend feed), GET `/my` (own activity)
|
|
||||||
- `admin/src/components/social/FeedCard.tsx` — activity card with type-based icon/color
|
|
||||||
- `admin/src/pages/volunteer/SocialFeedPage.tsx` — feed page with suggestions widget
|
|
||||||
|
|
||||||
### Phase 7 — CRM Bridge + Suggestions
|
|
||||||
- `api/src/modules/social/suggestions.service.ts` — ranked suggestions (household/mutual/shifts/campaigns)
|
|
||||||
- `api/src/modules/social/suggestions.routes.ts` — GET `/`, POST `/:userId/dismiss`
|
|
||||||
- `admin/src/components/social/FriendSuggestions.tsx` — horizontal scroll suggestions widget
|
|
||||||
- `admin/src/pages/volunteer/DiscoverPage.tsx` — search + suggestions page
|
|
||||||
|
|
||||||
### Phase 8 — Poke System + Video Recommendations
|
|
||||||
- `api/src/modules/social/poke.service.ts` — poke CRUD + 24h Redis cooldown per pair
|
|
||||||
- `api/src/modules/social/poke.routes.ts` — POST `/`, GET `/`, GET `/count`, POST `/:id/read`, GET `/cooldown/:userId`
|
|
||||||
- `api/src/modules/social/recommendation.service.ts` — video recommendation CRUD + duplicate detection
|
|
||||||
- `api/src/modules/social/recommendation.routes.ts` — POST `/`, GET `/`, GET `/sent`, GET `/count`, POST `/:id/read`
|
|
||||||
- `admin/src/components/social/PokeButton.tsx` — poke button with cooldown indicator
|
|
||||||
- `admin/src/components/social/RecommendVideoModal.tsx` — friend + video picker modal
|
|
||||||
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added PokeButton
|
|
||||||
|
|
||||||
### Phase 10 — Email Digest Notifications
|
|
||||||
- `api/prisma/migrations/20260224232546_add_digest_frequency/` — adds digestFrequency + lastDigestSentAt
|
|
||||||
- `api/src/services/social-digest.service.ts` — daily scan, generates digest emails
|
|
||||||
- `api/src/templates/email/social-digest.html` + `.txt` — digest email templates
|
|
||||||
- `api/src/server.ts` — added daily social digest scan interval
|
|
||||||
- `admin/src/pages/volunteer/NotificationsPage.tsx` — added digest frequency selector
|
|
||||||
|
|
||||||
### Phase 11 — Social Integration with Existing Features
|
|
||||||
- `api/src/modules/social/integration.service.ts` — friends on shifts, campaigns, and active map sessions (privacy-filtered)
|
|
||||||
- `api/src/modules/social/integration.routes.ts` — 3 endpoints: shifts/:id/friends, campaigns/:id/friends, map/friends
|
|
||||||
- `admin/src/components/social/FriendsAttendingBadge.tsx` — "N friends attending" badge with stacked avatars
|
|
||||||
- `admin/src/components/social/FriendsCampaignBadge.tsx` — "N friends participated" badge with stacked avatars
|
|
||||||
- `admin/src/components/social/FriendsOnMap.tsx` — floating panel showing friends currently canvassing (60s poll)
|
|
||||||
- `admin/src/pages/public/ShiftsPage.tsx` — added FriendsAttendingBadge per shift card
|
|
||||||
- `admin/src/pages/public/CampaignPage.tsx` — added FriendsCampaignBadge in hero section
|
|
||||||
- `admin/src/pages/volunteer/VolunteerShiftsPage.tsx` — added FriendsAttendingBadge per shift card
|
|
||||||
- `admin/src/pages/volunteer/VolunteerMapPage.tsx` — added FriendsOnMap overlay
|
|
||||||
- `admin/src/types/social.ts` — added FriendOnShift, FriendOnCampaign, FriendOnMap types
|
|
||||||
|
|
||||||
### Phase 12 — Rocket.Chat DM Integration
|
|
||||||
- `api/src/modules/social/messaging.service.ts` — openDM: provisions both users, creates DM room, returns roomId
|
|
||||||
- `api/src/modules/social/profile.routes.ts` — added POST `/:userId/dm` endpoint
|
|
||||||
- `api/src/services/rocketchat.client.ts` — added `createDM(usernames)` method
|
|
||||||
- `admin/src/components/social/MessageButton.tsx` — DM button (opens chat widget, RC-gated)
|
|
||||||
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added MessageButton for accepted friends
|
|
||||||
|
|
||||||
### Phase 13 — Group/Team Features
|
|
||||||
- `api/prisma/schema.prisma` — added SocialGroup, SocialGroupMember models + SocialGroupType enum + User.socialGroupMemberships relation
|
|
||||||
- `api/prisma/migrations/20260225000017_add_social_groups/` — migration creating social_groups + social_group_members tables
|
|
||||||
- `api/src/modules/social/group.service.ts` — getOrCreate, syncShiftTeam, syncCampaignTeam, listMyGroups, getGroupDetail
|
|
||||||
- `api/src/modules/social/group.routes.ts` — GET `/` (my groups), GET `/:id` (group detail)
|
|
||||||
- `api/src/modules/social/social.routes.ts` — mounted groupRouter at `/groups`
|
|
||||||
- `api/src/modules/map/shifts/shifts.service.ts` — added fire-and-forget groupService.syncShiftTeam() on all signup/cancel events
|
|
||||||
- `api/src/modules/influence/campaign-emails/campaign-emails.service.ts` — added fire-and-forget groupService.syncCampaignTeam() on email creation
|
|
||||||
- `admin/src/types/social.ts` — added SocialGroupSummary, SocialGroupDetail interfaces
|
|
||||||
- `admin/src/components/social/GroupCard.tsx` — group card with type-based icon/color
|
|
||||||
- `admin/src/pages/volunteer/GroupDetailPage.tsx` — group detail with member list + FriendButton per member
|
|
||||||
- `admin/src/pages/volunteer/FriendsPage.tsx` — added "Groups" tab
|
|
||||||
- `admin/src/App.tsx` — added `/volunteer/groups/:id` route
|
|
||||||
|
|
||||||
### Phase 14 — Gamification (Achievements, Streaks, Leaderboards)
|
|
||||||
- `api/src/modules/social/achievements.service.ts` — 11 achievements (4 shift, 4 canvass, 2 campaign, 2 social), checkAndUnlock, getLeaderboard (raw SQL), getVolunteerStats (on-the-fly computed)
|
|
||||||
- `api/src/modules/social/achievements.routes.ts` — 6 endpoints: achievements, definitions, stats, stats/:userId, user/:userId, leaderboard
|
|
||||||
- `api/src/modules/social/social.routes.ts` — mounted achievementsRouter at `/achievements`
|
|
||||||
- `api/src/modules/map/canvass/canvass.service.ts` — added achievements.checkAndUnlock after recordVisit
|
|
||||||
- `api/src/modules/map/shifts/shifts.service.ts` — added achievements.checkAndUnlock after signup events (admin, public, volunteer)
|
|
||||||
- `api/src/modules/influence/campaign-emails/campaign-emails.service.ts` — added achievements.checkAndUnlock after email creation
|
|
||||||
- `api/src/modules/social/friendship.service.ts` — added achievements.checkAndUnlock on friend accept (both users)
|
|
||||||
- `admin/src/types/social.ts` — added AchievementDef, AchievementWithProgress, VolunteerStats, LeaderboardEntry interfaces
|
|
||||||
- `admin/src/pages/volunteer/AchievementsPage.tsx` — badge gallery (locked/unlocked + progress bars), volunteer stats summary, leaderboard (canvass/shifts/campaigns tabs)
|
|
||||||
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added achievement badges section to own + other user profiles
|
|
||||||
- `admin/src/pages/volunteer/SocialFeedPage.tsx` — added top canvassers leaderboard widget
|
|
||||||
- `admin/src/App.tsx` — added `/volunteer/achievements` route
|
|
||||||
|
|
||||||
### Phase 15 — Real-Time Features (SSE for Live Notifications, Online Status)
|
|
||||||
- `api/src/modules/social/sse.service.ts` — in-memory SSE connection manager (addClient, removeClient, sendToUser, sendToUsers, heartbeat, closeAll)
|
|
||||||
- `api/src/modules/social/presence.service.ts` — online/offline tracking with privacy filtering, broadcastPresence to friends, stale cleanup (5min timeout), markAllOffline (startup)
|
|
||||||
- `api/src/modules/social/sse.routes.ts` — GET `/` (SSE stream), GET `/online-friends`, GET `/status`
|
|
||||||
- `api/src/modules/social/social.routes.ts` — mounted sseRouter at `/sse`, added query-param token injection for EventSource auth
|
|
||||||
- `api/src/modules/social/notification.service.ts` — SSE push after notification creation (real-time delivery)
|
|
||||||
- `api/src/modules/social/friendship.service.ts` — SSE push on friend_request + friend_accepted events
|
|
||||||
- `api/src/modules/social/poke.service.ts` — SSE push on poke events
|
|
||||||
- `api/src/server.ts` — SSE heartbeat start, presenceService.markAllOffline on startup, 1-min stale cleanup interval, sseService.closeAll on graceful shutdown
|
|
||||||
- `admin/src/hooks/useSSE.ts` — EventSource hook with auto-reconnect (exponential backoff), handles notification/presence/friend_request/friend_accepted/poke events
|
|
||||||
- `admin/src/components/social/OnlineIndicator.tsx` — green dot showing online status for friends
|
|
||||||
- `admin/src/components/social/UserAvatar.tsx` — added showOnline prop with OnlineIndicator overlay
|
|
||||||
- `admin/src/stores/social.store.ts` — added onlineFriends state + fetchOnlineFriends action
|
|
||||||
- `admin/src/components/VolunteerLayout.tsx` — initialized useSSE() on mount
|
|
||||||
- `admin/src/components/social/NotificationBell.tsx` — reduced polling to 2-min fallback (SSE handles real-time)
|
|
||||||
- `admin/src/pages/volunteer/FriendsPage.tsx` — enabled showOnline on friend list avatars
|
|
||||||
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — enabled showOnline on other user profile avatars
|
|
||||||
6
admin/.dockerignore
Normal file
6
admin/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
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"
|
||||||
|
}
|
||||||
238
admin/package-lock.json
generated
238
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",
|
||||||
@ -1154,9 +1156,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1167,9 +1169,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1180,9 +1182,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1193,9 +1195,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1206,9 +1208,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1219,9 +1221,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1232,9 +1234,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1245,9 +1247,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1258,9 +1260,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1271,9 +1273,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1284,9 +1286,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -1297,9 +1299,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -1310,9 +1312,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -1323,9 +1325,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -1336,9 +1338,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -1349,9 +1351,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -1362,9 +1364,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@ -1375,9 +1377,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1388,9 +1390,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1401,9 +1403,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1414,9 +1416,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1427,9 +1429,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1440,9 +1442,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@ -1453,9 +1455,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1466,9 +1468,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2261,9 +2263,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@ -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",
|
||||||
@ -2860,9 +2873,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -3651,9 +3664,9 @@
|
|||||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
@ -3666,31 +3679,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||||
"@rollup/rollup-android-arm64": "4.57.1",
|
"@rollup/rollup-android-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
"@rollup/rollup-openbsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -3993,10 +4006,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import CodeEditorPage from '@/pages/CodeEditorPage';
|
|||||||
import NocoDBPage from '@/pages/NocoDBPage';
|
import NocoDBPage from '@/pages/NocoDBPage';
|
||||||
import N8nPage from '@/pages/N8nPage';
|
import N8nPage from '@/pages/N8nPage';
|
||||||
import GiteaPage from '@/pages/GiteaPage';
|
import GiteaPage from '@/pages/GiteaPage';
|
||||||
|
import GiteaSetupPage from '@/pages/GiteaSetupPage';
|
||||||
import MailHogPage from '@/pages/MailHogPage';
|
import MailHogPage from '@/pages/MailHogPage';
|
||||||
import MiniQRPage from '@/pages/MiniQRPage';
|
import MiniQRPage from '@/pages/MiniQRPage';
|
||||||
import ExcalidrawPage from '@/pages/ExcalidrawPage';
|
import ExcalidrawPage from '@/pages/ExcalidrawPage';
|
||||||
@ -42,9 +43,15 @@ import JitsiMeetPage from '@/pages/JitsiMeetPage';
|
|||||||
import SettingsPage from '@/pages/SettingsPage';
|
import SettingsPage from '@/pages/SettingsPage';
|
||||||
import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
|
import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
|
||||||
import PangolinPage from '@/pages/PangolinPage';
|
import PangolinPage from '@/pages/PangolinPage';
|
||||||
|
import ControlPanelPage from '@/pages/ControlPanelPage';
|
||||||
import ObservabilityPage from '@/pages/ObservabilityPage';
|
import ObservabilityPage from '@/pages/ObservabilityPage';
|
||||||
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
|
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
|
||||||
|
import AnalyticsOverviewPage from '@/pages/analytics/AnalyticsOverviewPage';
|
||||||
|
import GeoAnalyticsPage from '@/pages/analytics/GeoAnalyticsPage';
|
||||||
|
import ContentAnalyticsPage from '@/pages/analytics/ContentAnalyticsPage';
|
||||||
|
import UserAnalyticsPage from '@/pages/analytics/UserAnalyticsPage';
|
||||||
import DocsCommentsPage from '@/pages/DocsCommentsPage';
|
import DocsCommentsPage from '@/pages/DocsCommentsPage';
|
||||||
|
import DocsMetadataPage from '@/pages/DocsMetadataPage';
|
||||||
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
||||||
import SubscribersPage from '@/pages/payments/SubscribersPage';
|
import SubscribersPage from '@/pages/payments/SubscribersPage';
|
||||||
import PaymentProductsPage from '@/pages/payments/ProductsPage';
|
import PaymentProductsPage from '@/pages/payments/ProductsPage';
|
||||||
@ -60,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';
|
||||||
@ -98,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,
|
||||||
@ -110,6 +123,8 @@ import {
|
|||||||
EVENTS_ROLES,
|
EVENTS_ROLES,
|
||||||
SOCIAL_ROLES,
|
SOCIAL_ROLES,
|
||||||
SYSTEM_ROLES,
|
SYSTEM_ROLES,
|
||||||
|
POLLS_ROLES,
|
||||||
|
ANALYTICS_ROLES,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import { isAdmin } from '@/utils/roles';
|
import { isAdmin } from '@/utils/roles';
|
||||||
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
||||||
@ -130,6 +145,10 @@ import ReferralAdminPage from '@/pages/social/ReferralAdminPage';
|
|||||||
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
|
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
|
||||||
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
|
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
|
||||||
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
|
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
|
||||||
|
import StrawPollsPage from '@/pages/influence/StrawPollsPage';
|
||||||
|
import ActionCampaignsPage from '@/pages/influence/ActionCampaignsPage';
|
||||||
|
import ActionCampaignEditorPage from '@/pages/influence/ActionCampaignEditorPage';
|
||||||
|
import VolunteerDashboardPage from '@/pages/volunteer/VolunteerDashboardPage';
|
||||||
import ReferralsPage from '@/pages/volunteer/ReferralsPage';
|
import ReferralsPage from '@/pages/volunteer/ReferralsPage';
|
||||||
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
|
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
|
||||||
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
|
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
|
||||||
@ -140,6 +159,8 @@ import MeetingAgendaPage from '@/pages/MeetingAgendaPage';
|
|||||||
import ActionItemsPage from '@/pages/ActionItemsPage';
|
import ActionItemsPage from '@/pages/ActionItemsPage';
|
||||||
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
|
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
|
||||||
import PollsListPage from '@/pages/public/PollsListPage';
|
import PollsListPage from '@/pages/public/PollsListPage';
|
||||||
|
import StrawPollPage from '@/pages/public/StrawPollPage';
|
||||||
|
import StrawPollsListPage from '@/pages/public/StrawPollsListPage';
|
||||||
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
||||||
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
|
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
|
||||||
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
|
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
|
||||||
@ -153,6 +174,7 @@ import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
|
|||||||
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
|
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
|
||||||
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
|
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
|
||||||
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
|
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
|
||||||
|
import SharedDocEditorPage from '@/pages/public/SharedDocEditorPage';
|
||||||
import NotFoundPage from '@/pages/NotFoundPage';
|
import NotFoundPage from '@/pages/NotFoundPage';
|
||||||
import CommandPalette from '@/components/command-palette/CommandPalette';
|
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||||
|
|
||||||
@ -165,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() {
|
||||||
@ -233,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>
|
||||||
@ -273,6 +301,14 @@ export default function App() {
|
|||||||
<Route index element={<SchedulingPollPage />} />
|
<Route index element={<SchedulingPollPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Straw polls — feature-gated */}
|
||||||
|
<Route path="/straw-polls" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
|
||||||
|
<Route index element={<StrawPollsListPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/straw-poll/:slug" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
|
||||||
|
<Route index element={<StrawPollPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Public ticketed event pages — feature-gated */}
|
{/* Public ticketed event pages — feature-gated */}
|
||||||
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
|
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<TicketedEventDetailPage />} />
|
<Route index element={<TicketedEventDetailPage />} />
|
||||||
@ -313,6 +349,9 @@ export default function App() {
|
|||||||
<Route index element={<ContactProfilePage />} />
|
<Route index element={<ContactProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Shared doc editor (no auth, token-based access) */}
|
||||||
|
<Route path="/docs/share/:shareToken" element={<SharedDocEditorPage />} />
|
||||||
|
|
||||||
{/* Public Media Gallery (purple theme) — feature-gated */}
|
{/* Public Media Gallery (purple theme) — feature-gated */}
|
||||||
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<MediaGalleryPage />} />
|
<Route index element={<MediaGalleryPage />} />
|
||||||
@ -334,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 />
|
||||||
@ -362,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 />} />
|
||||||
@ -381,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>
|
||||||
|
|
||||||
@ -556,6 +597,62 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="influence/petitions"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
|
<PetitionsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="influence/petitions/:id/signatures"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
|
<PetitionSignaturesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="influence/petitions/moderation"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
|
<PetitionModerationPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="influence/straw-polls"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={POLLS_ROLES}>
|
||||||
|
<StrawPollsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="influence/action-campaigns"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
|
<ActionCampaignsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="influence/action-campaigns/new"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
|
<ActionCampaignEditorPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="influence/action-campaigns/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
|
<ActionCampaignEditorPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="listmonk"
|
path="listmonk"
|
||||||
element={
|
element={
|
||||||
@ -604,6 +701,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="docs/metadata"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
|
<DocsMetadataPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="navigation"
|
path="navigation"
|
||||||
element={
|
element={
|
||||||
@ -644,6 +749,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="services/gitea/setup"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
|
<GiteaSetupPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="services/mailhog"
|
path="services/mailhog"
|
||||||
element={
|
element={
|
||||||
@ -765,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={
|
||||||
@ -773,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={
|
||||||
|
|||||||
@ -54,6 +54,7 @@ import {
|
|||||||
TrophyOutlined,
|
TrophyOutlined,
|
||||||
FlagOutlined,
|
FlagOutlined,
|
||||||
UserAddOutlined,
|
UserAddOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -70,6 +71,8 @@ import {
|
|||||||
MEDIA_ROLES,
|
MEDIA_ROLES,
|
||||||
PAYMENTS_ROLES,
|
PAYMENTS_ROLES,
|
||||||
SOCIAL_ROLES,
|
SOCIAL_ROLES,
|
||||||
|
POLLS_ROLES,
|
||||||
|
ANALYTICS_ROLES,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
|
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
|
||||||
import type { NavItem } from '@/types/api';
|
import type { NavItem } from '@/types/api';
|
||||||
@ -83,6 +86,10 @@ import {
|
|||||||
} from '@/lib/nav-defaults';
|
} from '@/lib/nav-defaults';
|
||||||
import { useCommandPaletteStore } from '@/stores/command-palette.store';
|
import { useCommandPaletteStore } from '@/stores/command-palette.store';
|
||||||
import { useFavoritesStore } from '@/stores/favorites.store';
|
import { useFavoritesStore } from '@/stores/favorites.store';
|
||||||
|
import { useTourStore } from '@/stores/tour.store';
|
||||||
|
import { AdminTour } from './tour/AdminTour';
|
||||||
|
import { TourHub } from './tour/TourHub';
|
||||||
|
import { TourTriggerButton } from './tour/TourTriggerButton';
|
||||||
import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items';
|
import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items';
|
||||||
import RocketChatWidget from './chat/RocketChatWidget';
|
import RocketChatWidget from './chat/RocketChatWidget';
|
||||||
|
|
||||||
@ -180,8 +187,14 @@ 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' }] : []),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -226,6 +239,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
|||||||
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
|
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
|
||||||
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
|
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
|
||||||
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
|
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
|
||||||
|
webChildren.push({ key: '/app/docs/metadata', icon: <DatabaseOutlined />, label: 'Metadata' });
|
||||||
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
|
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
|
||||||
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
|
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
|
||||||
items.push({
|
items.push({
|
||||||
@ -318,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 />,
|
||||||
@ -325,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' },
|
||||||
@ -333,6 +362,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
|||||||
{ type: 'group', label: 'Tools', children: [
|
{ type: 'group', label: 'Tools', children: [
|
||||||
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
|
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
|
||||||
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
|
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
|
||||||
|
{ key: '/app/services/gitea/setup', icon: <SettingOutlined />, label: 'Gitea Setup' },
|
||||||
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
|
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
|
||||||
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
|
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
|
||||||
]},
|
]},
|
||||||
@ -431,6 +461,14 @@ export default function AppLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userMenuItems: MenuProps['items'] = [
|
const userMenuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'tour',
|
||||||
|
icon: <QuestionCircleOutlined />,
|
||||||
|
label: 'Learning Tours',
|
||||||
|
onClick: () => {
|
||||||
|
useTourStore.getState().openHub();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'logout',
|
key: 'logout',
|
||||||
icon: <LogoutOutlined />,
|
icon: <LogoutOutlined />,
|
||||||
@ -578,6 +616,7 @@ export default function AppLayout() {
|
|||||||
trigger={null}
|
trigger={null}
|
||||||
collapsible
|
collapsible
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
|
data-tour="sidebar"
|
||||||
style={{ overflow: 'auto', height: '100vh', position: 'sticky', top: 0, left: 0 }}
|
style={{ overflow: 'auto', height: '100vh', position: 'sticky', top: 0, left: 0 }}
|
||||||
>
|
>
|
||||||
{sidebarMenu}
|
{sidebarMenu}
|
||||||
@ -615,11 +654,12 @@ export default function AppLayout() {
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<SearchOutlined />}
|
icon={<SearchOutlined />}
|
||||||
|
data-tour="search-button"
|
||||||
onClick={() => useCommandPaletteStore.getState().open()}
|
onClick={() => useCommandPaletteStore.getState().open()}
|
||||||
/>
|
/>
|
||||||
</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);
|
||||||
@ -628,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);
|
||||||
}
|
}
|
||||||
@ -654,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>
|
||||||
);
|
);
|
||||||
@ -667,25 +710,27 @@ 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 />}>
|
<Button type="text" icon={<UserOutlined />} data-tour="user-menu">
|
||||||
{!isMobile && !collapsed && (
|
{!isMobile && !collapsed && (
|
||||||
<Text style={{ marginLeft: 8 }}>
|
<Text style={{ marginLeft: 8 }}>
|
||||||
{user?.name || user?.email || 'User'}
|
{user?.name || user?.email || 'User'}
|
||||||
@ -695,6 +740,7 @@ export default function AppLayout() {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Header>
|
</Header>
|
||||||
<Content
|
<Content
|
||||||
|
id="app-content-area"
|
||||||
style={{
|
style={{
|
||||||
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
|
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
|
||||||
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
|
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
|
||||||
@ -702,12 +748,16 @@ export default function AppLayout() {
|
|||||||
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
|
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
|
||||||
minHeight: 280,
|
minHeight: 280,
|
||||||
overflow: fullBleed ? 'hidden' : undefined,
|
overflow: fullBleed ? 'hidden' : undefined,
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />
|
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
<AdminTour />
|
||||||
|
<TourHub />
|
||||||
|
<TourTriggerButton />
|
||||||
<RocketChatWidget />
|
<RocketChatWidget />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Result, Button } from 'antd';
|
import { Result, Button, Skeleton } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { SettingOutlined } from '@ant-design/icons';
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
@ -22,10 +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',
|
||||||
|
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'>;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,8 +39,8 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
|
|||||||
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
|
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
|
||||||
const featureName = FEATURE_LABELS[feature] || feature;
|
const featureName = FEATURE_LABELS[feature] || feature;
|
||||||
|
|
||||||
// While loading or if settings haven't arrived yet, render children (optimistic)
|
// Show skeleton while settings are loading to prevent briefly showing disabled features
|
||||||
if (loading || !settings) return <>{children}</>;
|
if (loading || !settings) return <Skeleton active style={{ padding: 24 }} />;
|
||||||
|
|
||||||
if (settings[feature] === false) {
|
if (settings[feature] === false) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -571,6 +571,40 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
|||||||
</div>
|
</div>
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
case 'straw-poll-inline': {
|
||||||
|
const pollSlug = (defaults.pollSlug as string) || '';
|
||||||
|
return `
|
||||||
|
<section style="padding: 60px 40px;">
|
||||||
|
<div class="straw-poll-inline"
|
||||||
|
data-poll-slug="${pollSlug}"
|
||||||
|
data-show-results="true"
|
||||||
|
style="max-width: 500px; margin: 0 auto;">
|
||||||
|
<div style="background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); border-radius: 12px; padding: 32px; text-align: center; color: #fff;">
|
||||||
|
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
|
||||||
|
<path d="M160 960h128V480H160v480zm256 0h128V320H416v640zm256 0h128V160H672v800z"/>
|
||||||
|
</svg>
|
||||||
|
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">Straw Poll (Inline)</p>
|
||||||
|
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
|
||||||
|
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Inline voting widget renders on published page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
case 'straw-poll-card': {
|
||||||
|
const pollSlug = (defaults.pollSlug as string) || '';
|
||||||
|
return `
|
||||||
|
<section style="padding: 40px;">
|
||||||
|
<div class="straw-poll-card"
|
||||||
|
data-poll-slug="${pollSlug}"
|
||||||
|
style="max-width: 400px; margin: 0 auto;">
|
||||||
|
<div style="background: linear-gradient(135deg, #722ed1 0%, #531dab 100%); border-radius: 12px; padding: 24px; text-align: center; color: #fff;">
|
||||||
|
<p style="margin: 0; font-size: 1rem; font-weight: 600;">Straw Poll (Card Link)</p>
|
||||||
|
<p style="margin: 8px 0 0; font-size: 0.85rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
|
||||||
|
<p style="margin: 8px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Preview card with vote link renders on published page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -344,6 +344,7 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '0 24px',
|
padding: '0 24px',
|
||||||
height: 56,
|
height: 56,
|
||||||
|
overflow: 'hidden',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -374,7 +375,7 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
<Space size={navCollapsed ? 8 : 16}>
|
<Space size={navCollapsed ? 8 : 16} style={{ flexWrap: 'nowrap', overflow: 'hidden' }}>
|
||||||
{visibleNavItems.map(renderDesktopLink)}
|
{visibleNavItems.map(renderDesktopLink)}
|
||||||
{overflowMenuItems.length > 0 && (
|
{overflowMenuItems.length > 0 && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { PlusOutlined, CheckCircleOutlined, VideoCameraOutlined, CopyOutlined }
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@ -79,9 +80,8 @@ export default function EventSubmissionForm({ initialDate, onSuccess, gancioUrl,
|
|||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const msg = err.response?.data?.error?.message || err.response?.data?.error || 'Failed to submit event';
|
message.error(getErrorMessage(err, 'Failed to submit event'));
|
||||||
message.error(msg);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,10 +48,11 @@ export default function ChatPanel({ panel, leftOffset }: Props) {
|
|||||||
if (!rcAuthToken || !iframeRef.current?.contentWindow) return;
|
if (!rcAuthToken || !iframeRef.current?.contentWindow) return;
|
||||||
|
|
||||||
const sendToken = () => {
|
const sendToken = () => {
|
||||||
if (!iframeRef.current?.contentWindow) return;
|
if (!iframeRef.current?.contentWindow || !rcServiceUrl) return;
|
||||||
|
const targetOrigin = new URL(rcServiceUrl).origin;
|
||||||
iframeRef.current.contentWindow.postMessage(
|
iframeRef.current.contentWindow.postMessage(
|
||||||
{ event: 'login-with-token', loginToken: rcAuthToken },
|
{ event: 'login-with-token', loginToken: rcAuthToken },
|
||||||
'*',
|
targetOrigin,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -240,7 +240,12 @@ export default function CommandPalette() {
|
|||||||
if (flatItem.type === 'command') {
|
if (flatItem.type === 'command') {
|
||||||
const cmd = flatItem.item;
|
const cmd = flatItem.item;
|
||||||
addRecent(cmd.id);
|
addRecent(cmd.id);
|
||||||
navigate(cmd.path, { state: cmd.navigationState });
|
// Special handling for non-navigation actions
|
||||||
|
if (cmd.id === 'action-learning-tours') {
|
||||||
|
import('@/stores/tour.store').then(({ useTourStore }) => useTourStore.getState().openHub());
|
||||||
|
} else {
|
||||||
|
navigate(cmd.path, { state: cmd.navigationState });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
navigate(flatItem.item.path, { state: flatItem.item.navigationState });
|
navigate(flatItem.item.path, { state: flatItem.item.navigationState });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -790,6 +790,18 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
requiredRoles: ['SUPER_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Help & Tours ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'action-learning-tours',
|
||||||
|
title: 'Learning Tours',
|
||||||
|
description: 'Browse interactive tutorials for each section of the admin',
|
||||||
|
group: 'Actions',
|
||||||
|
path: '',
|
||||||
|
icon: 'QuestionCircleOutlined',
|
||||||
|
keywords: ['tour', 'help', 'learn', 'tutorial', 'guide', 'onboarding', 'walkthrough'],
|
||||||
|
category: 'action',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Quick actions ─────────────────────────────────────
|
// ── Quick actions ─────────────────────────────────────
|
||||||
{
|
{
|
||||||
id: 'action-create-campaign',
|
id: 'action-create-campaign',
|
||||||
|
|||||||
391
admin/src/components/docs/AuthorsManagementModal.tsx
Normal file
391
admin/src/components/docs/AuthorsManagementModal.tsx
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
Divider,
|
||||||
|
Empty,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
|
||||||
|
|
||||||
|
type AuthorsMap = Record<string, AuthorEntry>;
|
||||||
|
|
||||||
|
interface AuthorsManagementModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
authors: AuthorsMap;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorFormState {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: AuthorFormState = { id: '', name: '', description: '', avatar: '' };
|
||||||
|
|
||||||
|
export function AuthorsManagementModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
authors,
|
||||||
|
onSaved,
|
||||||
|
}: AuthorsManagementModalProps) {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [localAuthors, setLocalAuthors] = useState<AuthorsMap>({});
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<AuthorFormState>(emptyForm);
|
||||||
|
const [addingNew, setAddingNew] = useState(false);
|
||||||
|
const [newForm, setNewForm] = useState<AuthorFormState>(emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
// Sync from props when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setLocalAuthors({ ...authors });
|
||||||
|
setEditingKey(null);
|
||||||
|
setAddingNew(false);
|
||||||
|
setNewForm(emptyForm);
|
||||||
|
setDirty(false);
|
||||||
|
}
|
||||||
|
}, [open, authors]);
|
||||||
|
|
||||||
|
const startEdit = (key: string) => {
|
||||||
|
const entry = localAuthors[key];
|
||||||
|
if (!entry) return;
|
||||||
|
setEditingKey(key);
|
||||||
|
setEditForm({
|
||||||
|
id: key,
|
||||||
|
name: entry.name,
|
||||||
|
description: entry.description ?? '',
|
||||||
|
avatar: entry.avatar ?? '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingKey(null);
|
||||||
|
setEditForm(emptyForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (!editForm.name.trim()) {
|
||||||
|
messageApi.warning('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = { ...localAuthors };
|
||||||
|
// If key changed, remove old and add new
|
||||||
|
if (editingKey && editingKey !== editForm.id && editForm.id.trim()) {
|
||||||
|
delete updated[editingKey];
|
||||||
|
}
|
||||||
|
const key = editForm.id.trim() || editingKey!;
|
||||||
|
updated[key] = buildEntry(editForm);
|
||||||
|
setLocalAuthors(updated);
|
||||||
|
setEditingKey(null);
|
||||||
|
setEditForm(emptyForm);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAuthor = (key: string) => {
|
||||||
|
const updated = { ...localAuthors };
|
||||||
|
delete updated[key];
|
||||||
|
setLocalAuthors(updated);
|
||||||
|
setDirty(true);
|
||||||
|
if (editingKey === key) cancelEdit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveNewAuthor = () => {
|
||||||
|
if (!newForm.id.trim()) {
|
||||||
|
messageApi.warning('Author ID is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newForm.name.trim()) {
|
||||||
|
messageApi.warning('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localAuthors[newForm.id.trim()]) {
|
||||||
|
messageApi.warning('An author with this ID already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = { ...localAuthors };
|
||||||
|
updated[newForm.id.trim()] = buildEntry(newForm);
|
||||||
|
setLocalAuthors(updated);
|
||||||
|
setNewForm(emptyForm);
|
||||||
|
setAddingNew(false);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAll = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.put('/docs/blog/authors', { authors: localAuthors });
|
||||||
|
messageApi.success('Authors saved');
|
||||||
|
setDirty(false);
|
||||||
|
onSaved();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
|
||||||
|
?.error?.message || 'Failed to save authors';
|
||||||
|
messageApi.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const authorEntries = Object.entries(localAuthors);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<UserOutlined style={{ marginRight: 8 }} />
|
||||||
|
Manage Authors
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={560}
|
||||||
|
placement="right"
|
||||||
|
mask={false}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
destroyOnClose
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!dirty}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 400, overflow: 'auto' }}>
|
||||||
|
{authorEntries.length === 0 && !addingNew && (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="No authors defined yet"
|
||||||
|
style={{ margin: '24px 0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authorEntries.map(([key, entry]) => (
|
||||||
|
<div key={key}>
|
||||||
|
{editingKey === key ? (
|
||||||
|
<AuthorEditForm
|
||||||
|
form={editForm}
|
||||||
|
onChange={setEditForm}
|
||||||
|
onSave={saveEdit}
|
||||||
|
onCancel={cancelEdit}
|
||||||
|
|
||||||
|
showId
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AuthorRow
|
||||||
|
id={key}
|
||||||
|
entry={entry}
|
||||||
|
|
||||||
|
onEdit={() => startEdit(key)}
|
||||||
|
onDelete={() => deleteAuthor(key)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* New author form */}
|
||||||
|
{addingNew ? (
|
||||||
|
<AuthorEditForm
|
||||||
|
form={newForm}
|
||||||
|
onChange={setNewForm}
|
||||||
|
onSave={saveNewAuthor}
|
||||||
|
onCancel={() => {
|
||||||
|
setAddingNew(false);
|
||||||
|
setNewForm(emptyForm);
|
||||||
|
}}
|
||||||
|
showId
|
||||||
|
isNew
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setAddingNew(true)}
|
||||||
|
block
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
Add Author
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntry(form: AuthorFormState): AuthorEntry {
|
||||||
|
const entry: AuthorEntry = { name: form.name.trim() };
|
||||||
|
if (form.description.trim()) entry.description = form.description.trim();
|
||||||
|
if (form.avatar.trim()) entry.avatar = form.avatar.trim();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only author row */
|
||||||
|
function AuthorRow({
|
||||||
|
id,
|
||||||
|
entry,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
entry: AuthorEntry;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
padding: '8px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Typography.Text strong>{entry.name}</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 11, fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
|
{id}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
{entry.description && (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, display: 'block', marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{entry.description}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
{entry.avatar && (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
display: 'block',
|
||||||
|
marginTop: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Avatar: {entry.avatar}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Space size={4}>
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} />
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete this author?"
|
||||||
|
onConfirm={onDelete}
|
||||||
|
okText="Delete"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline edit / create form for an author */
|
||||||
|
function AuthorEditForm({
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
showId,
|
||||||
|
isNew,
|
||||||
|
}: {
|
||||||
|
form: AuthorFormState;
|
||||||
|
onChange: (f: AuthorFormState) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
showId?: boolean;
|
||||||
|
isNew?: boolean;
|
||||||
|
}) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
background: token.colorFillQuaternary,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showId && (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Author ID (e.g. jdoe)"
|
||||||
|
value={form.id}
|
||||||
|
onChange={(e) => onChange({ ...form, id: e.target.value })}
|
||||||
|
addonBefore="ID"
|
||||||
|
disabled={!isNew && !!form.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Display name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange({ ...form, name: e.target.value })}
|
||||||
|
addonBefore="Name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Short description (optional)"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange({ ...form, description: e.target.value })}
|
||||||
|
addonBefore="Desc"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Avatar URL (optional)"
|
||||||
|
value={form.avatar}
|
||||||
|
onChange={(e) => onChange({ ...form, avatar: e.target.value })}
|
||||||
|
addonBefore="Avatar"
|
||||||
|
/>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button size="small" type="primary" icon={<SaveOutlined />} onClick={onSave}>
|
||||||
|
{isNew ? 'Add' : 'Update'}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<CloseOutlined />} onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
admin/src/components/docs/BlogFrontmatterPanel.tsx
Normal file
265
admin/src/components/docs/BlogFrontmatterPanel.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { DatePicker, Select, Switch, Input, Typography, theme, Button, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
CalendarOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { BlogFrontmatter } from '@/hooks/useBlogFrontmatter';
|
||||||
|
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
|
||||||
|
|
||||||
|
interface BlogFrontmatterPanelProps {
|
||||||
|
frontmatter: BlogFrontmatter | null;
|
||||||
|
onUpdate: (field: string, value: unknown) => void;
|
||||||
|
authors: Record<string, AuthorEntry>;
|
||||||
|
categories: string[];
|
||||||
|
loading: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
onCollapsedChange: (collapsed: boolean) => void;
|
||||||
|
onManageAuthors: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 250;
|
||||||
|
|
||||||
|
export function BlogFrontmatterPanel({
|
||||||
|
frontmatter,
|
||||||
|
onUpdate,
|
||||||
|
authors,
|
||||||
|
categories,
|
||||||
|
loading,
|
||||||
|
collapsed,
|
||||||
|
onCollapsedChange,
|
||||||
|
onManageAuthors,
|
||||||
|
}: BlogFrontmatterPanelProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
if (!frontmatter) return null;
|
||||||
|
|
||||||
|
const authorOptions = Object.entries(authors || {}).map(([key, entry]) => ({
|
||||||
|
label: entry.name,
|
||||||
|
value: key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const categoryOptions = (categories || []).map((cat) => ({
|
||||||
|
label: cat,
|
||||||
|
value: cat,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 8,
|
||||||
|
borderLeft: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="Show blog panel" placement="left">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
onClick={() => onCollapsedChange(false)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: PANEL_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderLeft: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||||
|
Blog
|
||||||
|
</Typography.Text>
|
||||||
|
<Tooltip title="Collapse panel">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={() => onCollapsedChange(true)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Date */}
|
||||||
|
<FieldGroup icon={<CalendarOutlined />} label="Date">
|
||||||
|
<DatePicker
|
||||||
|
value={frontmatter.date ? dayjs(frontmatter.date) : undefined}
|
||||||
|
onChange={(d) => onUpdate('date', d ? d.format('YYYY-MM-DD') : undefined)}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Authors */}
|
||||||
|
<FieldGroup
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
label="Authors"
|
||||||
|
extra={
|
||||||
|
<Tooltip title="Manage authors">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<TeamOutlined />}
|
||||||
|
onClick={onManageAuthors}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
size="small"
|
||||||
|
placeholder="Select authors"
|
||||||
|
value={frontmatter.authors ?? []}
|
||||||
|
onChange={(val) => onUpdate('authors', val)}
|
||||||
|
options={authorOptions}
|
||||||
|
loading={loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<FieldGroup icon={<FolderOutlined />} label="Categories">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
size="small"
|
||||||
|
placeholder="Add categories"
|
||||||
|
value={frontmatter.categories ?? []}
|
||||||
|
onChange={(val) => onUpdate('categories', val)}
|
||||||
|
options={categoryOptions}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<FieldGroup icon={<TagsOutlined />} label="Tags">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
size="small"
|
||||||
|
placeholder="Add tags"
|
||||||
|
value={frontmatter.tags ?? []}
|
||||||
|
onChange={(val) => onUpdate('tags', val)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Draft */}
|
||||||
|
<FieldGroup icon={<EyeInvisibleOutlined />} label="Draft">
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={frontmatter.draft ?? false}
|
||||||
|
onChange={(checked) => onUpdate('draft', checked || undefined)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<FieldGroup icon={<LinkOutlined />} label="Slug">
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="auto-generated"
|
||||||
|
value={frontmatter.slug ?? ''}
|
||||||
|
onChange={(e) => onUpdate('slug', e.target.value || undefined)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<FieldGroup icon={<FileTextOutlined />} label="Description">
|
||||||
|
<Input.TextArea
|
||||||
|
size="small"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Post description / excerpt"
|
||||||
|
value={frontmatter.description ?? ''}
|
||||||
|
onChange={(e) => onUpdate('description', e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small labeled field wrapper */
|
||||||
|
function FieldGroup({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
extra,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: token.colorTextSecondary, fontSize: 12 }}>{icon}</span>
|
||||||
|
<Typography.Text
|
||||||
|
style={{ fontSize: 11, fontWeight: 600, color: token.colorTextSecondary, textTransform: 'uppercase', letterSpacing: 0.3 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography.Text>
|
||||||
|
{extra && <span style={{ marginLeft: 'auto' }}>{extra}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
admin/src/components/docs/DocAccessPolicyPanel.tsx
Normal file
322
admin/src/components/docs/DocAccessPolicyPanel.tsx
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
message,
|
||||||
|
Alert,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
LockOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
UndoOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
interface DocAccessPolicyPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
documentPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EffectivePolicy {
|
||||||
|
id: string | null;
|
||||||
|
documentPath: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
allowedEditors: string[];
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_ROLES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'role:SUPER_ADMIN', label: 'Super Admin' },
|
||||||
|
{ value: 'role:CONTENT_ADMIN', label: 'Content Admin' },
|
||||||
|
{ value: 'role:INFLUENCE_ADMIN', label: 'Influence Admin' },
|
||||||
|
{ value: 'role:MAP_ADMIN', label: 'Map Admin' },
|
||||||
|
{ value: 'role:BROADCAST_ADMIN', label: 'Broadcast Admin' },
|
||||||
|
{ value: 'role:MEDIA_ADMIN', label: 'Media Admin' },
|
||||||
|
{ value: 'role:PAYMENTS_ADMIN', label: 'Payments Admin' },
|
||||||
|
{ value: 'role:EVENTS_ADMIN', label: 'Events Admin' },
|
||||||
|
{ value: 'role:SOCIAL_ADMIN', label: 'Social Admin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function editorLabel(editor: string): string {
|
||||||
|
if (editor === 'all_content_editors') return 'All Content Editors';
|
||||||
|
if (editor.startsWith('role:')) {
|
||||||
|
const roleName = editor.substring(5);
|
||||||
|
return roleName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
if (editor.startsWith('user:')) return editor.substring(5);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorColor(editor: string): string {
|
||||||
|
if (editor === 'all_content_editors') return 'green';
|
||||||
|
if (editor.startsWith('role:')) return 'blue';
|
||||||
|
if (editor.startsWith('user:')) return 'purple';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocAccessPolicyPanel({ open, onClose, documentPath }: DocAccessPolicyPanelProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [policy, setPolicy] = useState<EffectivePolicy | null>(null);
|
||||||
|
const [allContentEditors, setAllContentEditors] = useState(true);
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||||
|
const [userEmails, setUserEmails] = useState<string[]>([]);
|
||||||
|
const [isDirectory, setIsDirectory] = useState(false);
|
||||||
|
|
||||||
|
const fetchPolicy = useCallback(async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<EffectivePolicy>('/docs-access/policy', {
|
||||||
|
params: { path: documentPath },
|
||||||
|
});
|
||||||
|
setPolicy(data);
|
||||||
|
|
||||||
|
// Parse allowedEditors into form state
|
||||||
|
const hasAllContentEditors = data.allowedEditors.includes('all_content_editors');
|
||||||
|
setAllContentEditors(hasAllContentEditors);
|
||||||
|
setIsDirectory(data.isDirectory);
|
||||||
|
|
||||||
|
const roles: string[] = [];
|
||||||
|
const users: string[] = [];
|
||||||
|
for (const editor of data.allowedEditors) {
|
||||||
|
if (editor === 'all_content_editors') continue;
|
||||||
|
if (editor.startsWith('role:')) roles.push(editor);
|
||||||
|
else if (editor.startsWith('user:')) users.push(editor.substring(5));
|
||||||
|
}
|
||||||
|
setSelectedRoles(roles);
|
||||||
|
setUserEmails(users);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load access policy');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [documentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && documentPath) {
|
||||||
|
fetchPolicy();
|
||||||
|
}
|
||||||
|
}, [open, documentPath, fetchPolicy]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const allowedEditors: string[] = [];
|
||||||
|
if (allContentEditors) {
|
||||||
|
allowedEditors.push('all_content_editors');
|
||||||
|
}
|
||||||
|
allowedEditors.push(...selectedRoles);
|
||||||
|
allowedEditors.push(...userEmails.map((e) => `user:${e}`));
|
||||||
|
|
||||||
|
if (allowedEditors.length === 0) {
|
||||||
|
message.warning('At least one editor must be specified');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put('/docs-access/policy', {
|
||||||
|
documentPath,
|
||||||
|
isDirectory,
|
||||||
|
allowedEditors,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success('Access policy saved');
|
||||||
|
fetchPolicy();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to save access policy');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.delete('/docs-access/policy', {
|
||||||
|
params: { path: documentPath },
|
||||||
|
});
|
||||||
|
message.success('Policy reset to default');
|
||||||
|
fetchPolicy();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to reset policy');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName = documentPath?.split('/').pop() || 'Document';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<LockOutlined />
|
||||||
|
<span>Access Policy</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={480}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : !documentPath ? (
|
||||||
|
<Alert message="No document selected" type="info" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">Document</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{fileName}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{documentPath}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{policy?.isDefault && !policy.id && (
|
||||||
|
<Alert
|
||||||
|
message="Default policy"
|
||||||
|
description="No custom policy is set. All content editors can edit this file."
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{policy && !policy.isDefault && (
|
||||||
|
<Alert
|
||||||
|
message="Custom policy active"
|
||||||
|
description={
|
||||||
|
policy.isDirectory
|
||||||
|
? `Directory policy applied from: ${policy.documentPath}`
|
||||||
|
: 'File-specific policy'
|
||||||
|
}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Current Editors
|
||||||
|
</Title>
|
||||||
|
<div>
|
||||||
|
{policy?.allowedEditors.map((editor) => (
|
||||||
|
<Tag
|
||||||
|
key={editor}
|
||||||
|
color={editorColor(editor)}
|
||||||
|
icon={
|
||||||
|
editor === 'all_content_editors' ? (
|
||||||
|
<TeamOutlined />
|
||||||
|
) : editor.startsWith('user:') ? (
|
||||||
|
<UserOutlined />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
{editorLabel(editor)}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Edit Policy
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Form layout="vertical" style={{ width: '100%' }}>
|
||||||
|
<Form.Item label="All content editors (default)">
|
||||||
|
<Switch
|
||||||
|
checked={allContentEditors}
|
||||||
|
onChange={setAllContentEditors}
|
||||||
|
checkedChildren="On"
|
||||||
|
unCheckedChildren="Off"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Additional roles">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
value={selectedRoles}
|
||||||
|
onChange={setSelectedRoles}
|
||||||
|
options={AVAILABLE_ROLES}
|
||||||
|
placeholder="Select roles..."
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Specific users (by email)">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
value={userEmails}
|
||||||
|
onChange={setUserEmails}
|
||||||
|
placeholder="Type email and press Enter..."
|
||||||
|
tokenSeparators={[',']}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Apply to directory">
|
||||||
|
<Switch
|
||||||
|
checked={isDirectory}
|
||||||
|
onChange={setIsDirectory}
|
||||||
|
checkedChildren="Directory"
|
||||||
|
unCheckedChildren="File only"
|
||||||
|
/>
|
||||||
|
{isDirectory && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>
|
||||||
|
This policy will apply to all files within this directory and subdirectories.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
Save Policy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<UndoOutlined />}
|
||||||
|
onClick={handleReset}
|
||||||
|
loading={saving}
|
||||||
|
disabled={policy?.isDefault ?? true}
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
admin/src/components/docs/DocHistoryDrawer.tsx
Normal file
377
admin/src/components/docs/DocHistoryDrawer.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Timeline,
|
||||||
|
Button,
|
||||||
|
Spin,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
Empty,
|
||||||
|
Tag,
|
||||||
|
Divider,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
HistoryOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
RollbackOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface DocHistoryDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
documentPath: string | null;
|
||||||
|
currentContent: string;
|
||||||
|
onRestore: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryCommit {
|
||||||
|
sha: string;
|
||||||
|
commit: {
|
||||||
|
message: string;
|
||||||
|
author: { name: string; email: string; date: string };
|
||||||
|
committer: { name: string; email: string; date: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocHistoryDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
documentPath,
|
||||||
|
currentContent,
|
||||||
|
onRestore,
|
||||||
|
}: DocHistoryDrawerProps) {
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [commits, setCommits] = useState<HistoryCommit[]>([]);
|
||||||
|
const [selectedSha, setSelectedSha] = useState<string | null>(null);
|
||||||
|
const [revisionContent, setRevisionContent] = useState<string | null>(null);
|
||||||
|
const [loadingRevision, setLoadingRevision] = useState(false);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ commits: HistoryCommit[] }>(
|
||||||
|
`/docs/history/${documentPath}`,
|
||||||
|
);
|
||||||
|
setCommits(data.commits);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load version history');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [documentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && documentPath) {
|
||||||
|
fetchHistory();
|
||||||
|
setSelectedSha(null);
|
||||||
|
setRevisionContent(null);
|
||||||
|
}
|
||||||
|
}, [open, documentPath, fetchHistory]);
|
||||||
|
|
||||||
|
const handleSelectCommit = async (sha: string) => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
if (selectedSha === sha) {
|
||||||
|
// Toggle off
|
||||||
|
setSelectedSha(null);
|
||||||
|
setRevisionContent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedSha(sha);
|
||||||
|
setLoadingRevision(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ sha: string; path: string; content: string }>(
|
||||||
|
`/docs/revision/${sha}/${documentPath}`,
|
||||||
|
);
|
||||||
|
setRevisionContent(data.content);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load revision');
|
||||||
|
setRevisionContent(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingRevision(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
if (!documentPath || !selectedSha) return;
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ success: boolean; path: string }>(
|
||||||
|
`/docs/restore/${selectedSha}/${documentPath}`,
|
||||||
|
);
|
||||||
|
if (data.success && revisionContent) {
|
||||||
|
onRestore(revisionContent);
|
||||||
|
message.success('File restored to selected version');
|
||||||
|
setSelectedSha(null);
|
||||||
|
setRevisionContent(null);
|
||||||
|
fetchHistory();
|
||||||
|
} else {
|
||||||
|
message.error('Failed to restore revision');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to restore revision');
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName = documentPath?.split('/').pop() || 'Document';
|
||||||
|
|
||||||
|
// Simple line-by-line diff display
|
||||||
|
const renderDiffView = () => {
|
||||||
|
if (loadingRevision) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (revisionContent === null) return null;
|
||||||
|
|
||||||
|
const currentLines = currentContent.split('\n');
|
||||||
|
const historicalLines = revisionContent.split('\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Space style={{ marginBottom: 8 }}>
|
||||||
|
<Tag color="blue">Viewing revision {selectedSha?.substring(0, 7)}</Tag>
|
||||||
|
<Popconfirm
|
||||||
|
title="Restore this version?"
|
||||||
|
description="The current file content will be replaced with this revision."
|
||||||
|
onConfirm={handleRestore}
|
||||||
|
okText="Restore"
|
||||||
|
cancelText="Cancel"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<RollbackOutlined />}
|
||||||
|
loading={restoring}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSha(null);
|
||||||
|
setRevisionContent(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
|
||||||
|
Historical ({selectedSha?.substring(0, 7)})
|
||||||
|
</Text>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
background: themeToken.colorBgLayout,
|
||||||
|
border: `1px solid ${themeToken.colorBorderSecondary}`,
|
||||||
|
borderRadius: themeToken.borderRadius,
|
||||||
|
padding: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: 400,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{historicalLines.map((line, i) => {
|
||||||
|
const isDiff = i < currentLines.length && line !== currentLines[i];
|
||||||
|
const isAdded = i >= currentLines.length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: isDiff
|
||||||
|
? 'rgba(82, 196, 26, 0.1)'
|
||||||
|
: isAdded
|
||||||
|
? 'rgba(82, 196, 26, 0.15)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
|
||||||
|
{i + 1}
|
||||||
|
</Text>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
|
||||||
|
Current
|
||||||
|
</Text>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
background: themeToken.colorBgLayout,
|
||||||
|
border: `1px solid ${themeToken.colorBorderSecondary}`,
|
||||||
|
borderRadius: themeToken.borderRadius,
|
||||||
|
padding: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: 400,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentLines.map((line, i) => {
|
||||||
|
const isDiff = i < historicalLines.length && line !== historicalLines[i];
|
||||||
|
const isAdded = i >= historicalLines.length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: isDiff
|
||||||
|
? 'rgba(245, 34, 45, 0.1)'
|
||||||
|
: isAdded
|
||||||
|
? 'rgba(245, 34, 45, 0.15)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
|
||||||
|
{i + 1}
|
||||||
|
</Text>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<HistoryOutlined />
|
||||||
|
<span>Version History</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={selectedSha ? 800 : 420}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : !documentPath ? (
|
||||||
|
<Empty description="No document selected" />
|
||||||
|
) : commits.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
image={<FileTextOutlined style={{ fontSize: 48, color: themeToken.colorTextDisabled }} />}
|
||||||
|
description="No version history available"
|
||||||
|
>
|
||||||
|
<Text type="secondary">
|
||||||
|
History is recorded when files are saved with Gitea integration enabled.
|
||||||
|
</Text>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">File: </Text>
|
||||||
|
<Text strong>{fileName}</Text>
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{commits.length} revision{commits.length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<Timeline
|
||||||
|
items={commits.map((commit) => ({
|
||||||
|
color: selectedSha === commit.sha ? themeToken.colorPrimary : themeToken.colorTextSecondary,
|
||||||
|
children: (
|
||||||
|
<div
|
||||||
|
key={commit.sha}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: themeToken.borderRadius,
|
||||||
|
background:
|
||||||
|
selectedSha === commit.sha
|
||||||
|
? themeToken.colorPrimaryBg
|
||||||
|
: undefined,
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onClick={() => handleSelectCommit(commit.sha)}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 2 }}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>
|
||||||
|
{commit.commit.message}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Space size={8}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
<UserOutlined style={{ marginRight: 4 }} />
|
||||||
|
{commit.commit.author.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{dayjs(commit.commit.author.date).fromNow()}
|
||||||
|
</Text>
|
||||||
|
<Tag style={{ fontSize: 10 }}>{commit.sha.substring(0, 7)}</Tag>
|
||||||
|
</Space>
|
||||||
|
{selectedSha !== commit.sha && (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSelectCommit(commit.sha);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderDiffView()}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
390
admin/src/components/docs/DocSharePanel.tsx
Normal file
390
admin/src/components/docs/DocSharePanel.tsx
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Tooltip,
|
||||||
|
message,
|
||||||
|
Alert,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
ShareAltOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface DocSharePanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
documentPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareLink {
|
||||||
|
id: string;
|
||||||
|
documentPath: string;
|
||||||
|
shareToken: string;
|
||||||
|
status: 'ACTIVE' | 'REVOKED' | 'EXPIRED';
|
||||||
|
canEdit: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
maxUses: number | null;
|
||||||
|
useCount: number;
|
||||||
|
guestName: string | null;
|
||||||
|
createdBy: { id: string; name: string | null; email: string };
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPIRY_OPTIONS = [
|
||||||
|
{ value: 1, label: '1 hour' },
|
||||||
|
{ value: 24, label: '24 hours' },
|
||||||
|
{ value: 168, label: '7 days' },
|
||||||
|
{ value: 720, label: '30 days' },
|
||||||
|
{ value: 0, label: 'No expiry' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusTag(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'ACTIVE':
|
||||||
|
return (
|
||||||
|
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||||
|
Active
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 'REVOKED':
|
||||||
|
return (
|
||||||
|
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||||
|
Revoked
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 'EXPIRED':
|
||||||
|
return (
|
||||||
|
<Tag icon={<ClockCircleOutlined />} color="default">
|
||||||
|
Expired
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Tag>{status}</Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocSharePanel({ open, onClose, documentPath }: DocSharePanelProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [links, setLinks] = useState<ShareLink[]>([]);
|
||||||
|
const [generatedUrl, setGeneratedUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [canEdit, setCanEdit] = useState(true);
|
||||||
|
const [expiryHours, setExpiryHours] = useState<number>(168); // 7 days default
|
||||||
|
const [maxUses, setMaxUses] = useState<number | null>(null);
|
||||||
|
const [guestName, setGuestName] = useState('');
|
||||||
|
|
||||||
|
const fetchLinks = useCallback(async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ links: ShareLink[] }>('/docs-access/share/links', {
|
||||||
|
params: { path: documentPath },
|
||||||
|
});
|
||||||
|
setLinks(data.links);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load share links');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [documentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && documentPath) {
|
||||||
|
fetchLinks();
|
||||||
|
setGeneratedUrl(null);
|
||||||
|
}
|
||||||
|
}, [open, documentPath, fetchLinks]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
documentPath,
|
||||||
|
canEdit,
|
||||||
|
};
|
||||||
|
if (expiryHours > 0) {
|
||||||
|
payload.expiresInHours = expiryHours;
|
||||||
|
}
|
||||||
|
if (maxUses && maxUses > 0) {
|
||||||
|
payload.maxUses = maxUses;
|
||||||
|
}
|
||||||
|
if (guestName.trim()) {
|
||||||
|
payload.guestName = guestName.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.post<{ id: string; shareToken: string; documentPath: string }>(
|
||||||
|
'/docs-access/share/create',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/docs/share/${data.shareToken}`;
|
||||||
|
setGeneratedUrl(url);
|
||||||
|
message.success('Share link created');
|
||||||
|
fetchLinks();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to create share link');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
message.success('Link copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to copy link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/docs-access/share/${id}/revoke`);
|
||||||
|
message.success('Share link revoked');
|
||||||
|
fetchLinks();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to revoke share link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<ShareLink> = [
|
||||||
|
{
|
||||||
|
title: 'Token',
|
||||||
|
dataIndex: 'shareToken',
|
||||||
|
key: 'token',
|
||||||
|
width: 100,
|
||||||
|
render: (token: string) => (
|
||||||
|
<Tooltip title={token}>
|
||||||
|
<Text code copyable={{ text: `${window.location.origin}/docs/share/${token}` }}>
|
||||||
|
{token.substring(0, 8)}...
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Guest',
|
||||||
|
dataIndex: 'guestName',
|
||||||
|
key: 'guest',
|
||||||
|
width: 100,
|
||||||
|
render: (name: string | null) => name || <Text type="secondary">--</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Created',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'created',
|
||||||
|
width: 110,
|
||||||
|
render: (date: string) => (
|
||||||
|
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
|
||||||
|
<span>{dayjs(date).fromNow()}</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Expiry',
|
||||||
|
dataIndex: 'expiresAt',
|
||||||
|
key: 'expiry',
|
||||||
|
width: 110,
|
||||||
|
render: (date: string | null) =>
|
||||||
|
date ? (
|
||||||
|
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
|
||||||
|
<span>{dayjs(date).fromNow()}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">Never</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Uses',
|
||||||
|
key: 'uses',
|
||||||
|
width: 70,
|
||||||
|
render: (_: unknown, record: ShareLink) => (
|
||||||
|
<span>
|
||||||
|
{record.useCount}
|
||||||
|
{record.maxUses ? ` / ${record.maxUses}` : ''}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (status: string) => statusTag(status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 60,
|
||||||
|
render: (_: unknown, record: ShareLink) =>
|
||||||
|
record.status === 'ACTIVE' ? (
|
||||||
|
<Tooltip title="Revoke">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => handleRevoke(record.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fileName = documentPath?.split('/').pop() || 'Document';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<ShareAltOutlined />
|
||||||
|
<span>Share Document</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={640}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{!documentPath ? (
|
||||||
|
<Alert message="No document selected" type="info" />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">Document</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{fileName}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{documentPath}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }}>Create Share Link</Divider>
|
||||||
|
|
||||||
|
<Form layout="vertical" style={{ width: '100%' }}>
|
||||||
|
<Form.Item label="Can edit">
|
||||||
|
<Switch
|
||||||
|
checked={canEdit}
|
||||||
|
onChange={setCanEdit}
|
||||||
|
checkedChildren="Edit"
|
||||||
|
unCheckedChildren="View only"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Expiry">
|
||||||
|
<Select
|
||||||
|
value={expiryHours}
|
||||||
|
onChange={setExpiryHours}
|
||||||
|
options={EXPIRY_OPTIONS}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Max uses (optional)">
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={maxUses}
|
||||||
|
onChange={(v) => setMaxUses(v)}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Guest name (optional)">
|
||||||
|
<Input
|
||||||
|
value={guestName}
|
||||||
|
onChange={(e) => setGuestName(e.target.value)}
|
||||||
|
placeholder="Name shown to collaborators"
|
||||||
|
maxLength={200}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
loading={creating}
|
||||||
|
>
|
||||||
|
Generate Link
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{generatedUrl && (
|
||||||
|
<Alert
|
||||||
|
message="Share link created"
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Paragraph
|
||||||
|
copyable={{
|
||||||
|
text: generatedUrl,
|
||||||
|
onCopy: () => message.success('Copied'),
|
||||||
|
}}
|
||||||
|
style={{ margin: 0, wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{generatedUrl}
|
||||||
|
</Paragraph>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => handleCopyUrl(generatedUrl)}
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setGeneratedUrl(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }}>Active Links</Divider>
|
||||||
|
|
||||||
|
<Table<ShareLink>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={links}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
locale={{ emptyText: 'No share links for this document' }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
admin/src/components/docs/NewBlogPostModal.tsx
Normal file
170
admin/src/components/docs/NewBlogPostModal.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Drawer, Form, Input, DatePicker, Select, Switch, Button, message, theme } from 'antd';
|
||||||
|
import { FileMarkdownOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
|
||||||
|
|
||||||
|
interface NewBlogPostModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (path: string) => void;
|
||||||
|
authors: Record<string, AuthorEntry>;
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewBlogPostModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
authors,
|
||||||
|
categories,
|
||||||
|
}: NewBlogPostModalProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
const titleValue = Form.useWatch('title', form) as string | undefined;
|
||||||
|
const dateValue = Form.useWatch('date', form) as dayjs.Dayjs | undefined;
|
||||||
|
|
||||||
|
const slug = titleValue ? slugify(titleValue) : '';
|
||||||
|
const dateStr = dateValue ? dateValue.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||||
|
const previewFilename = slug ? `blog/posts/${dateStr}-${slug}.md` : '';
|
||||||
|
|
||||||
|
const authorOptions = Object.entries(authors || {}).map(([key, entry]) => ({
|
||||||
|
label: entry.name,
|
||||||
|
value: key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const categoryOptions = (categories || []).map((cat) => ({
|
||||||
|
label: cat,
|
||||||
|
value: cat,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const res = await api.post<{ path: string }>('/docs/blog/posts', {
|
||||||
|
title: values.title,
|
||||||
|
date: values.date ? values.date.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
|
||||||
|
authors: values.authors ?? [],
|
||||||
|
categories: values.categories ?? [],
|
||||||
|
draft: values.draft ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
messageApi.success('Blog post created');
|
||||||
|
form.resetFields();
|
||||||
|
onCreated(res.data.path);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
|
||||||
|
?.error?.message || 'Failed to create blog post';
|
||||||
|
messageApi.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<FileMarkdownOutlined style={{ marginRight: 8 }} />
|
||||||
|
New Blog Post
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
width={480}
|
||||||
|
placement="right"
|
||||||
|
mask={false}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
destroyOnClose
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{contextHolder}
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
date: dayjs(),
|
||||||
|
draft: true,
|
||||||
|
}}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
rules={[{ required: true, message: 'Title is required' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="My New Blog Post" autoFocus />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{previewFilename && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: -12,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
background: token.colorFillQuaternary,
|
||||||
|
fontSize: 12,
|
||||||
|
color: token.colorTextSecondary,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewFilename}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item name="date" label="Date">
|
||||||
|
<DatePicker format="YYYY-MM-DD" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="authors" label="Author(s)">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="Select authors"
|
||||||
|
options={authorOptions}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="categories" label="Categories">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
placeholder="Add categories"
|
||||||
|
options={categoryOptions}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="draft" label="Draft" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import type {
|
|||||||
AreaImportProgress,
|
AreaImportProgress,
|
||||||
AreaImportSourceStatus,
|
AreaImportSourceStatus,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
@ -232,8 +233,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post('/map/area-import/preview', buildRequestBody());
|
const { data } = await api.post('/map/area-import/preview', buildRequestBody());
|
||||||
setPreview(data);
|
setPreview(data);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed');
|
setPreviewError(getErrorMessage(err, 'Preview failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(false);
|
setPreviewLoading(false);
|
||||||
}
|
}
|
||||||
@ -259,8 +260,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
|
|||||||
// Ignore polling errors
|
// Ignore polling errors
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setPreviewError(err?.response?.data?.error?.message || 'Failed to start import');
|
setPreviewError(getErrorMessage(err, 'Failed to start import'));
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
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';
|
||||||
import type { PlaylistSummary } from '@/types/media';
|
import type { PlaylistSummary } from '@/types/media';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -139,8 +140,8 @@ export default function AddToPlaylistModal({
|
|||||||
{ ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
|
{ ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
|
||||||
]);
|
]);
|
||||||
setSelections((prev) => ({ ...prev, [data.id]: true }));
|
setSelections((prev) => ({ ...prev, [data.id]: true }));
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 409) {
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||||
message.error('You already have a playlist with this name');
|
message.error('You already have a playlist with this name');
|
||||||
} else {
|
} else {
|
||||||
message.error('Failed to create playlist');
|
message.error('Failed to create playlist');
|
||||||
@ -151,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 }}>
|
||||||
@ -237,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 { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
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 { getTokens } = getAuthCallbacks();
|
if (!signed) {
|
||||||
const { accessToken } = getTokens();
|
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,6 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
interface BulkAccessLevelModalProps {
|
interface BulkAccessLevelModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -28,21 +29,27 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
|
|||||||
});
|
});
|
||||||
message.success(`Updated access level to "${accessLevel}" for ${data.updatedCount} video(s)`);
|
message.success(`Updated access level to "${accessLevel}" for ${data.updatedCount} video(s)`);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
message.error(error.response?.data?.message || 'Failed to update access levels');
|
message.error(getErrorMessage(error, 'Failed to update access levels'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
||||||
@ -53,6 +60,6 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
|
|||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
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';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -62,8 +63,8 @@ export default function BulkAddToPlaylistModal({
|
|||||||
try {
|
try {
|
||||||
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
|
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
|
||||||
added++;
|
added++;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 409) {
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||||
skipped++;
|
skipped++;
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
@ -98,8 +99,8 @@ export default function BulkAddToPlaylistModal({
|
|||||||
setNewName('');
|
setNewName('');
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
message.success(`Created "${data.name}"`);
|
message.success(`Created "${data.name}"`);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 409) {
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||||
message.error('You already have a playlist with this name');
|
message.error('You already have a playlist with this name');
|
||||||
} else {
|
} else {
|
||||||
message.error('Failed to create playlist');
|
message.error('Failed to create playlist');
|
||||||
@ -112,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 }}>
|
||||||
@ -183,6 +189,6 @@ export default function BulkAddToPlaylistModal({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
|
|||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@ -105,8 +106,8 @@ export default function CommentSection({ videoId }: CommentSectionProps) {
|
|||||||
setComments((prev) => [response.data.comment, ...prev]);
|
setComments((prev) => [response.data.comment, ...prev]);
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
message.success('Comment posted!');
|
message.success('Comment posted!');
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
message.error('Please log in to comment');
|
message.error('Please log in to comment');
|
||||||
} else {
|
} else {
|
||||||
message.error('Failed to post comment');
|
message.error('Failed to post comment');
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
interface CreateAlbumModalProps {
|
interface CreateAlbumModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -30,8 +31,8 @@ export default function CreateAlbumModal({
|
|||||||
message.success('Album created');
|
message.success('Album created');
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.data?.message) {
|
if (axios.isAxiosError(error) && error.response?.data?.message) {
|
||||||
message.error(error.response.data.message);
|
message.error(error.response.data.message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -40,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' }]}>
|
||||||
@ -61,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,6 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
interface CreatePlaylistModalProps {
|
interface CreatePlaylistModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -31,10 +32,10 @@ export default function CreatePlaylistModal({
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
onCreated?.(data);
|
onCreated?.(data);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 409) {
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||||
message.error('You already have a playlist with this name');
|
message.error('You already have a playlist with this name');
|
||||||
} else if (!error.errorFields) {
|
} else if (!(error instanceof Object && 'errorFields' in error)) {
|
||||||
message.error('Failed to create playlist');
|
message.error('Failed to create playlist');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -52,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">
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { Drawer, Form, Input, Select, Descriptions, message, Button } from 'antd';
|
import { Drawer, Form, Input, Select, Descriptions, message, Button } from 'antd';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import type { Photo } from '@/types/media';
|
import type { Photo } from '@/types/media';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
interface EditPhotoModalProps {
|
interface EditPhotoModalProps {
|
||||||
photo: Photo | null;
|
photo: Photo | null;
|
||||||
@ -34,8 +35,8 @@ export default function EditPhotoModal({ photo, open, onClose, onSuccess }: Edit
|
|||||||
await mediaApi.patch(`/photos/${photo.id}`, values);
|
await mediaApi.patch(`/photos/${photo.id}`, values);
|
||||||
message.success('Photo updated');
|
message.success('Photo updated');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
message.error(error.response?.data?.message || 'Failed to update photo');
|
message.error(getErrorMessage(error, 'Failed to update photo'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/
|
|||||||
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';
|
||||||
import type { PlaylistVideoItem } from '@/types/media';
|
import type { PlaylistVideoItem } from '@/types/media';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -71,10 +72,10 @@ export default function EditPlaylistModal({
|
|||||||
|
|
||||||
message.success('Playlist updated');
|
message.success('Playlist updated');
|
||||||
onUpdated?.();
|
onUpdated?.();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 409) {
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||||
message.error('You already have a playlist with this name');
|
message.error('You already have a playlist with this name');
|
||||||
} else if (!error.errorFields) {
|
} else if (!(error instanceof Object && 'errorFields' in error)) {
|
||||||
message.error('Failed to update playlist');
|
message.error('Failed to update playlist');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -129,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
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { EditOutlined } from '@ant-design/icons';
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import type { Video } from '@/types/media';
|
import type { Video } from '@/types/media';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
interface EditVideoDrawerProps {
|
interface EditVideoDrawerProps {
|
||||||
video: Video | null;
|
video: Video | null;
|
||||||
@ -87,8 +88,8 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
|
|||||||
message.success('Video updated successfully');
|
message.success('Video updated successfully');
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.data?.message) {
|
if (axios.isAxiosError(error) && error.response?.data?.message) {
|
||||||
message.error(error.response.data.message);
|
message.error(error.response.data.message);
|
||||||
}
|
}
|
||||||
// form validation errors are shown inline
|
// form validation errors are shown inline
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
import type { PublicAlbum } from '@/types/media';
|
import type { PublicAlbum } from '@/types/media';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@ -103,8 +104,8 @@ export default function ExpandedAlbumCard({ album }: ExpandedAlbumCardProps) {
|
|||||||
}
|
}
|
||||||
setHasUpvoted(true);
|
setHasUpvoted(true);
|
||||||
setUpvoteCount(prev => prev + 1);
|
setUpvoteCount(prev => prev + 1);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
message.info('Please log in to upvote');
|
message.info('Please log in to upvote');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
import type { PublicPhoto } from '@/types/media';
|
import type { PublicPhoto } from '@/types/media';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@ -80,8 +81,8 @@ export default function ExpandedPhotoCard({ photo }: ExpandedPhotoCardProps) {
|
|||||||
await mediaPublicApi.post(`/photos/${photo.id}/upvote`);
|
await mediaPublicApi.post(`/photos/${photo.id}/upvote`);
|
||||||
setHasUpvoted(true);
|
setHasUpvoted(true);
|
||||||
setUpvoteCount(prev => prev + 1);
|
setUpvoteCount(prev => prev + 1);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
message.info('Please log in to upvote');
|
message.info('Please log in to upvote');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import ReactionButtons from './ReactionButtons';
|
|||||||
import AddToPlaylistModal from './AddToPlaylistModal';
|
import AddToPlaylistModal from './AddToPlaylistModal';
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@ -117,9 +118,9 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
|||||||
await mediaPublicApi.post(`/public/${video.id}/upvote`);
|
await mediaPublicApi.post(`/public/${video.id}/upvote`);
|
||||||
setHasUpvoted(true);
|
setHasUpvoted(true);
|
||||||
setUpvoteCount(prev => prev + 1);
|
setUpvoteCount(prev => prev + 1);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Upvote failed:', error);
|
console.error('Upvote failed:', error);
|
||||||
if (error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
alert('Please log in to upvote videos');
|
alert('Please log in to upvote videos');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import {
|
|||||||
ExpandOutlined,
|
ExpandOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@ -147,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: {
|
||||||
@ -260,8 +255,8 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
|
|||||||
// Immediately expand the new job
|
// Immediately expand the new job
|
||||||
setExpandedJobId(data.jobId);
|
setExpandedJobId(data.jobId);
|
||||||
fetchJobs();
|
fetchJobs();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
message.error(err.response?.data?.message || 'Failed to submit fetch job');
|
message.error(getErrorMessage(err, 'Failed to submit fetch job'));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -272,8 +267,8 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
|
|||||||
await mediaApi.delete(`/videos/fetch/jobs/${jobId}`);
|
await mediaApi.delete(`/videos/fetch/jobs/${jobId}`);
|
||||||
message.success('Job cancelled');
|
message.success('Job cancelled');
|
||||||
fetchJobs();
|
fetchJobs();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
message.error(err.response?.data?.message || 'Failed to cancel job');
|
message.error(getErrorMessage(err, 'Failed to cancel job'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import { useMediaAuth } from '@/contexts/MediaAuthContext';
|
import { useMediaAuth } from '@/contexts/MediaAuthContext';
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@ -302,12 +303,12 @@ export default function LiveChat({
|
|||||||
setCommentInput('');
|
setCommentInput('');
|
||||||
|
|
||||||
// Note: New comment will appear via SSE broadcast
|
// Note: New comment will appear via SSE broadcast
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to submit comment:', err);
|
console.error('Failed to submit comment:', err);
|
||||||
|
|
||||||
if (err.response?.status === 429) {
|
if (axios.isAxiosError(err) && err.response?.status === 429) {
|
||||||
alert('Rate limit exceeded. Please wait a minute before commenting again.');
|
alert('Rate limit exceeded. Please wait a minute before commenting again.');
|
||||||
} else if (err.response?.status === 401) {
|
} else if (axios.isAxiosError(err) && err.response?.status === 401) {
|
||||||
alert('Please log in to comment.');
|
alert('Please log in to comment.');
|
||||||
if (onRequestLogin) {
|
if (onRequestLogin) {
|
||||||
onRequestLogin();
|
onRequestLogin();
|
||||||
|
|||||||
@ -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 { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
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 { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
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,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Modal, Select, message } from 'antd';
|
import { Modal, Select, message } from 'antd';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
interface PublishModalProps {
|
interface PublishModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -28,8 +29,8 @@ export default function PublishModal({ open, videoIds, onSuccess, onCancel }: Pu
|
|||||||
message.success(`Successfully published ${videoIds.length} video(s) to ${category}`);
|
message.success(`Successfully published ${videoIds.length} video(s) to ${category}`);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
setCategory('videos');
|
setCategory('videos');
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
message.error(error.response?.data?.message || 'Failed to publish videos');
|
message.error(getErrorMessage(error, 'Failed to publish videos'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import type { VideoAnalytics } from '@/types/media';
|
import type { VideoAnalytics } from '@/types/media';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
interface QuickAnalyticsModalProps {
|
interface QuickAnalyticsModalProps {
|
||||||
videoId: number;
|
videoId: number;
|
||||||
@ -41,9 +42,9 @@ export default function QuickAnalyticsModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
|
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
|
||||||
setAnalytics(response.data);
|
setAnalytics(response.data);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to fetch analytics:', error);
|
console.error('Failed to fetch analytics:', error);
|
||||||
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
|
setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Space, Button, message, theme } from 'antd';
|
|||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import { hexToRgba } from '@/utils/color';
|
import { hexToRgba } from '@/utils/color';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
interface ReactionButtonsProps {
|
interface ReactionButtonsProps {
|
||||||
videoId: number;
|
videoId: number;
|
||||||
@ -63,8 +64,8 @@ export default function ReactionButtons({ videoId, currentTime }: ReactionButton
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
message.success(`${emoji} reaction added!`);
|
message.success(`${emoji} reaction added!`);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
message.error('Please log in to add reactions');
|
message.error('Please log in to add reactions');
|
||||||
} else {
|
} else {
|
||||||
message.error('Failed to add reaction');
|
message.error('Failed to add reaction');
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined }
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
interface ScheduleEvent {
|
interface ScheduleEvent {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@ -55,9 +56,9 @@ export default function ScheduleCalendarDrawer({
|
|||||||
params: { limit: 100 },
|
params: { limit: 100 },
|
||||||
});
|
});
|
||||||
setSchedules(response.data.schedules || []);
|
setSchedules(response.data.schedules || []);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to fetch schedules:', error);
|
console.error('Failed to fetch schedules:', error);
|
||||||
setError(error.response?.data?.message || 'Failed to load schedules. Please try again.');
|
setError(getErrorMessage(error, 'Failed to load schedules. Please try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -69,8 +70,8 @@ export default function ScheduleCalendarDrawer({
|
|||||||
message.success(`${action} schedule cancelled`);
|
message.success(`${action} schedule cancelled`);
|
||||||
fetchSchedules();
|
fetchSchedules();
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`);
|
message.error(getErrorMessage(error, `Failed to cancel ${action} schedule`));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
@ -6,6 +6,7 @@ import utc from 'dayjs/plugin/utc';
|
|||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import type { Video } from '@/types/media';
|
import type { Video } from '@/types/media';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@ -100,8 +101,8 @@ export default function SchedulePublishModal({
|
|||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
message.error(error.response?.data?.message || 'Failed to schedule video');
|
message.error(getErrorMessage(error, 'Failed to schedule video'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -116,8 +117,8 @@ export default function SchedulePublishModal({
|
|||||||
message.success(`${action} schedule cancelled`);
|
message.success(`${action} schedule cancelled`);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`);
|
message.error(getErrorMessage(error, `Failed to cancel ${action} schedule`));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -151,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 />
|
||||||
@ -159,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 && (
|
||||||
@ -301,6 +303,6 @@ export default function SchedulePublishModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Drawer, Upload, Form, Input, Select, Button, message, Progress, List, T
|
|||||||
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
import type { PhotoAlbum } from '@/types/media';
|
import type { PhotoAlbum } from '@/types/media';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
@ -75,11 +76,11 @@ export default function UploadPhotoDrawer({ open, onClose, onSuccess, albumId }:
|
|||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
uploadResults.push({ filename: file.name, success: true });
|
uploadResults.push({ filename: file.name, success: true });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
uploadResults.push({
|
uploadResults.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: false,
|
success: false,
|
||||||
error: error.response?.data?.message || 'Upload failed',
|
error: getErrorMessage(error, 'Upload failed'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@ -117,11 +118,11 @@ export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVi
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
uploadResults.push({
|
uploadResults.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: false,
|
success: false,
|
||||||
error: error.response?.data?.message || 'Upload failed',
|
error: getErrorMessage(error, 'Upload failed'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { mediaApi } from '@/lib/media-api';
|
|||||||
import type { VideoAnalytics } from '@/types/media';
|
import type { VideoAnalytics } from '@/types/media';
|
||||||
import AnalyticsChart from './AnalyticsChart';
|
import AnalyticsChart from './AnalyticsChart';
|
||||||
import ViewersTable from './ViewersTable';
|
import ViewersTable from './ViewersTable';
|
||||||
|
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||||
|
|
||||||
interface VideoAnalyticsModalProps {
|
interface VideoAnalyticsModalProps {
|
||||||
videoId: number | null;
|
videoId: number | null;
|
||||||
@ -46,9 +47,9 @@ export default function VideoAnalyticsModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
|
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
|
||||||
setAnalytics(response.data);
|
setAnalytics(response.data);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to fetch analytics:', error);
|
console.error('Failed to fetch analytics:', error);
|
||||||
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
|
setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
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 { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
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);
|
||||||
@ -139,8 +141,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
// Use relative URL to go through nginx proxy
|
// Use relative URL to go through nginx proxy
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
@ -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 { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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