Compare commits

..

2 Commits

1452 changed files with 422899 additions and 2349 deletions

View File

@ -0,0 +1,180 @@
---
name: foss-compliance-reviewer
description: "Use this agent when new dependencies, services, or tools are being added to the project, or when reviewing code changes that introduce third-party libraries, external services, or infrastructure components. It should verify that the project maintains its commitment to Free and Open Source Software (FOSS) principles.\\n\\nExamples:\\n\\n- Example 1:\\n user: \"Let's add Stripe for payment processing and Auth0 for authentication\"\\n assistant: \"Let me check these choices against our FOSS principles before proceeding.\"\\n <uses Task tool to launch foss-compliance-reviewer agent to evaluate Stripe and Auth0>\\n assistant: \"The FOSS compliance reviewer has flagged both Stripe and Auth0 as proprietary services. Here are the findings and suggested alternatives...\"\\n\\n- Example 2:\\n user: \"I just added chart.js and @sentry/node to the project\"\\n assistant: \"Let me verify these new dependencies align with our FOSS standards.\"\\n <uses Task tool to launch foss-compliance-reviewer agent to review chart.js and @sentry/node>\\n assistant: \"The reviewer found that chart.js is MIT-licensed (approved), but @sentry/node connects to a proprietary SaaS by default. Here are the details...\"\\n\\n- Example 3:\\n user: \"Can you set up monitoring with Datadog?\"\\n assistant: \"Before implementing this, let me check if Datadog aligns with our FOSS principles.\"\\n <uses Task tool to launch foss-compliance-reviewer agent to evaluate Datadog vs existing FOSS monitoring stack>\\n assistant: \"The FOSS compliance reviewer notes that we already have a Prometheus + Grafana monitoring stack which is fully open source. Datadog is proprietary SaaS. Recommendation is to continue using our existing FOSS stack.\"\\n\\n- Example 4:\\n user: \"Please install these packages: prisma, bullmq, axios, and algolia\"\\n assistant: \"Let me review these packages for FOSS compliance before installing.\"\\n <uses Task tool to launch foss-compliance-reviewer agent to review prisma, bullmq, axios, and algolia>\\n assistant: \"The reviewer approved prisma (Apache-2.0), bullmq (MIT), and axios (MIT). However, Algolia's search client connects to proprietary SaaS. Suggested alternatives include Meilisearch or Typesense.\""
model: sonnet
color: purple
memory: project
---
You are an expert Free and Open Source Software (FOSS) compliance reviewer with deep knowledge of open source licensing, the FOSS ecosystem, and self-hosted infrastructure. You have extensive experience evaluating software dependencies, services, and tools against FOSS principles. You understand the nuances between truly open source software, source-available software, open-core models, and proprietary systems.
## Your Mission
You review technology choices in the Changemaker Lite project to ensure the stack remains predominantly Free and Open Source. This is a self-hosted political campaign platform that values digital sovereignty, transparency, and community-driven software. The project already demonstrates strong FOSS alignment with its stack (PostgreSQL, Redis, Nginx, Prometheus, Grafana, Listmonk, Gitea, n8n, NocoDB, MkDocs, etc.).
## Review Process
When evaluating technology choices, follow this systematic approach:
### 1. Identify What's Being Evaluated
- New npm/Node.js dependencies
- Docker services or containers
- External APIs or SaaS platforms
- Development tools
- Infrastructure components
- Frontend libraries or frameworks
### 2. Check License Classification
For each item, determine its license and classify it:
**Approved FOSS Licenses (Green):**
- MIT, ISC, BSD-2-Clause, BSD-3-Clause
- Apache-2.0
- GPL-2.0, GPL-3.0, LGPL-2.1, LGPL-3.0
- MPL-2.0
- Unlicense, CC0-1.0
- PostgreSQL License
- Artistic-2.0
**Caution - Review Needed (Yellow):**
- AGPL-3.0 (fine for self-hosted, but review implications)
- SSPL (Server Side Public License - used by MongoDB, not OSI-approved)
- BSL (Business Source License - used by some HashiCorp tools, MariaDB)
- Elastic License 2.0
- Commons Clause additions
- Any "source-available" but not OSI-approved license
**Not FOSS (Red):**
- Proprietary/commercial licenses
- SaaS-only services with no self-hosted option
- Closed-source binaries
- Services requiring proprietary API keys with no open alternative
### 3. Evaluate the Full Picture
Beyond just the license, consider:
- **Governance**: Is the project community-governed or single-company controlled?
- **Self-hostability**: Can it be fully self-hosted without phoning home?
- **Data sovereignty**: Does data stay on your infrastructure?
- **Vendor lock-in risk**: How hard is it to migrate away?
- **Open-core concerns**: Is the open source version meaningfully usable, or is it crippled to upsell?
- **Transitive dependencies**: Do key dependencies have problematic licenses?
### 4. Provide Clear Recommendations
For each item reviewed, provide:
- **Status**: ✅ Approved, ⚠️ Caution, ❌ Not Recommended
- **License**: The specific license
- **Reasoning**: Why it passes or fails
- **Alternative** (if not recommended): A FOSS alternative that achieves the same goal
## Project Context
The Changemaker Lite project already uses these FOSS-aligned technologies (use as reference for what's acceptable):
| Component | License | Category |
|-----------|---------|----------|
| PostgreSQL | PostgreSQL License | Database |
| Redis | BSD-3-Clause (pre-7.4) / RSALv2+SSPLv1 (7.4+) | Cache/Queue |
| Nginx | BSD-2-Clause | Reverse Proxy |
| Node.js/Express | MIT | API Framework |
| Fastify | MIT | API Framework |
| React | MIT | Frontend |
| Vite | MIT | Build Tool |
| Ant Design | MIT | UI Library |
| Prisma | Apache-2.0 | ORM |
| BullMQ | MIT | Job Queue |
| Prometheus | Apache-2.0 | Monitoring |
| Grafana | AGPL-3.0 | Dashboards |
| Listmonk | AGPL-3.0 | Newsletter |
| NocoDB | AGPL-3.0 | Data Browser |
| Gitea | MIT | Git Hosting |
| n8n | Sustainable Use License (⚠️) | Workflow |
| MkDocs | BSD-2-Clause | Documentation |
| GrapesJS | BSD-3-Clause | Page Builder |
| Leaflet | BSD-2-Clause | Maps |
| Docker | Apache-2.0 | Containers |
**Note on Redis**: Redis changed to dual RSALv2+SSPLv1 in v7.4. The project may be using an older BSD-licensed version or a fork like Valkey (BSD-3-Clause). Flag this if relevant.
**Note on n8n**: n8n uses the Sustainable Use License which is NOT OSI-approved. It's already in the project but should be noted as an exception.
## Output Format
Structure your review as follows:
```
## FOSS Compliance Review
### Items Reviewed
| Item | License | Status | Notes |
|------|---------|--------|-------|
| ... | ... | ✅/⚠️/❌ | ... |
### Detailed Findings
[For each item that is ⚠️ or ❌, provide detailed analysis]
### FOSS Alternatives
[For each ❌ item, suggest FOSS replacements]
### Overall Assessment
[Summary: Is the project maintaining its FOSS commitment?]
```
## Important Guidelines
1. **Be pragmatic, not dogmatic.** A project that is 95% FOSS with a few pragmatic exceptions (like n8n) is still a strong FOSS project. Note exceptions but don't treat them as failures.
2. **Distinguish between dependencies and services.** An MIT-licensed npm package that only works with a proprietary API is effectively proprietary. Evaluate the full dependency chain.
3. **Consider the ecosystem.** Some packages are so standard (e.g., Express, React) that their FOSS status is well-established. Focus your detailed analysis on less common or newer additions.
4. **Check actual files when possible.** Use tools to read `package.json` files, `docker-compose.yml`, and other configuration to identify what's actually in use. Don't rely solely on what the user tells you.
5. **Flag copyleft implications.** If a GPL/AGPL dependency is being used, note any distribution or linking implications, especially for the API server.
6. **Acknowledge trade-offs.** Sometimes there's no good FOSS alternative for a specific need. In those cases, be honest about the trade-off rather than recommending an inferior FOSS option.
7. **When reviewing recently added code**, focus on new `import` statements, new entries in `package.json`, new services in `docker-compose.yml`, and any new external API integrations.
**Update your agent memory** as you discover licensing information about dependencies, services with licensing changes (like Redis's license change), FOSS alternatives that work well for specific use cases, and any exceptions or trade-offs the project has accepted. This builds institutional knowledge across conversations. Write concise notes about what you found.
Examples of what to record:
- License classifications for commonly used packages
- Known licensing changes in popular projects (e.g., Redis, Elasticsearch, Terraform)
- Verified FOSS alternatives that have been evaluated
- Project-specific exceptions and the reasoning behind them
- Transitive dependency issues discovered during reviews
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/bunker-admin/changemaker.lite/.claude/agent-memory/foss-compliance-reviewer/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.

836
CLAUDE.md
View File

@ -6,212 +6,734 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
**Current state:** V2 rebuild in progress on the `v2` branch. See `V2_PLAN.md` for the full roadmap.
**Current state:** V2 rebuild substantially complete on the `v2` branch. Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
**Status Summary:**
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
- ✅ Security Audit Complete (13 findings addressed, Feb 2026)
- ✅ NAR 2025 Server Import (Canadian electoral data)
- ✅ Media Manager Integration (dual API architecture)
- ✅ Email Templates System
- ✅ Data Quality Dashboard
- ✅ Observability Dashboard
- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026)
- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026)
- 🚧 Phase 15 (Testing + Polish) - Next
---
## V2 Architecture (Active Development)
## V2 Architecture
### Stack
- **Single unified Express.js API** — TypeScript, port 4000, Prisma ORM + PostgreSQL 16
- **Dual API Architecture**
- **Express.js API** (TypeScript, port 4000) — Main V2 features with Prisma ORM + PostgreSQL 16
- **Fastify Media API** (TypeScript, port 4100) — Video library with Prisma ORM (shared DB) ✅ **Migrated from Drizzle (Feb 2026)**
- **React Admin GUI** — Vite + Ant Design + Zustand, port 3000
- **Nginx reverse proxy** — subdomain routing (`*.cmlite.org`)
- **NocoDB v2** — read-only data browser on port 8091
- **JWT auth** — access tokens (15min) + refresh tokens (7 days, stored in DB)
- **BullMQ** — async email job queue, **Listmonk** for newsletters
- **Redis** — caching, rate limiting, BullMQ backend
- **Redis** — caching, rate limiting, BullMQ backend, geocoding queue (authenticated)
- **Monitoring Stack** (Docker profile: `monitoring`) — Prometheus, Grafana, Alertmanager, cAdvisor, exporters
### Directory Structure
### Authentication & Security
- **JWT-based auth:** access tokens (15min) + refresh tokens (7 days, stored in DB)
- **Password policy:** 12+ characters, uppercase, lowercase, digit (enforced at schema level)
- **Initial admin:** Configured via `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD` env vars (auto-created during database seeding)
- **Roles:** `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `USER`, `TEMP`
- **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware
- **Security features:**
- 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
- **BullMQ** — async advocacy email job queue with SMTP
- **Listmonk** — newsletter/marketing campaigns (opt-in sync via `LISTMONK_SYNC_ENABLED`)
- **MailHog** — dev email capture (`EMAIL_TEST_MODE=true`)
### Directory Structure (Annotated)
```
changemaker.lite/
├── api/ # Unified Express.js API (TypeScript)
│ ├── prisma/ # Schema, migrations, seed
├── api/ # Dual API servers (Express + Fastify)
│ ├── prisma/
│ │ ├── schema.prisma # 30+ models: User, Campaign, Location, Shift, etc.
│ │ ├── migrations/ # Prisma migration history
│ │ └── seed.ts # Admin user, settings, page blocks
│ ├── drizzle/ # Media tables (Drizzle ORM)
│ ├── Dockerfile.media # Fastify media server container
│ └── src/
│ ├── config/ # env.ts, database.ts, redis.ts
│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac
│ ├── server.ts # Express API entry point (port 4000)
│ ├── media-server.ts # Fastify media API entry point (port 4100)
│ ├── config/
│ │ └── env.ts # Zod-validated environment config (100+ vars)
│ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler
│ ├── modules/
│ │ ├── auth/ # auth.service, auth.routes, auth.schemas
│ │ ├── users/ # users.service, users.routes, users.schemas
│ │ ├── influence/ # campaigns, representatives, responses, postal-codes
│ │ └── map/ # locations, shifts, cuts
│ ├── types/ # express.d.ts (Request augmentation)
│ └── utils/ # logger.ts (Winston), metrics.ts (prom-client)
├── admin/ # React Admin (Vite + Ant Design + Zustand)
│ │ ├── auth/ # JWT login, register, refresh, logout
│ │ ├── users/ # User CRUD + pagination + search
│ │ ├── settings/ # Site settings singleton
│ │ ├── services/ # Service health checks
│ │ ├── influence/
│ │ │ ├── campaigns/ # Campaign CRUD + public routes
│ │ │ ├── representatives/ # Represent API integration + cache
│ │ │ ├── responses/ # Response wall + moderation + upvoting
│ │ │ ├── postal-codes/ # Postal code cache service
│ │ │ ├── campaign-emails/ # Email tracking + stats
│ │ │ └── email-queue/ # BullMQ queue admin
│ │ ├── map/
│ │ │ ├── locations/ # Location CRUD + geocoding + NAR import
│ │ │ ├── geocoding/ # Multi-provider geocoding (6 providers)
│ │ │ ├── cuts/ # Polygon CRUD + spatial queries
│ │ │ ├── shifts/ # Shift CRUD + signups
│ │ │ ├── canvass/ # Canvassing sessions + visits + routes
│ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes)
│ │ │ └── settings/ # Map settings singleton
│ │ ├── pages/
│ │ │ ├── pages-admin.routes.ts # Landing page CRUD
│ │ │ ├── pages-public.routes.ts # Public page renderer
│ │ │ └── blocks.routes.ts # Block library API
│ │ ├── email-templates/ # Email template CRUD + rendering
│ │ ├── media/ # Fastify media API (videos, reactions, jobs)
│ │ ├── listmonk/ # Newsletter sync admin routes
│ │ ├── pangolin/ # Tunnel management (Newt integration)
│ │ ├── docs/ # MkDocs + Code Server health checks
│ │ ├── qr/ # QR code PNG generation (public)
│ │ └── observability/ # Prometheus/Grafana/Alertmanager integration
│ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker
│ ├── types/ # express.d.ts (Request augmentation)
│ └── utils/ # logger (Winston), metrics (prom-client), spatial
├── admin/ # React Admin (Vite + Ant Design + Zustand)
│ └── src/
│ ├── components/ # ProtectedRoute, AppLayout
│ ├── pages/ # LoginPage, DashboardPage, UsersPage
│ ├── stores/ # auth.store.ts (Zustand)
│ ├── lib/ # api.ts (axios instance + interceptors)
│ └── types/ # api.ts (TypeScript interfaces)
├── nginx/ # Reverse proxy config
├── public-web/ # Public landing pages
├── docker-compose.yml # V2 orchestration
├── docker-compose.v1.yml # V1 backup for reference
└── V2_PLAN.md # Full 14-phase roadmap
│ ├── App.tsx # Main router + route definitions
│ ├── components/
│ │ ├── AppLayout.tsx # Admin sidebar layout
│ │ ├── PublicLayout.tsx # Public dark theme layout
│ │ ├── VolunteerLayout.tsx # Volunteer portal layout
│ │ ├── MediaPublicLayout.tsx # Public media gallery layout
│ │ ├── GrapesJSEditor.tsx # Landing page editor wrapper (forwardRef, Ctrl+S)
│ │ ├── map/ # Leaflet map components + controls + drawing modes
│ │ ├── canvass/ # GPS tracking, markers, route, visit recording
│ │ ├── media/ # VideoCard, BulkActions, gallery components
│ │ ├── email-templates/ # Email template components
│ │ └── observability/ # Monitoring components
│ ├── pages/
│ │ ├── auth/ # LoginPage
│ │ ├── DashboardPage.tsx # Admin dashboard
│ │ ├── UsersPage.tsx # User CRUD
│ │ ├── SettingsPage.tsx # Global site settings
│ │ ├── influence/
│ │ │ ├── CampaignsPage.tsx # Campaign management
│ │ │ ├── ResponsesPage.tsx # Response moderation
│ │ │ ├── RepresentativesPage.tsx # Rep cache admin
│ │ │ └── EmailQueuePage.tsx # Queue monitoring
│ │ ├── map/
│ │ │ ├── LocationsPage.tsx # Location CRUD + CSV + geocoding
│ │ │ ├── CutsPage.tsx # Cut table + map drawing editor
│ │ │ ├── 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
│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces)
├── media-manager/ # Legacy media manager (reference)
├── nginx/ # Reverse proxy config (subdomain routing + CSP)
├── configs/ # Prometheus, Grafana, Alertmanager configs
├── scripts/ # backup.sh, legacy Cloudflare scripts
├── docker-compose.yml # V2 orchestration (20+ services)
├── docker-compose.v1.yml # V1 backup (reference)
├── .env.example # All required environment variables
└── V2_PLAN.md # Full 14-phase roadmap
```
### Key Files
| File | Purpose |
|------|---------|
| `api/prisma/schema.prisma` | Full database schema (20+ models) |
| `api/src/server.ts` | API entry point, middleware stack, route wiring |
| `api/src/config/env.ts` | Zod-validated environment config |
| `api/src/modules/auth/` | JWT auth (login, register, refresh, logout) |
| `api/src/modules/users/` | User CRUD with pagination + search |
| `admin/src/App.tsx` | React admin shell with routing |
| `admin/src/stores/auth.store.ts` | Zustand auth state with token persistence |
| `admin/src/lib/api.ts` | Axios instance with 401 refresh interceptor |
| `docker-compose.yml` | V2 service orchestration |
| `.env.example` | All required environment variables |
### Auth Flow
- JWT-based: access tokens (15min) + refresh tokens (7 days, stored in DB)
- Login → verify bcrypt hash → generate token pair → return tokens + user
- Refresh → validate refresh token → rotate (invalidate old, issue new) → return new pair
- Roles: `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `USER`, `TEMP`
- RBAC middleware: `requireRole(...roles)`, `requireNonTemp`
### Nginx Routing
| Subdomain | Target |
|-----------|--------|
| `app.cmlite.org` | Admin React app (port 3000) |
| `api.cmlite.org` | Express API (port 4000) |
| `data.cmlite.org` | NocoDB read-only (port 8091) |
| `docs.cmlite.org` | MkDocs (port 4001) |
| `cmlite.org` | Public landing pages |
---
## V2 Development Commands
## Quick Start Guide
### Initial Setup (First Time)
1. **Clone repository and checkout v2 branch:**
```bash
git clone <repo-url> changemaker.lite
cd changemaker.lite
git checkout v2
```
2. **Create environment file:**
```bash
cp .env.example .env
# Edit .env and set:
# - V2_POSTGRES_PASSWORD (strong password)
# - REDIS_PASSWORD (strong password)
# - JWT_ACCESS_SECRET (openssl rand -hex 32)
# - JWT_REFRESH_SECRET (openssl rand -hex 32)
# - ENCRYPTION_KEY (openssl rand -hex 32, must differ from JWT secrets)
```
3. **Start core services:**
```bash
docker compose up -d v2-postgres redis api admin
```
4. **Run database migrations:**
```bash
docker compose exec api npx prisma migrate deploy
docker compose exec api npx prisma db seed
```
5. **Access the application:**
- Admin GUI: http://localhost:3000 (see INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env)
- API: http://localhost:4000
- **Change default password immediately**
### Development Workflow
**Starting services:**
```bash
# Core services
docker compose up -d v2-postgres redis api admin
# Include monitoring stack
docker compose --profile monitoring up -d
# Include media API
docker compose up -d media-api
```
**Local development (without Docker):**
```bash
# Terminal 1: API
cd api && npm install && npm run dev
# Terminal 2: Admin
cd admin && npm install && npm run dev
# Terminal 3 (optional): Media API
cd api && npm run dev:media
```
### Accessing Services
| Service | URL | Default Credentials |
|---------|-----|---------------------|
| Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env |
| API | http://localhost:4000 | - |
| NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_ADMIN_PASSWORD` in .env |
| MailHog | http://localhost:8025 | - |
| Grafana | http://localhost:3001 | admin / admin |
| Prometheus | http://localhost:9090 | - |
| Listmonk | http://localhost:9001 | See `LISTMONK_WEB_ADMIN_USER`/`PASSWORD` in .env |
### Feature Flags
Enable optional features in `.env`:
```bash
# Media Manager
ENABLE_MEDIA_FEATURES=true
# Listmonk Newsletter Sync
LISTMONK_SYNC_ENABLED=true
# Email Test Mode (sends to MailHog instead of SMTP)
EMAIL_TEST_MODE=true
```
---
## Development Commands
The user likes to use Docker - recereating services as if in production.
### API Development
```bash
cd api && npm run dev # Dev server with tsx watch (auto-reload)
cd api && npx tsc --noEmit # Type-check without emitting
cd api && npx prisma migrate dev # Run/create migrations
cd api && npx prisma studio # Browse database in browser
cd api && npx prisma generate # Regenerate Prisma client
cd api && npm run dev # Express dev server (port 4000)
cd api && npm run dev:media # Fastify media dev server (port 4100)
cd api && npx tsc --noEmit # Type-check
cd api && npx prisma migrate dev # Run/create Prisma migrations
cd api && npx prisma studio # Browse database
cd api && npx drizzle-kit push # Push Drizzle schema changes (media)
```
### Admin GUI Development
### Admin Development
```bash
cd admin && npm run dev # Vite dev server (port 3000)
cd admin && npx tsc --noEmit # Type-check without emitting
cd admin && npm run build # Production build (tsc + vite)
cd admin && npx tsc --noEmit # Type-check
cd admin && npm run build # Production build
```
### Docker (V2 Services)
### Docker Operations
```bash
docker compose up -d v2-postgres redis api # Start API + dependencies
docker compose up -d admin # Start admin GUI
docker compose up -d # Start all v2 services
docker compose logs -f api # Tail API logs
docker compose exec api npx prisma migrate dev # Run migrations in container
docker compose down # Stop all services
# Start services
docker compose up -d v2-postgres redis api admin
docker compose up -d media-api
docker compose --profile monitoring up -d
# View logs
docker compose logs -f api
docker compose logs -f media-api
# Database operations
docker compose exec api npx prisma migrate dev
docker compose exec api npx drizzle-kit push
# Stop services
docker compose down
```
### Type Checking (Both Projects)
### Testing & Backup
```bash
# Media API tests
cd api && ./test-media-api.sh
# Backup (PostgreSQL + Listmonk + uploads)
./scripts/backup.sh
# Type-check all projects
cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
```
---
## Port Reference (V2)
## Core Modules Reference
| Port | Service |
|------|---------|
| 3000 | Admin GUI (Vite dev / React) |
| 3001 | Grafana |
| 3010 | Homepage |
| 3030 | Gitea |
| 4000 | V2 API (Express.js) |
| 4001 | MkDocs (built static) |
| 5432 | Listmonk PostgreSQL |
| 5433 | V2 PostgreSQL (localhost) |
| 5678 | n8n |
| 6379 | Redis |
| 8025 | MailHog Web UI |
| 8080 | cAdvisor |
| 8089 | Mini QR |
| 8091 | NocoDB v2 (read-only) |
| 8888 | Code Server |
| 9001 | Listmonk |
| 9090 | Prometheus |
| 9093 | Alertmanager |
### Auth & Users
**Files:**
- `api/src/modules/auth/` — JWT login, register, refresh, logout
- `api/src/modules/users/` — User CRUD + pagination + search
- `api/src/middleware/auth.ts` — JWT verification + RBAC
- `admin/src/stores/auth.store.ts` — Zustand auth state + token persistence
- `admin/src/lib/api.ts` — Axios with 401 refresh interceptor
**Features:** JWT access/refresh tokens, bcrypt passwords (12+ chars), role-based access control, user enumeration prevention, rate limiting
### Influence Module (Advocacy Campaigns)
**Files:**
- `api/src/modules/influence/campaigns/` — Campaign CRUD + public routes
- `api/src/modules/influence/representatives/` — Represent API client + cache
- `api/src/modules/influence/responses/` — Response wall + moderation + upvoting
- `api/src/services/email-queue.service.ts` — BullMQ queue + worker
- `admin/src/pages/CampaignsPage.tsx` — Campaign management
- `admin/src/pages/public/CampaignPage.tsx` — Public campaign page
**Features:** Postal code → representative lookup, email campaigns, response wall with moderation, BullMQ async email queue
**Routes:**
- Admin: `/app/influence/campaigns`, `/app/influence/responses`, `/app/influence/email-queue`
- Public: `/campaigns`, `/campaigns/:id`, `/responses/:campaignId`
### Map Module (Locations & Canvassing)
**Files:**
- `api/src/modules/map/locations/` — Location CRUD + geocoding + NAR import
- `api/src/modules/map/geocoding/geocoding.service.ts` — Multi-provider geocoding (6 providers)
- `api/src/modules/map/cuts/` — Polygon CRUD + spatial queries
- `api/src/modules/map/shifts/` — Shift CRUD + signups
- `api/src/modules/map/canvass/` — Canvassing sessions + visits + routes
- `api/src/modules/map/tracking/` — GPS tracking sessions (volunteer + admin routes)
- `api/src/utils/spatial.ts` — Point-in-polygon, haversine, bounds, centroids
- `admin/src/pages/LocationsPage.tsx` — Location CRUD + CSV + geocoding
- `admin/src/pages/CutsPage.tsx` — Cut table + map drawing editor
- `admin/src/pages/CanvassDashboardPage.tsx` — Admin canvass overview
- `admin/src/pages/volunteer/VolunteerMapPage.tsx` — Full-screen GPS canvass map
**Features:** Multi-provider geocoding, NAR 2025 import (Canadian electoral data), polygon cuts, volunteer shifts, canvassing system with GPS tracking, walking route algorithm, printable walk sheets
**Routes:**
- Admin: `/app/map/locations`, `/app/map/cuts`, `/app/map/shifts`, `/app/canvass/dashboard`
- Public: `/map`, `/shifts`
- Volunteer: `/volunteer/canvass/:cutId`, `/volunteer/assignments`, `/volunteer/activity`
### Landing Pages & Email Templates
**Files:**
- `api/src/modules/pages/` — Landing page CRUD + block library (3 route files)
- `api/src/modules/email-templates/` — Email template CRUD + rendering
- `admin/src/components/GrapesJSEditor.tsx` — GrapesJS wrapper (forwardRef, Ctrl+S)
- `admin/src/pages/PageEditorPage.tsx` — Full-screen page editor
- `admin/src/pages/EmailTemplateEditorPage.tsx` — Email template editor
**Features:** GrapesJS WYSIWYG editor, page/template CRUD, MkDocs export (Jinja2 Material overrides), public renderer, desktop-only editor warning
**Routes:**
- Admin: `/app/pages`, `/app/pages/:id/edit`, `/app/email-templates`
- Public: `/p/:slug`
### Media Manager (Dual API)
**Files:**
- `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics)
- `api/src/modules/media/services/` — FFprobe, video analytics service
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload
- `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing
- `admin/src/lib/media-api.ts` — Dedicated axios instance for Media API
- `admin/src/pages/media/LibraryPage.tsx` — Video library with quick actions + calendar
- `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard
- `admin/src/pages/media/SharedMediaPage.tsx` — Public gallery admin
- `admin/src/pages/public/MediaGalleryPage.tsx` — Public video gallery
- `admin/src/components/media/` — VideoCard, VideoActions, modals, charts
**Features:**
- **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
- **Scheduled Publishing** (Feb 2026): BullMQ job queue, timezone support (11 zones), calendar view, publish/unpublish automation
- **Analytics** (Feb 2026): Views, watch time, completion rate, traffic sources, registered viewers, GDPR-compliant (IP hashing, 90-day retention)
- **Tracking:** Public endpoints for view/event recording, 10s heartbeat, navigator.sendBeacon for reliability
- **UI Features:** Keyboard shortcuts (E/P/A/S), hover overlays, skeleton loading, error handling, mobile responsive
**Routes:**
- 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)
- Tracking (public): `/track/view`, `/track/event`, `/track/heartbeat`
**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
**Listmonk Newsletter Sync:**
- `api/src/services/listmonk.client.ts` — Listmonk REST API client (native fetch)
- `api/src/services/listmonk-sync.service.ts` — Sync participants/locations → lists
- `admin/src/pages/ListmonkPage.tsx` — Newsletter sync management
- Opt-in sync: `LISTMONK_SYNC_ENABLED=true`
**Pangolin Tunnel Management:**
- `api/src/services/pangolin.client.ts` — Pangolin Integration API client
- `api/src/modules/pangolin/pangolin.routes.ts` — Tunnel management routes (includes `/setup-automated`)
- `admin/src/pages/PangolinPage.tsx` — Setup wizard + status dashboard + automated setup button
- `scripts/pangolin-setup.sh` — CLI wrapper for automated setup
- `configs/pangolin/resources.yml` — Central resource definitions (12 services)
- Newt container integration (Cloudflare alternative)
- **Automated setup:** One-command deployment (creates site, updates .env, restarts Newt)
- **Continuous sync:** Hourly resource sync via nginx cron job
**MkDocs + Code Server:**
- `api/src/modules/docs/docs.routes.ts` — Health checks + export routes
- `admin/src/pages/DocsPage.tsx` — MkDocs export management
- `admin/src/pages/CodeEditorPage.tsx` — Code Server management
- Embedded iframes in admin (CSP `frame-ancestors` for embedding)
**Mini QR Service:**
- `api/src/modules/qr/qr.routes.ts` — QR code PNG generation (public, no auth)
- `admin/src/pages/MiniQRPage.tsx` — Mini QR iframe
- Used by walk sheets + cut exports
### Observability & Monitoring
**Files:**
- `api/src/modules/observability/observability.routes.ts` — Prometheus/Grafana/Alertmanager integration
- `api/src/utils/metrics.ts` — 12 custom `cm_*` Prometheus metrics
- `admin/src/pages/ObservabilityPage.tsx` — Monitoring dashboard (3 tabs)
- `admin/src/pages/DataQualityDashboardPage.tsx` — Geocoding quality metrics
- `configs/prometheus/` — Scrape targets, alert rules
- `configs/grafana/` — 3 pre-configured dashboards
**Features:** 12 custom `cm_*` metrics (API uptime, queue size, sessions, etc.), HTTP request metrics, external service health gauges, 3 Grafana dashboards, alert rules, auto-start banner
**Routes:**
- Admin: `/app/observability`, `/app/map/data-quality`
- Direct: `localhost:9090` (Prometheus), `localhost:3001` (Grafana)
---
## Port Reference
| Port | Service | Notes |
|------|---------|-------|
| **Core Services** | | |
| 3000 | Admin GUI | Vite dev / React production |
| 4000 | Express API | Main V2 API (Prisma) |
| 4100 | Fastify Media API | Video library (Drizzle) |
| 5433 | V2 PostgreSQL | Localhost (container: 5432) |
| 6379 | Redis | Cache, rate limit, BullMQ |
| **Supporting Services** | | |
| 3001 | Grafana | Metrics visualization |
| 3010 | Homepage | Service dashboard |
| 3030 | Gitea | Git hosting |
| 4001 | MkDocs Site | Served docs |
| 4003 | MkDocs Dev | Live preview |
| 5432 | Listmonk PostgreSQL | Listmonk DB |
| 5678 | n8n | Workflow automation |
| 8025 | MailHog | Email capture (dev) |
| 8089 | Mini QR | QR generator |
| 8091 | NocoDB | Data browser |
| 8885 | Mini QR Proxy | Iframe-friendly |
| 8888 | Code Server | Web IDE |
| 9001 | Listmonk | Newsletter platform |
| **Monitoring** (profile: `monitoring`) | | |
| 8080 | cAdvisor | Container metrics |
| 8889 | Gotify | Notifications |
| 9090 | Prometheus | Metrics collection |
| 9093 | Alertmanager | Alert routing |
| 9100 | Node Exporter | Host metrics |
| 9121 | Redis Exporter | Redis metrics |
---
## Nginx Routing
| Subdomain | Target | Purpose |
|-----------|--------|---------|
| `app.cmlite.org` | Admin (3000) | **All application routes** (admin + public pages, campaigns, map, shifts, media) |
| `api.cmlite.org` | Express (4000) | Main API |
| `media.cmlite.org` | Fastify (4100) | Media API |
| `db.cmlite.org` | NocoDB (8091) | Data browser |
| `docs.cmlite.org` | MkDocs (4003) | Docs site |
| `code.cmlite.org` | Code Server (8888) | Web IDE |
| `n8n.cmlite.org` | n8n (5678) | Workflow automation |
| `git.cmlite.org` | Gitea (3030) | Git hosting |
| `home.cmlite.org` | Homepage (3010) | Dashboard |
| `grafana.cmlite.org` | Grafana (3001) | Metrics viz |
| `listmonk.cmlite.org` | Listmonk (9001) | Newsletters |
| `qr.cmlite.org` | Mini QR (8089) | QR generator |
| `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.
---
## Common Patterns
**Note:** See `MEMORY.md` for comprehensive development patterns, gotchas, and lessons learned. Below are V2-specific patterns only.
### API Router Structure
- Service layer (`*.service.ts`) — business logic, database queries
- Routes (`*.routes.ts`) — Express router, middleware, validation
- Schemas (`*.schemas.ts`) — Zod validation schemas
- Split admin/public routes when needed (e.g., `campaigns.routes.ts` + `campaigns-public.routes.ts`)
### Authentication Middleware
- `authenticate` — requires any logged-in user
- `requireRole(...roles)` — requires specific role(s)
- `requireNonTemp` — blocks TEMP users
- Login redirects: ADMIN_ROLES → `/app`, USER/TEMP → `/volunteer`
### Frontend Architecture
- Admin pages: `admin/src/pages/` (AppLayout)
- Public pages: `admin/src/pages/public/` (PublicLayout, dark theme)
- Volunteer pages: `admin/src/pages/volunteer/` (VolunteerLayout)
- Zustand stores: `auth.store.ts`, `canvass.store.ts`
- API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts`
### Database ORMs
- **Prisma** (main API): Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays
- **Drizzle** (media API): Separate schema file, push with `npx drizzle-kit push`, no migrations generated
### V2-Specific Gotchas
- 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
- MkDocs port 4003 (was 4000, conflicted with API)
- Media upload: requires separate RW volume mount for inbox directory (`:rw` on `/media/local/inbox`), library remains read-only
- FFmpeg/FFprobe: installed in media-api container (Alpine `apk add --no-cache ffmpeg`), used for metadata extraction
---
## Security & Configuration
### Security Audit
Comprehensive security audit completed 2025-02-11, addressing 13 findings. See `SECURITY_AUDIT_2025-02-11.md` for full report.
**Key improvements:**
- Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced)
- Rate limits on auth endpoints (10/min per IP)
- Refresh token rotation (atomic transaction)
- User enumeration prevention (401 not 404)
- Redis authentication required
- XSS/injection prevention (HTML escaping)
- Path traversal protection
- Encryption key for DB secrets (`ENCRYPTION_KEY` required in production)
- Nginx security headers (HSTS, Permissions-Policy, CSP)
### Required Environment Variables
See `.env.example` for all 100+ variables. Critical ones:
- `V2_POSTGRES_PASSWORD`, `REDIS_PASSWORD`
- `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`
- `ENCRYPTION_KEY` (must differ from JWT secrets)
- `LISTMONK_SYNC_ENABLED` (opt-in newsletter sync)
- `EMAIL_TEST_MODE` (MailHog vs SMTP)
- `ENABLE_MEDIA_FEATURES` (media manager)
### Production Deployment
- **Tunneling:** Pangolin with Newt container (Cloudflare alternative)
- **SSL/TLS:** Handled by tunnel provider (Pangolin/Cloudflare)
- **Docker Networking:** All containers share `changemaker-lite` bridge network, reference by container name
- **Monitoring:** Enable with `docker compose --profile monitoring up -d`
- **Backups:** Run `./scripts/backup.sh` (PostgreSQL + Listmonk + uploads, optional S3 upload)
#### Production CORS Configuration
When deploying to a production domain via Pangolin tunnel, you MUST update the `.env` file to include the production domain in `CORS_ORIGINS`:
```bash
# Example for betteredmonton.org
CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost
# Also set production mode
NODE_ENV=production
```
Without this, API requests from the production domain will fail CORS validation. After updating `.env`, restart the API container:
```bash
docker compose restart api
```
---
## Troubleshooting
### Production 403/302 Errors - Pangolin Resources
**Symptom:** All API endpoints return 302 redirects to Pangolin authentication page, or 403 Forbidden errors.
**Root Cause:** Pangolin tunnel resources are configured with authentication enabled (default behavior).
**Fix:** Log in to your Pangolin dashboard and edit each resource:
1. Navigate to **Resources** → **Public**
2. For each resource (app, api, media, docs, etc.), click **Edit**
3. Change **Authentication** setting to **"Not Protected"** (or "Public Access"/"No Authentication")
4. Save changes
**Critical resources to fix first:**
- `api.betteredmonton.org` - Main API (all endpoints fail without this)
- `app.betteredmonton.org` - Admin GUI + public pages
- `media.betteredmonton.org` - Media API
**Verification:**
```bash
# Should return JSON, NOT a 302 redirect
curl https://api.betteredmonton.org/api/health
```
**See Also:** `PRODUCTION_403_FIX.md` for detailed step-by-step instructions.
### CORS Errors in Production
**Symptom:** Browser console shows CORS errors when accessing production domain.
**Fix:** Add production domain to `CORS_ORIGINS` in `.env` file (see Production CORS Configuration above).
### API Works Locally But Not Via Tunnel
Check in order:
1. **Newt container running:** `docker compose ps newt`
2. **Newt connected:** `docker compose logs newt --tail 50` (should show successful connection)
3. **Environment variables set:** `PANGOLIN_SITE_ID`, `PANGOLIN_NEWT_ID`, `PANGOLIN_NEWT_SECRET` in `.env`
4. **Pangolin resources configured:** All resources set to "Not Protected"
5. **Nginx running:** `docker compose ps nginx`
### Database Connection Failures
**Symptom:** API logs show database connection errors.
**Fix:**
1. Check PostgreSQL container: `docker compose ps v2-postgres`
2. Verify `DATABASE_URL` in `.env` matches container name and port
3. Check PostgreSQL logs: `docker compose logs v2-postgres --tail 50`
4. Test connection: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`
### Redis Connection Failures
**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 code is preserved in `influence/` and `map/` directories and backed up in `docker-compose.v1.yml`.
### V1 Architecture
Two independent Express.js apps using NocoDB REST API as data layer:
- **Influence** (`influence/app/`, port 3333) — Postal code → representative lookup, email campaigns, response tracking
- **Map** (`map/app/`, port 3000) — Leaflet.js map, volunteer shifts, walk sheets, QR codes
Both apps use: session-based auth (Redis-backed), bcryptjs passwords, Bull job queues, NocoDB REST API (not direct DB).
### V1 Express App Structure
```
app/
├── server.js # Entry point, middleware stack
├── config/ # Environment-based configuration
├── routes/ # Express route definitions
├── controllers/ # Business logic
├── services/ # External integrations (nocodb.js, email.js, listmonk.js)
├── middleware/ # auth.js, csrf.js, rateLimiter.js
├── utils/ # logger.js, metrics.js, validators.js
├── public/ # Static assets
└── templates/ # Server-rendered HTML templates
```
### V1 Commands
```bash
cd influence && cp example.env .env
./scripts/build-nocodb.sh # Initialize NocoDB tables
docker compose up -d
docker compose exec influence-app npm test # Run Jest tests
cd map && cp example.env .env
./build-nocodb.sh # Initialize NocoDB tables
docker compose up -d
```
### V1 Build Scripts
- `config.sh` — Interactive wizard that generates `.env` with secure random passwords
- `start-production.sh` — Installs cloudflared, creates tunnel, configures DNS
- `map/build-nocodb.sh` and `influence/scripts/build-nocodb.sh` — Create NocoDB schema + seed data
- `reset-site.sh` — Resets MkDocs to baseline
### V1 Documentation
- `influence/README.MD` — Features, config, campaign management, email testing
- `influence/files-explainer.md` — File-by-file code documentation
V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two independent Express apps using NocoDB REST API. See individual README files for V1 documentation:
- `influence/README.MD` — Features, config, campaign management
- `map/README.md` — Features, config, setup instructions
- `map/files-explainer.md` — File-by-file code documentation
- Both use session-based auth, bcryptjs passwords, Bull job queues
---
## Key Configuration Files
| File | Purpose |
|------|---------|
| `docker-compose.yml` | V2 orchestration (all services) |
| `docker-compose.v1.yml` | V1 backup |
| `.env` / `.env.example` | Environment variables (never committed) |
| `api/prisma/schema.prisma` | Database schema |
| `nginx/` | Reverse proxy configuration |
| `configs/prometheus/prometheus.yml` | Monitoring scrape targets |
| `configs/cloudflare/tunnel-config.yml` | Production ingress routing |
### Infrastructure
- `docker-compose.yml` — V2 orchestration (20+ services, monitoring profile)
- `.env` / `.env.example` — Environment variables (100+ vars)
## Networking
### Database
- `api/prisma/schema.prisma` — Main schema (30+ Prisma models)
- `api/drizzle.config.ts` — Drizzle config for media tables
- `api/prisma/seed.ts` — Database seeding
All containers share the `changemaker-lite` bridge network and reference each other by container name. Production uses Cloudflare tunnel with ingress rules mapping `*.cmlite.org` subdomains.
### Nginx
- `nginx/nginx.conf` — Global config + security headers
- `nginx/conf.d/default.conf` — Subdomain routing (12+ subdomains)
- `nginx/conf.d/api.conf` — API reverse proxy (Express + Fastify)
- `nginx/conf.d/services.conf` — Service proxies
### Monitoring
- `configs/prometheus/prometheus.yml` — Scrape targets + global config
- `configs/prometheus/alerts.yml` — Alert rules (12 rules)
- `configs/grafana/` — 3 pre-configured dashboards
- `configs/alertmanager/alertmanager.yml` — Alert routing
### Documentation
- `CLAUDE.md` — Project-wide instructions (this file)
- `V2_PLAN.md` — Full 14-phase roadmap
- `SECURITY_AUDIT_2025-02-11.md` — Security audit report
- `MEMORY.md` — Development patterns and gotchas

BIN
NARguide.pdf Normal file

Binary file not shown.

View File

@ -0,0 +1,181 @@
# Pangolin Tunnel Management + Nginx Multi-Domain Support - Implementation Summary
**Date:** 2026-02-15
**Status:** ✅ COMPLETE
## Changes Implemented
### 1. Pangolin Resources Configuration (`configs/pangolin/resources.yml`)
**Added 2 new resources:**
- Excalidraw (subdomain: `draw`, container: `excalidraw-changemaker`, port: 80)
- MailHog (subdomain: `mail`, container: `mailhog-changemaker`, port: 8025)
**Fixed Mini QR resource:**
- Container name: `miniqr-changemaker``mini-qr`
- Port: `8089``8080`
**Total resources:** 14 (up from 12)
### 2. Nginx Template Updates (`nginx/conf.d/services.conf.template`)
Updated 6 server blocks to support multi-domain routing:
| Service | Old server_name | New server_name |
|---------|----------------|-----------------|
| **Gitea** | `git.${DOMAIN}` | `git.cmlite.org git.betteredmonton.org` |
| **n8n** | `n8n.${DOMAIN}` | `n8n.cmlite.org n8n.betteredmonton.org` |
| **Code Server** | `code.${DOMAIN}` | `code.cmlite.org code.betteredmonton.org` |
| **MailHog** | `mail.${DOMAIN}` | `mail.cmlite.org mail.betteredmonton.org` |
| **Mini QR** | `qr.${DOMAIN}` | `qr.cmlite.org qr.betteredmonton.org` |
| **Excalidraw** | `draw.${DOMAIN}` | `draw.cmlite.org draw.betteredmonton.org` |
**CSP Headers Updated:**
All 6 services now allow iframe embedding from both admin domains:
```nginx
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
```
### 3. Nginx Container Rebuild
- Rebuilt nginx image to pick up updated template files
- Restarted nginx container with new configuration
- Verified nginx syntax: ✅ OK
## Root Cause Analysis
### Issue 1: Missing Resources
- Excalidraw and MailHog existed in infrastructure but weren't listed in `resources.yml`
- Pangolin page reads from this YAML to display resource status
### Issue 2: X-Frame-Options Error
- Nginx template used `${DOMAIN}` variable (only substitutes ONE domain from `.env`)
- When accessing via alternate domain, nginx didn't match any server block
- Request fell back to default server with `X-Frame-Options: SAMEORIGIN`
- This blocked iframe embedding
### Issue 3: Wrong Container Name
- resources.yml referenced `miniqr-changemaker` (doesn't exist)
- Correct name from docker-compose.yml: `mini-qr`
## Solution Applied
**Pattern:** Hardcoded dual-domain `server_name` directives (same pattern as NocoDB, Listmonk, Grafana, etc.)
**Why not template variables?**
- `${DOMAIN}` substitution only supports ONE domain value
- We need BOTH domains for multi-domain production deployment
- Hardcoding is explicit and predictable
## Verification Results
### ✅ Nginx Configuration
```bash
$ docker compose exec nginx nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```
### ✅ Multi-Domain Server Blocks
All 6 services verified with both domains in `server_name`:
- Mini QR: `qr.cmlite.org qr.betteredmonton.org`
- Gitea: `git.cmlite.org git.betteredmonton.org`
- n8n: `n8n.cmlite.org n8n.betteredmonton.org`
- Code Server: `code.cmlite.org code.betteredmonton.org`
- MailHog: `mail.cmlite.org mail.betteredmonton.org`
- Excalidraw: `draw.cmlite.org draw.betteredmonton.org`
### ✅ Resources Count
```bash
$ grep -c "subdomain:" configs/pangolin/resources.yml
14
```
## Testing Instructions
### 1. Test Pangolin Resources Page
```bash
# Access Pangolin page
# Navigate to http://localhost:3000/app/pangolin
# or https://app.betteredmonton.org/app/pangolin
# Verify:
# ✅ Resources table shows 14 services
# ✅ "Excalidraw" appears in list
# ✅ "MailHog" appears in list
# ✅ Mini QR container shows as "mini-qr"
```
### 2. Test Multi-Domain Access (Production)
```bash
# Test all 6 services respond on both domains
for service in qr git n8n code mail draw; do
for domain in cmlite.org betteredmonton.org; do
echo "Testing ${service}.${domain}..."
curl -I https://${service}.${domain} 2>&1 | grep HTTP | head -1
done
done
# Expected: All return HTTP/1.1 200 OK (or 302 for auth-protected)
```
### 3. Test Iframe Embedding
```bash
# Test Mini QR iframe from both domains
# 1. Navigate to https://app.betteredmonton.org/app/services/qr
# 2. Check browser console for errors
# 3. Verify Mini QR loads without "Refused to display" error
# 4. Repeat from https://app.cmlite.org/app/services/qr
# Test other services similarly
```
### 4. Verify CSP Headers
```bash
# Check CSP headers include both admin domains
curl -I https://qr.betteredmonton.org 2>&1 | grep Content-Security-Policy
# Expected:
# Content-Security-Policy: frame-ancestors 'self' app.cmlite.org app.betteredmonton.org
```
## Success Criteria
✅ Pangolin resources list shows 14 services (up from 12)
✅ Excalidraw and MailHog appear in resources table
✅ Mini QR container name corrected to `mini-qr`
✅ Mini QR iframe loads without X-Frame-Options errors
✅ All 6 iframe-embedded services work on both domains
✅ CSP headers allow embedding from both app domains
✅ Nginx config regenerates successfully without syntax errors
## Files Modified
1. **configs/pangolin/resources.yml** - Added 2 resources, fixed 1 container name/port
2. **nginx/conf.d/services.conf.template** - Updated 6 server blocks for dual-domain support
## Next Steps
1. **Pangolin Setup** (Manual)
- Login to Pangolin dashboard
- Create resources for new services (Excalidraw, MailHog)
- Set authentication to "Not Protected" for public access
- Verify tunnel connectivity
2. **Production Deployment**
- Rebuild nginx image: `docker compose build nginx`
- Restart nginx: `docker compose up -d nginx`
- Test all services on both domains
- Verify iframe embedding works
3. **Documentation Updates**
- Update CLAUDE.md with new resource count
- Update MEMORY.md with nginx rebuild requirement for template changes
- Document dual-domain pattern in troubleshooting guide
## Notes
- **Important:** Template changes require nginx image rebuild (`docker compose build nginx`)
- **Pattern:** Hardcoded domains prevent future confusion vs. variable substitution
- **Scope:** Frontend already uses `buildServiceUrl()` correctly (no code changes needed)
- **Manual Setup:** Pangolin resources.yml is documentation/reference only (manual setup per MEMORY.md)

200
PRODUCTION_403_FIX.md Normal file
View File

@ -0,0 +1,200 @@
# Production 403 Errors - Root Cause & Fix
## Diagnosis Summary
**Issue:** All API endpoints returning 302 redirects to Pangolin authentication page
**Root Cause:** Pangolin tunnel resources configured with authentication enabled (should be "Not Protected")
**Status:** CORS configuration ✅ FIXED | Pangolin resources ❌ NEEDS MANUAL FIX
---
## What Was Fixed
### ✅ CORS Configuration (COMPLETED)
**File:** `/home/bunker-admin/changemaker.lite/.env`
**Changes applied:**
```bash
# Changed from development to production
NODE_ENV=production
# Added production domain to CORS whitelist
CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost
```
**API container restarted:** ✅ Done
---
## What Still Needs Manual Fix
### ❌ Pangolin Resource Authentication (REQUIRES MANUAL ACTION)
**Problem:** Resources are configured with authentication, causing 302 redirects to auth page.
**Evidence:**
```bash
$ curl -I https://api.betteredmonton.org/api/health
HTTP/2 302
location: https://pangolin.bnkserve.org/auth/resource/68488f80-b055-41ea-bc1b-0ab905fb8a53?redirect=...
```
**Fix Required:** Change authentication setting for ALL Pangolin resources to "Not Protected"
---
## Step-by-Step Fix Instructions
### 1. Log in to Pangolin Dashboard
URL: https://api.bnkserve.org (remove `/v1` from API URL)
### 2. Navigate to Resources
Dashboard → **Resources** → **Public**
### 3. Edit Each Resource
For EACH of these critical resources:
- ✅ **app.betteredmonton.org** (Admin GUI + Public Pages)
- ✅ **api.betteredmonton.org** (Main API)
- ✅ **media.betteredmonton.org** (Media API)
- db.betteredmonton.org (NocoDB)
- docs.betteredmonton.org (MkDocs)
- code.betteredmonton.org (Code Server)
- git.betteredmonton.org (Gitea)
- n8n.betteredmonton.org (n8n)
- grafana.betteredmonton.org (Grafana)
- listmonk.betteredmonton.org (Listmonk)
- qr.betteredmonton.org (Mini QR)
- home.betteredmonton.org (Homepage)
**Most critical (fix these first):**
1. **api.betteredmonton.org** - Main API (all endpoints fail without this)
2. **app.betteredmonton.org** - Admin GUI (login page won't work)
3. **media.betteredmonton.org** - Media API (video library features)
### 4. Change Authentication Setting
For each resource:
1. Click **Edit** (pencil icon)
2. Find **Authentication** or **Access Policy** section
3. Change from **"Protected"** or **"Authenticated"** to:
- **"Not Protected"** OR
- **"Public Access"** OR
- **"No Authentication"**
(exact wording depends on Pangolin UI version)
4. Click **Save**
### 5. Verify Fix
After changing authentication settings, test each endpoint:
**Test API:**
```bash
curl https://api.betteredmonton.org/api/health
# Expected: {"status":"healthy","checks":{"database":"ok","redis":"ok"}}
# NOT: 302 redirect
```
**Test Public Campaigns:**
```bash
curl https://api.betteredmonton.org/api/campaigns/public
# Expected: JSON array of campaigns
# NOT: 302 redirect
```
**Test Admin GUI:**
Visit https://app.betteredmonton.org in browser
- Should see login page
- NO redirect to Pangolin auth page
---
## Why This Happened
1. **Pangolin resources default to "Protected"** - requires manual change to "Not Protected"
2. **Manual setup process** - automated setup was removed, so resources must be configured manually
3. **No API enforcement** - Pangolin API doesn't enforce "Not Protected" when creating resources programmatically
---
## Resource Configuration Reference
**Correct settings for ALL resources:**
- **Protocol:** HTTPS (SSL enabled)
- **Target:** nginx:80 (all services route through nginx)
- **Authentication:** **Not Protected** ← THIS IS CRITICAL
- **SSL/TLS:** Enabled
---
## Troubleshooting
### Still seeing 302 redirects after changing settings?
1. **Clear browser cache** - old redirects may be cached
2. **Try incognito/private window**
3. **Wait 30-60 seconds** - Pangolin may need time to update routing
4. **Check resource status** - ensure resource shows as "Active" in Pangolin dashboard
5. **Verify target** - should point to `nginx:80` (not individual service ports)
### API works locally but not via tunnel?
Confirm:
- [ ] Newt container is running: `docker compose ps newt`
- [ ] Newt logs show connection: `docker compose logs newt --tail 50`
- [ ] PANGOLIN_SITE_ID, PANGOLIN_NEWT_ID, PANGOLIN_NEWT_SECRET are set in .env
- [ ] Nginx is running: `docker compose ps nginx`
### Health endpoint works but other endpoints fail?
Check in this order:
1. Test public endpoints (no auth): `/api/campaigns/public`, `/api/shifts/public`
2. Test protected endpoints with valid JWT: `/api/campaigns`, `/api/users`
3. Check auth store in browser DevTools: localStorage should have `auth-storage` with tokens
4. Verify JWT secrets haven't changed (would invalidate existing tokens)
---
## Post-Fix Verification Checklist
After changing Pangolin resource authentication to "Not Protected":
- [ ] Health endpoint returns JSON (not 302): `curl https://api.betteredmonton.org/api/health`
- [ ] Public campaigns endpoint works: `curl https://api.betteredmonton.org/api/campaigns/public`
- [ ] Admin GUI loads: visit https://app.betteredmonton.org
- [ ] Login works: can authenticate with admin credentials
- [ ] Campaign management page loads data (no console errors)
- [ ] Representative lookup functions
- [ ] Public campaign page accessible: https://app.betteredmonton.org/campaigns
- [ ] Map page loads: https://app.betteredmonton.org/map
- [ ] Shifts page works: https://app.betteredmonton.org/shifts
---
## Summary
**What was done:**
1. ✅ Updated `.env` with production CORS origins
2. ✅ Set NODE_ENV to production
3. ✅ Restarted API container
4. ✅ Verified API works locally
**What you need to do:**
1. ❌ Log in to Pangolin dashboard at https://api.bnkserve.org
2. ❌ Edit each resource and set Authentication to "Not Protected"
3. ❌ Verify endpoints no longer return 302 redirects
4. ❌ Test application is fully functional
**Time estimate:** 5-10 minutes to update all 12 resources
---
## Contact
If you encounter issues after following these steps:
- Check Pangolin documentation: https://pangolin.bnkserve.org/docs (if available)
- Review Newt container logs: `docker compose logs newt`
- Verify nginx routing: `docker compose logs nginx | grep betteredmonton.org`

BIN
RNAguide.pdf Normal file

Binary file not shown.

View File

@ -364,13 +364,22 @@ changemaker.lite/
---
### Phase 15: Testing + Polish [ ]
### Phase 15: Testing + Polish [IN PROGRESS]
**Media Admin Features (Feb 2026) [COMPLETE]:**
- [x] Quick Action Buttons — Edit, preview, analytics, schedule, duplicate, preview links (24h JWT), reset analytics
- [x] Scheduled Publishing — BullMQ job queue, timezone support (11 zones), calendar view, publish/unpublish automation
- [x] Video Analytics — Views, watch time, completion rate, traffic sources, registered viewers tracking
- [x] Privacy & Compliance — IP hashing (SHA-256), user agent truncation, 90-day retention, GDPR-compliant
- [x] UI/UX Polish — Keyboard shortcuts (E/P/A/S), hover overlays, skeleton loading, error handling, mobile responsive
- [x] Documentation — MEDIA_ADMIN_FEATURES.md, VIDEO_ANALYTICS_GUIDE.md, api/src/modules/media/README.md
**Remaining Testing + Polish:**
- [ ] API integration tests (Jest/Vitest)
- [ ] Admin E2E tests
- [ ] Performance optimization
- [ ] Security audit
- [ ] Documentation updates
- [ ] Security audit (auth-security-reviewer for media features)
- [ ] UI design review (ui-design-critic for media components)
### PHASE 1: Extras
- [ ] Add apache answers

419
admin/package-lock.json generated
View File

@ -11,9 +11,11 @@
"@ant-design/icons": "^5.6.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@monaco-editor/react": "^4.7.0",
"@types/dompurify": "^3.2.0",
"antd": "^5.23.0",
"axios": "^1.7.9",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"grapesjs": "^0.22.14",
"grapesjs-blocks-basic": "^1.0.2",
"grapesjs-component-countdown": "^1.0.2",
@ -26,11 +28,14 @@
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.0.0",
"react-router-dom": "^7.1.1",
"recharts": "^3.7.0",
"yaml": "^2.8.2",
"zustand": "^5.0.3"
},
@ -901,7 +906,6 @@
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
@ -1059,6 +1063,40 @@
"react-dom": "^19.0.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1390,6 +1428,16 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1440,6 +1488,69 @@
"@types/underscore": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/dompurify": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"dependencies": {
"dompurify": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1502,14 +1613,18 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true,
"peer": true
"optional": true
},
"node_modules/@types/underscore": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz",
"integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA=="
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -1706,6 +1821,14 @@
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "5.63.0",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz",
@ -1763,6 +1886,116 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
@ -1785,6 +2018,11 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1794,10 +2032,9 @@
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"peer": true,
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@ -1862,6 +2099,11 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@ -1912,6 +2154,11 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2162,6 +2409,23 @@
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
"integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA=="
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -2200,12 +2464,29 @@
"node": ">=6"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"peerDependencies": {
"leaflet": "^1.3.1"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -2264,6 +2545,15 @@
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2960,6 +3250,43 @@
"react-dom": "^19.0.0"
}
},
"node_modules/react-leaflet-cluster": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-4.0.0.tgz",
"integrity": "sha512-Lu75+KOu2ruGyAx8LoCQvlHuw+3CLLJQGEoSk01ymsDN/YnCiRV6ChkpsvaruVyYBPzUHwiskFw4Jo7WHj5qNw==",
"dependencies": {
"leaflet.markercluster": "^1.5.3"
},
"peerDependencies": {
"@react-leaflet/core": "^3.0.0",
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -3005,6 +3332,50 @@
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@ -3113,6 +3484,11 @@
"node": ">=12.22"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3182,6 +3558,35 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",

View File

@ -12,9 +12,11 @@
"@ant-design/icons": "^5.6.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@monaco-editor/react": "^4.7.0",
"@types/dompurify": "^3.2.0",
"antd": "^5.23.0",
"axios": "^1.7.9",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"grapesjs": "^0.22.14",
"grapesjs-blocks-basic": "^1.0.2",
"grapesjs-component-countdown": "^1.0.2",
@ -27,11 +29,14 @@
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.0.0",
"react-router-dom": "^7.1.1",
"recharts": "^3.7.0",
"yaml": "^2.8.2",
"zustand": "^5.0.3"
},

View File

@ -8,14 +8,17 @@ import FeatureGate from '@/components/FeatureGate';
import AppLayout from '@/components/AppLayout';
import PublicLayout from '@/components/PublicLayout';
import VolunteerLayout from '@/components/VolunteerLayout';
import MediaPublicLayout from '@/components/MediaPublicLayout';
import LoginPage from '@/pages/LoginPage';
import DashboardPage from '@/pages/DashboardPage';
import UsersPage from '@/pages/UsersPage';
import CampaignsPage from '@/pages/CampaignsPage';
import RepresentativesPage from '@/pages/RepresentativesPage';
import EmailQueuePage from '@/pages/EmailQueuePage';
import EmailTemplatesPage from '@/pages/EmailTemplatesPage';
import ResponsesPage from '@/pages/ResponsesPage';
import LocationsPage from '@/pages/LocationsPage';
import DataQualityDashboardPage from '@/pages/DataQualityDashboardPage';
import CutsPage from '@/pages/CutsPage';
import ShiftsPage from '@/pages/ShiftsPage';
import MapSettingsPage from '@/pages/MapSettingsPage';
@ -23,7 +26,6 @@ import CutExportPage from '@/pages/CutExportPage';
import CanvassDashboardPage from '@/pages/CanvassDashboardPage';
import ListmonkPage from '@/pages/ListmonkPage';
import LandingPagesPage from '@/pages/LandingPagesPage';
import PageEditorPage from '@/pages/PageEditorPage';
import DocsPage from '@/pages/DocsPage';
import MkDocsSettingsPage from '@/pages/MkDocsSettingsPage';
import CodeEditorPage from '@/pages/CodeEditorPage';
@ -31,24 +33,45 @@ import NocoDBPage from '@/pages/NocoDBPage';
import N8nPage from '@/pages/N8nPage';
import GiteaPage from '@/pages/GiteaPage';
import MailHogPage from '@/pages/MailHogPage';
import MiniQRPage from '@/pages/MiniQRPage';
import ExcalidrawPage from '@/pages/ExcalidrawPage';
import SettingsPage from '@/pages/SettingsPage';
import PangolinPage from '@/pages/PangolinPage';
import ObservabilityPage from '@/pages/ObservabilityPage';
import LibraryPage from '@/pages/media/LibraryPage';
import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage';
import MediaJobsPage from '@/pages/media/MediaJobsPage';
import CommentModerationPage from '@/pages/media/CommentModerationPage';
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
import PublicLandingPage from '@/pages/public/LandingPage';
import CampaignsListPage from '@/pages/public/CampaignsListPage';
import CampaignPage from '@/pages/public/CampaignPage';
import CreateCampaignPage from '@/pages/public/CreateCampaignPage';
import MyCampaignsPage from '@/pages/public/MyCampaignsPage';
import ResponseWallPage from '@/pages/public/ResponseWallPage';
import MapPage from '@/pages/public/MapPage';
import PublicShiftsPage from '@/pages/public/ShiftsPage';
import MediaGalleryPage from '@/pages/public/MediaGalleryPage';
import ShortsPage from '@/pages/public/ShortsPage';
import MediaViewerPage from '@/pages/public/MediaViewerPage';
import PlaylistBrowsePage from '@/pages/public/PlaylistBrowsePage';
import PlaylistViewerPage from '@/pages/public/PlaylistViewerPage';
import PlaylistManagementPage from '@/pages/media/PlaylistManagementPage';
import MyStatsPage from '@/pages/public/MyStatsPage';
import MySettingsPage from '@/pages/public/MySettingsPage';
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
import { ADMIN_ROLES } from '@/types/api';
import { isAdmin } from '@/utils/roles';
import VerifyEmailPage from '@/pages/VerifyEmailPage';
import ResetPasswordPage from '@/pages/ResetPasswordPage';
function RoleAwareRedirect() {
const { user, isAuthenticated } = useAuthStore();
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (user && ADMIN_ROLES.includes(user.role)) return <Navigate to="/app" replace />;
if (user && isAdmin(user)) return <Navigate to="/app" replace />;
return <Navigate to="/volunteer" replace />;
}
@ -116,6 +139,24 @@ export default function App() {
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route index element={<CampaignsListPage />} />
</Route>
<Route path="/campaigns/create" element={
<FeatureGate feature="enableInfluence">
<ProtectedRoute>
<PublicLayout />
</ProtectedRoute>
</FeatureGate>
}>
<Route index element={<CreateCampaignPage />} />
</Route>
<Route path="/campaigns/mine" element={
<FeatureGate feature="enableInfluence">
<ProtectedRoute>
<PublicLayout />
</ProtectedRoute>
</FeatureGate>
}>
<Route index element={<MyCampaignsPage />} />
</Route>
<Route path="/campaign" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route path=":slug" element={<CampaignPage />} />
<Route path=":slug/responses" element={<ResponseWallPage />} />
@ -126,6 +167,27 @@ export default function App() {
<Route path="/map" element={<FeatureGate feature="enableMap"><MapPage /></FeatureGate>} />
<Route path="/p/:slug" element={<FeatureGate feature="enableLandingPages"><PublicLandingPage /></FeatureGate>} />
{/* Public Media Gallery (purple theme) — feature-gated */}
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<MediaGalleryPage />} />
<Route path="shorts" element={<ShortsPage />} />
<Route path=":category" element={<MediaGalleryPage />} />
</Route>
<Route path="/gallery/curated" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<PlaylistBrowsePage />} />
</Route>
<Route path="/gallery/curated/share/:token" element={<FeatureGate feature="enableMediaFeatures"><PlaylistViewerPage /></FeatureGate>} />
<Route path="/gallery/curated/:playlistId" element={<FeatureGate feature="enableMediaFeatures"><PlaylistViewerPage /></FeatureGate>} />
<Route path="/gallery/my-stats" element={<FeatureGate feature="enableMediaFeatures"><ProtectedRoute><MediaPublicLayout /></ProtectedRoute></FeatureGate>}>
<Route index element={<MyStatsPage />} />
</Route>
<Route path="/gallery/my-settings" element={<FeatureGate feature="enableMediaFeatures"><ProtectedRoute><MediaPublicLayout /></ProtectedRoute></FeatureGate>}>
<Route index element={<MySettingsPage />} />
</Route>
<Route path="/gallery/watch/:id" element={<FeatureGate feature="enableMediaFeatures"><MediaViewerPage /></FeatureGate>} />
{/* Email link alias for video viewer */}
<Route path="/media/:id" element={<MediaViewerPage />} />
{/* Volunteer map — full-screen, default landing page */}
<Route
path="/volunteer"
@ -156,14 +218,8 @@ export default function App() {
/>
<Route path="/login" element={<LoginPage />} />
<Route
path="/app/pages/:id/edit"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<PageEditorPage />
</ProtectedRoute>
}
/>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route
path="/app"
element={
@ -205,6 +261,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="email-templates"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<EmailTemplatesPage />
</ProtectedRoute>
}
/>
<Route
path="responses"
element={
@ -213,6 +277,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="campaign-moderation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CampaignModerationPage />
</ProtectedRoute>
}
/>
<Route
path="listmonk"
element={
@ -285,6 +357,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="services/miniqr"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<MiniQRPage />
</ProtectedRoute>
}
/>
<Route
path="services/excalidraw"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ExcalidrawPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={
@ -301,6 +389,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="observability"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ObservabilityPage />
</ProtectedRoute>
}
/>
<Route
path="map"
element={
@ -309,6 +405,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="map/data-quality"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<DataQualityDashboardPage />
</ProtectedRoute>
}
/>
<Route
path="map/shifts"
element={
@ -349,6 +453,46 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="media/library"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<LibraryPage />
</ProtectedRoute>
}
/>
<Route
path="media/analytics"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<AnalyticsDashboardPage />
</ProtectedRoute>
}
/>
<Route
path="media/jobs"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<MediaJobsPage />
</ProtectedRoute>
}
/>
<Route
path="media/curated"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<PlaylistManagementPage />
</ProtectedRoute>
}
/>
<Route
path="media/moderation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CommentModerationPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<RoleAwareRedirect />} />
</Routes>

View File

@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react';
import { useState } from 'react';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { Layout, Menu, Dropdown, Button, Typography, Drawer, Grid, theme } from 'antd';
import {
@ -26,25 +26,28 @@ import {
ApiOutlined,
BranchesOutlined,
CloudServerOutlined,
QrcodeOutlined,
VideoCameraOutlined,
FolderOutlined,
HistoryOutlined,
LineChartOutlined,
BarChartOutlined,
SoundOutlined,
EditOutlined,
OrderedListOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import type { PageHeaderConfig, AppOutletContext } from '@/types/api';
// Re-export for backward compatibility
export type { PageHeaderConfig, AppOutletContext };
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const { useBreakpoint } = Grid;
export interface PageHeaderConfig {
title?: string;
actions?: ReactNode;
fullBleed?: boolean;
}
export interface AppOutletContext {
setPageHeader: (config: PageHeaderConfig | null) => void;
}
function buildMenuItems(settings: import('@/types/api').SiteSettings | null): MenuProps['items'] {
const items: MenuProps['items'] = [
{
@ -61,6 +64,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
label: 'Influence',
children: [
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
{ key: '/app/campaign-moderation', icon: <FileTextOutlined />, label: 'Campaign Review' },
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
@ -70,9 +74,13 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
if (settings?.enableNewsletter !== false) {
items.push({
key: '/app/listmonk',
key: 'broadcast-submenu',
icon: <NotificationOutlined />,
label: 'Newsletter',
label: 'Broadcast',
children: [
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Listmonk' },
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
],
});
}
@ -97,7 +105,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
icon: <EnvironmentOutlined />,
label: 'Map',
children: [
{ key: '/app/map', label: 'Locations' },
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
{ key: '/app/map/shifts', icon: <CalendarOutlined />, label: 'Shifts' },
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Cuts' },
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
@ -106,6 +115,20 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
});
}
if (settings?.enableMediaFeatures !== false) {
items.push({
key: 'media-submenu',
icon: <VideoCameraOutlined />,
label: 'Media Library',
children: [
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Videos' },
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
{ key: '/app/media/moderation', icon: <MessageOutlined />, label: 'Moderation' },
{ key: '/app/media/jobs', icon: <HistoryOutlined />, label: 'Processing Jobs' },
],
});
}
items.push({
key: 'services-submenu',
icon: <CloudServerOutlined />,
@ -115,7 +138,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/mailhog', icon: <MailOutlined />, label: 'MailHog' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
{ key: '/app/observability', icon: <LineChartOutlined />, label: 'Observability' },
],
});
@ -230,7 +256,6 @@ export default function AppLayout() {
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={['influence-submenu', 'map-submenu', 'web-submenu', 'services-submenu']}
items={menuItems}
onClick={handleMenuClick}
/>
@ -304,6 +329,22 @@ export default function AppLayout() {
>
{!isMobile && 'Canvass'}
</Button>
<Button
type="text"
icon={<VideoCameraOutlined />}
onClick={() => navigate('/gallery')}
title="Open Video Gallery"
>
{!isMobile && 'Video'}
</Button>
<Button
type="text"
icon={<SoundOutlined />}
onClick={() => navigate('/campaigns')}
title="View Public Campaigns"
>
{!isMobile && 'Campaigns'}
</Button>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />}>
{!isMobile && (

View File

@ -0,0 +1,242 @@
import { useState, useEffect } from 'react';
import { Modal, Form, Input, Button, Alert, Segmented, Typography } from 'antd';
import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import axios from 'axios';
const { Text } = Typography;
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
type AuthMode = 'signin' | 'register';
interface AuthModalProps {
open: boolean;
onCancel: () => void;
onSuccess: () => void;
title?: string;
subtitle?: string;
}
export default function AuthModal({ open, onCancel, onSuccess, title, subtitle }: AuthModalProps) {
const { login, register, isLoading, error, errorCode, registrationMessage, clearError } = useAuthStore();
const [mode, setMode] = useState<AuthMode>('signin');
const [loginForm] = Form.useForm();
const [registerForm] = Form.useForm();
const [resendLoading, setResendLoading] = useState(false);
const [resendSent, setResendSent] = useState(false);
// Clear errors when switching modes
useEffect(() => {
clearError();
setResendSent(false);
}, [mode]); // eslint-disable-line react-hooks/exhaustive-deps
const handleLogin = async (values: { email: string; password: string }) => {
try {
await login(values.email, values.password);
loginForm.resetFields();
onSuccess();
} catch {
// Error is set in store
}
};
const handleRegister = async (values: { name: string; email: string; password: string }) => {
try {
const result = await register(values.name, values.email, values.password);
if (result?.requiresVerification) {
// Stay open to show verification message — don't call onSuccess
registerForm.resetFields();
return;
}
registerForm.resetFields();
onSuccess();
} catch {
// Error is set in store
}
};
const handleResendVerification = async () => {
const email = loginForm.getFieldValue('email');
if (!email) return;
setResendLoading(true);
try {
await axios.post(`${API_URL}/api/auth/resend-verification`, { email });
setResendSent(true);
} catch {
// Ignore — always show success for security
setResendSent(true);
} finally {
setResendLoading(false);
}
};
const handleCancel = () => {
loginForm.resetFields();
registerForm.resetFields();
clearError();
onCancel();
};
return (
<Modal
open={open}
onCancel={handleCancel}
footer={null}
destroyOnHidden
width={420}
>
{title && (
<div style={{ textAlign: 'center', marginBottom: 4 }}>
<Text strong style={{ fontSize: 18 }}>{title}</Text>
</div>
)}
{subtitle && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: 13 }}>{subtitle}</Text>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 20 }}>
<Segmented
options={[
{ label: 'Sign In', value: 'signin' },
{ label: 'Register', value: 'register' },
]}
value={mode}
onChange={(val) => setMode(val as AuthMode)}
/>
</div>
{/* Registration success — verification required */}
{registrationMessage && (
<Alert
message="Check Your Email"
description={registrationMessage}
type="success"
showIcon
icon={<CheckCircleOutlined />}
closable
onClose={() => clearError()}
style={{ marginBottom: 16 }}
/>
)}
{error && (
<Alert
message={error}
type="error"
showIcon
closable
onClose={() => clearError()}
description={
errorCode === 'EMAIL_NOT_VERIFIED' ? (
resendSent ? (
<Text type="success" style={{ fontSize: 12 }}>Verification email sent! Check your inbox.</Text>
) : (
<Button
type="link"
size="small"
loading={resendLoading}
onClick={handleResendVerification}
style={{ padding: 0 }}
>
Resend verification email
</Button>
)
) : errorCode === 'ACCOUNT_PENDING' ? (
<Text type="secondary" style={{ fontSize: 12 }}>
An admin will review your account shortly.
</Text>
) : undefined
}
style={{ marginBottom: 16 }}
/>
)}
{mode === 'signin' ? (
<Form form={loginForm} onFinish={handleLogin} layout="vertical" size="large">
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" autoFocus />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Please enter your password' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={isLoading} block>
Sign In
</Button>
</Form.Item>
</Form>
) : (
<Form form={registerForm} onFinish={handleRegister} layout="vertical" size="large">
<Form.Item
name="name"
rules={[{ required: true, message: 'Please enter your name' }]}
>
<Input prefix={<UserOutlined />} placeholder="Full Name" autoFocus />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: 'Please enter a password' },
{ min: 12, message: 'Password must be at least 12 characters' },
{
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Must contain uppercase, lowercase, and a digit',
},
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password (12+ chars)" />
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: 'Please confirm your password' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('Passwords do not match'));
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Confirm Password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={isLoading} block>
Create Account
</Button>
</Form.Item>
</Form>
)}
</Modal>
);
}

View File

@ -4,7 +4,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import type { SiteSettings } from '@/types/api';
interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter'>;
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures'>;
children: ReactNode;
}

View File

@ -215,6 +215,38 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
<button type="submit" style="padding: 12px 24px; background: #9d4edd; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">Send Message</button>
</form>
</section>`;
case 'video': {
const videoId = defaults.videoId || 'PLACEHOLDER';
const playerType = defaults.playerType || 'standard';
const width = defaults.width || '100%';
const height = defaults.height || 'auto';
// Generate placeholder with data attributes for hydration
return `
<section style="padding: 60px 40px;">
<div class="video-block"
data-video-id="${videoId}"
data-player-type="${playerType}"
data-width="${width}"
data-height="${height}"
data-autoplay="${defaults.autoplay || false}"
data-controls="${defaults.controls !== false}"
data-show-reactions="${defaults.showReactions !== false}"
style="max-width: ${width}; margin: 0 auto;">
<div class="video-placeholder" style="aspect-ratio: 16/9; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden;">
<div style="text-align: center; color: #fff; padding: 24px;">
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" />
</svg>
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">Video Player</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;">ID: ${videoId}</p>
<p style="margin: 4px 0 0; font-size: 0.85rem; opacity: 0.7;">${playerType === 'advanced' ? 'Advanced Player (with reactions)' : 'Standard HTML5 Player'}</p>
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Video will render on published page</p>
</div>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}

View File

@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { ConfigProvider, Layout, theme, Grid } from 'antd';
import { Outlet } from 'react-router-dom';
import MediaSidebar from '@/components/media/MediaSidebar';
import MediaBottomNav from '@/components/media/MediaBottomNav';
import ChatNotificationToast from '@/components/media/ChatNotificationToast';
import { ChatBarProvider } from '@/components/media/chatbar/ChatBarContext';
import ChatBar from '@/components/media/chatbar/ChatBar';
import { useChatNotifications } from '@/hooks/useChatNotifications';
import { useSettingsStore } from '@/stores/settings.store';
import { hexToRgba } from '@/utils/color';
const { useBreakpoint } = Grid;
export default function MediaPublicLayout() {
const { settings } = useSettingsStore();
const { notifications, clearNotification } = useChatNotifications();
// Read colors from site settings (same source as PublicLayout)
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
const orgName = settings?.organizationName ?? 'Changemaker Lite';
const screens = useBreakpoint();
const isMobile = !screens.md; // < 768px
// Get sidebar collapse state from localStorage
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('media_sidebar_collapsed');
return saved ? JSON.parse(saved) : false;
});
// Listen for sidebar collapse state changes
useEffect(() => {
const handleStorage = () => {
const saved = localStorage.getItem('media_sidebar_collapsed');
if (saved) {
setSidebarCollapsed(JSON.parse(saved));
}
};
window.addEventListener('storage', handleStorage);
// Also poll localStorage every 100ms to catch same-window changes
const interval = setInterval(handleStorage, 100);
return () => {
window.removeEventListener('storage', handleStorage);
clearInterval(interval);
};
}, []);
// Set document title for media pages
useEffect(() => {
document.title = `Media Gallery | ${orgName}`;
}, [orgName]);
// Calculate main content left margin based on sidebar state and screen size
const mainContentMarginLeft = isMobile ? 0 : sidebarCollapsed ? 64 : 256;
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary,
colorBgBase,
colorBgContainer,
colorBgElevated: colorBgContainer,
colorBorder: hexToRgba(colorPrimary, 0.2),
colorBorderSecondary: 'rgba(255,255,255,0.06)',
borderRadius: 12,
colorLink: colorPrimary,
colorText: 'rgba(255, 255, 255, 0.85)',
colorTextSecondary: 'rgba(255, 255, 255, 0.65)',
colorTextTertiary: 'rgba(255, 255, 255, 0.45)',
},
}}
>
<ChatBarProvider>
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
{/* Desktop: Show sidebar, Mobile: Hide */}
{!isMobile && <MediaSidebar />}
{/* Main content area */}
<main
style={{
marginLeft: mainContentMarginLeft,
minHeight: '100vh',
overflowY: 'auto',
paddingBottom: 48, // Space for bottom search bar
transition: 'margin-left 0.3s ease',
background: colorBgBase,
}}
>
<div
style={{
width: '100%',
margin: '0 auto',
padding: isMobile ? '8px 8px' : '12px 12px',
}}
>
<Outlet />
</div>
</main>
{/* Mobile: Show bottom nav, Desktop: Hide */}
<MediaBottomNav />
{/* Chat reply notifications */}
<ChatNotificationToast
notifications={notifications}
clearNotification={clearNotification}
/>
{/* Messenger-style chat bar */}
<ChatBar />
</Layout>
</ChatBarProvider>
</ConfigProvider>
);
}

View File

@ -1,6 +1,7 @@
import { Navigate } from 'react-router-dom';
import { Spin, Result } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { hasAnyRole } from '@/utils/roles';
import type { UserRole } from '@/types/api';
interface ProtectedRouteProps {
@ -33,7 +34,7 @@ export default function ProtectedRoute({
return <Navigate to="/login" replace />;
}
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
if (requiredRoles && user && !hasAnyRole(user, requiredRoles)) {
return (
<Result
status="403"

View File

@ -1,12 +1,65 @@
import { useEffect } from 'react';
import { ConfigProvider, Layout, Typography, theme } from 'antd';
import { Outlet, Link } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { ConfigProvider, Layout, Typography, theme, Space } from 'antd';
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { PlayCircleOutlined, PlusCircleOutlined, FileTextOutlined, LoginOutlined, LogoutOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import AuthModal from '@/components/AuthModal';
const { Header, Content, Footer } = Layout;
const navItemStyle: React.CSSProperties = {
color: 'rgba(255, 255, 255, 0.85)',
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
transition: 'color 0.2s',
whiteSpace: 'nowrap',
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
font: 'inherit',
};
function NavLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
return (
<Link
to={to}
style={navItemStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<span>{label}</span>
</Link>
);
}
function NavButton({ onClick, icon, label }: { onClick: () => void; icon: React.ReactNode; label: string }) {
return (
<span
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') onClick(); }}
style={navItemStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<span>{label}</span>
</span>
);
}
export default function PublicLayout() {
const { settings } = useSettingsStore();
const { isAuthenticated, logout } = useAuthStore();
const navigate = useNavigate();
const [authModalOpen, setAuthModalOpen] = useState(false);
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
@ -53,12 +106,13 @@ export default function PublicLayout() {
background: headerGradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
justifyContent: 'space-between',
padding: '0 24px',
height: 56,
borderBottom: 'none',
}}
>
{/* Left: Logo */}
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 10 }}>
{logoUrl && (
<img
@ -71,6 +125,23 @@ export default function PublicLayout() {
{orgName}
</Typography.Text>
</Link>
{/* Right: Navigation */}
<Space size={16} wrap>
{isAuthenticated ? (
<>
<NavLink to="/campaigns/create" icon={<PlusCircleOutlined />} label="Create Campaign" />
<NavLink to="/campaigns/mine" icon={<FileTextOutlined />} label="My Campaigns" />
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
</>
) : (
<>
<NavButton onClick={() => setAuthModalOpen(true)} icon={<PlusCircleOutlined />} label="Create Campaign" />
<NavButton onClick={() => setAuthModalOpen(true)} icon={<LoginOutlined />} label="Sign In" />
</>
)}
<NavLink to="/gallery" icon={<PlayCircleOutlined />} label="Media Gallery" />
</Space>
</Header>
<Content
style={{
@ -94,10 +165,29 @@ export default function PublicLayout() {
<div>{footerText}</div>
<div style={{ marginTop: 8 }}>
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Return to Main Page
Campaigns
</Link>
{' • '}
<Link to="/campaigns/create" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Create Campaign
</Link>
{' • '}
<Link to="/gallery" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Media Gallery
</Link>
</div>
</Footer>
<AuthModal
open={authModalOpen}
onCancel={() => setAuthModalOpen(false)}
onSuccess={() => {
setAuthModalOpen(false);
navigate('/campaigns/create');
}}
title="Sign in to Create a Campaign"
subtitle="Sign in or create an account to submit your own campaign"
/>
</Layout>
</ConfigProvider>
);

View File

@ -5,6 +5,7 @@ import {
CalendarOutlined,
HistoryOutlined,
NodeIndexOutlined,
MenuOutlined,
} from '@ant-design/icons';
const NAV_ITEMS = [
@ -16,9 +17,11 @@ const NAV_ITEMS = [
interface VolunteerFooterNavProps {
style?: React.CSSProperties;
onMenuOpen?: () => void;
menuActive?: boolean;
}
export default function VolunteerFooterNav({ style }: VolunteerFooterNavProps) {
export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = false }: VolunteerFooterNavProps) {
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
@ -47,6 +50,29 @@ export default function VolunteerFooterNav({ style }: VolunteerFooterNavProps) {
...style,
}}
>
{/* Menu button */}
{onMenuOpen && (
<div
onClick={onMenuOpen}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
cursor: 'pointer',
padding: '6px 0',
color: menuActive ? token.colorPrimary : 'rgba(255,255,255,0.5)',
transition: 'color 0.2s',
}}
>
<MenuOutlined style={{ fontSize: 22, marginBottom: 2 }} />
<span style={{ fontSize: 12, lineHeight: '16px', fontWeight: menuActive ? 600 : 400 }}>
Menu
</span>
</div>
)}
{NAV_ITEMS.map(({ key, icon: Icon, label }) => {
const isActive = activeKey === key;
return (

View File

@ -15,6 +15,7 @@ interface AddLocationDrawerProps {
userRole: UserRole;
sessionId?: string;
shiftId?: string;
zIndex?: number;
}
const outcomeKeys: VisitOutcome[] = [
@ -35,12 +36,13 @@ export default function AddLocationDrawer({
lat,
lng,
userRole,
sessionId,
shiftId,
sessionId: _sessionId,
shiftId: _shiftId,
zIndex = 1000,
}: AddLocationDrawerProps) {
const [form] = Form.useForm();
const { message } = App.useApp();
const { addLocation, recordVisit, reverseGeocode } = useCanvassStore();
const { addLocation, reverseGeocode } = useCanvassStore();
const [loading, setLoading] = useState(false);
const [geocoding, setGeocoding] = useState(false);
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
@ -99,24 +101,16 @@ export default function AddLocationDrawer({
if (showDetailFields && notes) locationData.notes = notes;
// Create location
const newLoc = await addLocation(locationData);
await addLocation(locationData);
// Track event point for location added
useTrackingStore.getState().addEventPoint(lat, lng, 'LOCATION_ADDED');
// Record visit on the new location
await recordVisit({
locationId: newLoc.id,
outcome,
supportLevel,
signRequested,
signSize,
notes: notes || undefined,
sessionId,
shiftId,
});
// TODO: Record visit on the new address
// Need to get addressId from created location (returned from addLocation above)
// For now, just add the location - visit can be recorded separately
message.success('Location added & visit recorded');
message.success('Location added successfully');
onClose();
} catch {
message.error('Failed to add location');
@ -131,6 +125,7 @@ export default function AddLocationDrawer({
open={open}
onClose={onClose}
height="auto"
zIndex={zIndex}
forceRender
styles={{
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },

View File

@ -1,20 +1,23 @@
import { useState, useRef } from 'react';
import { Input, Button, App } from 'antd';
import { Input, Button, App, Grid } from 'antd';
import type { InputRef } from 'antd';
import { SearchOutlined, CloseOutlined } from '@ant-design/icons';
import { useCanvassStore } from '@/stores/canvass.store';
interface Props {
onFlyTo: (lat: number, lng: number) => void;
style?: React.CSSProperties;
}
export default function AddressSearchOverlay({ onFlyTo }: Props) {
export default function AddressSearchOverlay({ onFlyTo, style }: Props) {
const [expanded, setExpanded] = useState(false);
const [searching, setSearching] = useState(false);
const [query, setQuery] = useState('');
const inputRef = useRef<InputRef>(null);
const { message } = App.useApp();
const { geocodeSearch } = useCanvassStore();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const handleSearch = async () => {
if (!query.trim()) return;
@ -37,10 +40,7 @@ export default function AddressSearchOverlay({ onFlyTo }: Props) {
onClick={() => setExpanded(true)}
title="Search address"
style={{
position: 'absolute',
top: 12,
left: 60,
zIndex: 1000,
...style,
width: 40,
height: 40,
borderRadius: 8,
@ -63,10 +63,7 @@ export default function AddressSearchOverlay({ onFlyTo }: Props) {
return (
<div
style={{
position: 'absolute',
top: 12,
left: 60,
zIndex: 1000,
...style,
display: 'flex',
gap: 4,
background: 'rgba(0,0,0,0.75)',
@ -83,7 +80,13 @@ export default function AddressSearchOverlay({ onFlyTo }: Props) {
onChange={(e) => setQuery(e.target.value)}
onPressEnter={handleSearch}
size="small"
style={{ width: 200, background: 'rgba(255,255,255,0.1)', border: 'none', color: '#fff' }}
style={{
width: isMobile ? '100%' : 200,
maxWidth: isMobile ? '100%' : 200,
background: 'rgba(255,255,255,0.1)',
border: 'none',
color: '#fff',
}}
autoFocus
/>
<Button

View File

@ -1,14 +1,17 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { MapContainer, TileLayer } from 'react-leaflet';
import { MapContainer } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { api } from '@/lib/api';
import type { LiveVolunteer } from '@/types/tracking';
import type { PublicCut, MapSettings } from '@/types/api';
import CutOverlays from '@/components/map/CutOverlays';
import CutOverlayControls from '@/components/map/CutOverlayControls';
import DynamicTileLayer from '@/components/map/DynamicTileLayer';
import TileLayerToggle from '@/components/map/TileLayerToggle';
import { getPersistedTileLayer, persistTileLayer, getTileConfig } from '@/components/map/tileLayers';
import VolunteerMarker from './VolunteerMarker';
const DARK_TILE = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const DEFAULT_CENTER: [number, number] = [45.42, -75.69];
const DEFAULT_ZOOM = 13;
const POLL_INTERVAL = 15000;
@ -16,10 +19,13 @@ const POLL_INTERVAL = 15000;
interface AdminLiveMapProps {
cuts: PublicCut[];
mapSettings: MapSettings | null;
visibleCutIds: Set<string>;
onToggleCut: (id: string) => void;
}
export default function AdminLiveMap({ cuts, mapSettings }: AdminLiveMapProps) {
export default function AdminLiveMap({ cuts, mapSettings, visibleCutIds, onToggleCut }: AdminLiveMapProps) {
const [volunteers, setVolunteers] = useState<LiveVolunteer[]>([]);
const [tileKey, setTileKey] = useState(getPersistedTileLayer);
const mapRef = useRef<LeafletMap | null>(null);
const fetchLive = useCallback(async () => {
@ -42,8 +48,6 @@ export default function AdminLiveMap({ cuts, mapSettings }: AdminLiveMapProps) {
: DEFAULT_CENTER;
const zoom = mapSettings?.zoom ?? DEFAULT_ZOOM;
const visibleCutIds = new Set(cuts.map((c) => c.id));
return (
<MapContainer
center={center}
@ -52,9 +56,22 @@ export default function AdminLiveMap({ cuts, mapSettings }: AdminLiveMapProps) {
zoomControl={true}
ref={mapRef}
>
<TileLayer
attribution='&copy; <a href="https://carto.com">CARTO</a>'
url={DARK_TILE}
<DynamicTileLayer config={getTileConfig(tileKey)} />
<TileLayerToggle
activeKey={tileKey}
onChange={(key) => {
setTileKey(key);
persistTileLayer(key);
}}
position="bottom-right"
/>
<CutOverlayControls
cuts={cuts}
visibleCutIds={visibleCutIds}
onToggleCut={onToggleCut}
variant="admin"
/>
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />

View File

@ -0,0 +1,418 @@
import { useState } from 'react';
import { Button, Badge, Drawer } from 'antd';
import {
MenuOutlined,
ArrowRightOutlined,
NodeIndexOutlined,
AimOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
PlusOutlined,
SearchOutlined,
EyeOutlined,
InfoCircleOutlined,
UpOutlined,
DownOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import type { PublicCut } from '@/types/api';
import CutOverlayControls from '@/components/map/CutOverlayControls';
import CanvassLegend from './CanvassLegend';
import AddressSearchOverlay from './AddressSearchOverlay';
import { TILE_LAYERS } from '@/components/map/tileLayers';
interface BottomControlPanelProps {
// Session controls
sessionActive?: boolean;
visitedCount: number;
totalCount: number;
routeVisible: boolean;
gpsFollowing: boolean;
fullscreen?: boolean;
// Cut overlay
cuts: PublicCut[];
visibleCutIds: Set<string>;
// Tile layer
activeTileKey: string;
onTileChange: (key: string) => void;
// Handlers
onNextDoor: () => void;
onToggleRoute: () => void;
onToggleGps: () => void;
onToggleFullscreen?: () => void;
onAddAtCenter?: () => void;
onToggleCut: (id: string) => void;
onSearchFlyTo: (lat: number, lng: number) => void;
bottomOffset?: number;
}
export default function BottomControlPanel({
sessionActive = false,
visitedCount,
totalCount,
routeVisible,
gpsFollowing,
fullscreen,
cuts,
visibleCutIds,
activeTileKey,
onTileChange,
onNextDoor,
onToggleRoute,
onToggleGps,
onToggleFullscreen,
onAddAtCenter,
onToggleCut,
onSearchFlyTo,
bottomOffset = 56,
}: BottomControlPanelProps) {
const [collapsed, setCollapsed] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showCuts, setShowCuts] = useState(false);
const [showLegend, setShowLegend] = useState(false);
const [showTiles, setShowTiles] = useState(false);
// Compact icon button with scale feedback
const IconButton = ({
icon,
onClick,
label,
type = 'default',
ghost,
badge,
}: {
icon: React.ReactNode;
onClick: () => void;
label: string;
type?: 'default' | 'primary';
ghost?: boolean;
badge?: string | number;
}) => {
const [pressed, setPressed] = useState(false);
const button = (
<Button
type={type}
icon={icon}
onClick={onClick}
size="middle"
ghost={ghost}
aria-label={label}
onTouchStart={() => setPressed(true)}
onTouchEnd={() => setPressed(false)}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
style={{
transform: pressed ? 'scale(0.92)' : 'scale(1)',
transition: 'transform 0.08s ease',
minWidth: 36,
width: 36,
height: 36,
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
);
return badge ? (
<Badge count={badge} size="small" offset={[-4, 4]} style={{ fontSize: 10 }}>
{button}
</Badge>
) : (
button
);
};
// When collapsed, show a small floating expand button
if (collapsed) {
return (
<div
style={{
position: 'absolute',
bottom: bottomOffset + 8,
right: 12,
zIndex: 1100,
}}
>
<Button
type="primary"
icon={<UpOutlined />}
onClick={() => setCollapsed(false)}
size="small"
shape="circle"
aria-label="Show controls"
style={{
width: 36,
height: 36,
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
}}
/>
</div>
);
}
return (
<>
{/* Single compact control bar */}
<div
style={{
position: 'absolute',
bottom: bottomOffset,
left: 0,
right: 0,
zIndex: 1100,
background: 'rgba(13, 27, 42, 0.98)',
borderTop: '1px solid rgba(255,255,255,0.15)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
paddingBottom: 'env(safe-area-inset-bottom)',
transition: 'all 0.12s ease',
}}
>
<div
style={{
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
gap: 6,
justifyContent: 'space-between',
flexWrap: 'wrap',
}}
>
{/* Left group: Collapse */}
<div style={{ display: 'flex', gap: 4 }}>
<IconButton
icon={<DownOutlined />}
onClick={() => setCollapsed(true)}
label="Hide controls"
/>
</div>
{/* Center group: Session actions (if active) */}
{sessionActive && (
<div style={{ display: 'flex', gap: 4 }}>
<IconButton
icon={<ArrowRightOutlined />}
onClick={onNextDoor}
label="Next door"
type="primary"
/>
<IconButton
icon={<NodeIndexOutlined />}
onClick={onToggleRoute}
label="Toggle route"
type={routeVisible ? 'primary' : 'default'}
ghost={routeVisible}
/>
</div>
)}
{/* Center group: Utility buttons */}
<div style={{ display: 'flex', gap: 4, flex: sessionActive ? 0 : 1, justifyContent: sessionActive ? 'flex-start' : 'center' }}>
<IconButton
icon={<GlobalOutlined />}
onClick={() => setShowTiles(!showTiles)}
label="Map tiles"
type={showTiles ? 'primary' : 'default'}
/>
<IconButton
icon={<SearchOutlined />}
onClick={() => setShowSearch(!showSearch)}
label="Search"
type={showSearch ? 'primary' : 'default'}
/>
{cuts.length > 0 && (
<IconButton
icon={<EyeOutlined />}
onClick={() => setShowCuts(!showCuts)}
label="Cuts"
type={showCuts ? 'primary' : 'default'}
badge={visibleCutIds.size > 0 ? visibleCutIds.size : undefined}
/>
)}
<IconButton
icon={<InfoCircleOutlined />}
onClick={() => setShowLegend(!showLegend)}
label="Legend"
type={showLegend ? 'primary' : 'default'}
/>
{onAddAtCenter && (
<IconButton
icon={<PlusOutlined />}
onClick={onAddAtCenter}
label="Add location"
/>
)}
{onToggleFullscreen && (
<IconButton
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={onToggleFullscreen}
label={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
type={fullscreen ? 'primary' : 'default'}
/>
)}
</div>
{/* Right group: GPS + Badge */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<IconButton
icon={<AimOutlined />}
onClick={onToggleGps}
label="GPS"
type={gpsFollowing ? 'primary' : 'default'}
ghost={gpsFollowing}
/>
{sessionActive && (
<Badge
count={`${visitedCount}/${totalCount}`}
style={{
backgroundColor: visitedCount === totalCount && totalCount > 0 ? '#27ae60' : '#3498db',
fontSize: 10,
height: 18,
lineHeight: '18px',
padding: '0 5px',
borderRadius: 9,
}}
/>
)}
</div>
</div>
</div>
{/* Floating panels */}
{/* Tile layer popup - vertical stack above button */}
{showTiles && (
<>
{/* Backdrop to close on tap */}
<div
onClick={() => setShowTiles(false)}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1089,
}}
/>
{/* Tile options - vertically stacked */}
<div
style={{
position: 'absolute',
bottom: bottomOffset + 44 + 8, // Just above control bar (44px bar height + 8px gap)
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1090,
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{TILE_LAYERS.map((layer) => (
<button
key={layer.key}
onClick={() => {
onTileChange(layer.key);
setShowTiles(false);
}}
title={layer.label}
style={{
width: 36,
height: 36,
border: activeTileKey === layer.key ? '2px solid #1890ff' : '1px solid rgba(255,255,255,0.3)',
borderRadius: 8,
background: activeTileKey === layer.key ? 'rgba(24, 144, 255, 0.25)' : 'rgba(13, 27, 42, 0.98)',
backdropFilter: 'blur(12px)',
color: activeTileKey === layer.key ? '#1890ff' : 'rgba(255,255,255,0.8)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
transition: 'all 0.08s',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
>
<GlobalOutlined style={{ transform: layer.key === 'satellite' ? 'rotate(45deg)' : 'none' }} />
</button>
))}
</div>
</>
)}
{/* Search drawer */}
<Drawer
placement="top"
open={showSearch}
onClose={() => setShowSearch(false)}
height="auto"
closable={false}
maskClosable
mask={false}
zIndex={1090}
styles={{
body: { padding: '12px 16px', background: 'rgba(13, 27, 42, 0.98)' },
wrapper: { top: 'env(safe-area-inset-top)' },
}}
>
<AddressSearchOverlay
onFlyTo={(lat, lng) => {
onSearchFlyTo(lat, lng);
setShowSearch(false);
}}
style={{ position: 'relative', top: 0, left: 0, right: 0 }}
/>
</Drawer>
{/* Cuts drawer */}
<Drawer
placement="top"
open={showCuts}
onClose={() => setShowCuts(false)}
height="auto"
closable={false}
maskClosable
mask={false}
zIndex={1090}
styles={{
body: { padding: '12px 16px', background: 'rgba(13, 27, 42, 0.98)' },
wrapper: { top: 'env(safe-area-inset-top)' },
}}
>
<CutOverlayControls
cuts={cuts}
visibleCutIds={visibleCutIds}
onToggleCut={onToggleCut}
variant="public"
style={{ position: 'relative', top: 0, left: 0 }}
/>
</Drawer>
{/* Legend drawer */}
<Drawer
placement="top"
open={showLegend}
onClose={() => setShowLegend(false)}
height="auto"
closable={false}
maskClosable
mask={false}
zIndex={1090}
styles={{
body: { padding: '12px 16px', background: 'rgba(13, 27, 42, 0.98)' },
wrapper: { top: 'env(safe-area-inset-top)' },
}}
>
<CanvassLegend style={{ position: 'relative', top: 0, right: 0, maxWidth: '100%' }} />
</Drawer>
</>
);
}

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { Button, Badge } from 'antd';
import {
AimOutlined,
@ -9,6 +10,51 @@ import {
MenuOutlined,
} from '@ant-design/icons';
interface ToolbarButtonProps {
icon: React.ReactNode;
onClick: () => void;
label: string;
disabled?: boolean;
type?: 'default' | 'primary' | 'text' | 'link' | 'dashed';
ghost?: boolean;
children?: React.ReactNode;
}
function ToolbarButton({
icon,
onClick,
label,
disabled,
type = 'default',
ghost,
children,
}: ToolbarButtonProps) {
const [isPressed, setIsPressed] = useState(false);
return (
<Button
type={type}
icon={icon}
onClick={onClick}
disabled={disabled}
size="middle"
ghost={ghost}
aria-label={label}
onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)}
onTouchStart={() => setIsPressed(true)}
onTouchEnd={() => setIsPressed(false)}
style={{
transform: isPressed ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease',
}}
>
{children}
</Button>
);
}
interface CanvassBottomToolbarProps {
visitedCount: number;
totalCount: number;
@ -47,7 +93,8 @@ export default function CanvassBottomToolbar({
<div
style={{
position: 'absolute',
bottom: bottomOffset,
bottom: `max(${bottomOffset}px, calc(${bottomOffset}px + env(safe-area-inset-bottom)))`,
paddingBottom: 'env(safe-area-inset-bottom)',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
@ -60,59 +107,53 @@ export default function CanvassBottomToolbar({
}}
>
{onMenuOpen && (
<Button
<ToolbarButton
type="default"
icon={<MenuOutlined />}
onClick={onMenuOpen}
size="middle"
aria-label="Open menu"
label="Open menu"
/>
)}
{sessionActive && (
<>
<Button
<ToolbarButton
type="primary"
icon={<ArrowRightOutlined />}
onClick={onNextDoor}
size="middle"
aria-label="Next door"
label="Next door"
>
Next
</Button>
<Button
</ToolbarButton>
<ToolbarButton
type={routeVisible ? 'primary' : 'default'}
icon={<NodeIndexOutlined />}
onClick={onToggleRoute}
size="middle"
ghost={routeVisible}
aria-label="Toggle walking route"
label="Toggle walking route"
/>
</>
)}
<Button
<ToolbarButton
type={gpsFollowing ? 'primary' : 'default'}
icon={<AimOutlined />}
onClick={onToggleGps}
size="middle"
ghost={gpsFollowing}
aria-label="Toggle GPS following"
label="Toggle GPS following"
/>
{onToggleFullscreen && (
<Button
<ToolbarButton
type={fullscreen ? 'primary' : 'default'}
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={onToggleFullscreen}
size="middle"
aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
/>
)}
{onAddAtCenter && (
<Button
<ToolbarButton
type="default"
icon={<PlusOutlined />}
onClick={onAddAtCenter}
size="middle"
aria-label="Add location at crosshair"
label="Add location at crosshair"
/>
)}
{sessionActive && (

View File

@ -9,7 +9,11 @@ const items: { key: string; label: string; color: string }[] = [
})),
];
export default function CanvassLegend() {
interface CanvassLegendProps {
style?: React.CSSProperties;
}
export default function CanvassLegend({ style }: CanvassLegendProps = {}) {
return (
<div
style={{
@ -21,6 +25,7 @@ export default function CanvassLegend() {
borderRadius: 8,
padding: '8px 10px',
maxWidth: 180,
...style,
}}
>
{/* Icon type indicators */}

View File

@ -0,0 +1,335 @@
import React, { useMemo, useState } from 'react';
import { Marker, Popup } from 'react-leaflet';
import { Alert, theme } from 'antd';
import L from 'leaflet';
import type { CanvassAddress, AddressGroup, VisitOutcome } from '@/types/canvass';
import { VISIT_OUTCOME_COLORS, VISIT_OUTCOME_LABELS } from '@/types/canvass';
import { sanitizeHtml } from '@/utils/sanitize';
interface CanvassMarkerGroupProps {
group: AddressGroup;
selectedAddressId: string | null;
onAddressClick: (addressId: string) => void;
}
// Marker size constants
const MARKER_SIZE_DEFAULT = 26;
const MARKER_SIZE_SELECTED = 34;
const MARKER_TOUCH_TARGET = 44;
const MARKER_ANCHOR_OFFSET = 22;
function getMarkerColor(address: CanvassAddress): string {
if (!address.lastVisit) return '#95a5a6'; // gray — unvisited
return VISIT_OUTCOME_COLORS[address.lastVisit.outcome] ?? '#95a5a6';
}
function getDominantOutcomeColor(addresses: CanvassAddress[]): string {
const outcomeCounts: Record<string, number> = {};
let unvisitedCount = 0;
for (const addr of addresses) {
if (addr.lastVisit) {
const outcome = addr.lastVisit.outcome;
outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1;
} else {
unvisitedCount++;
}
}
// If any are unvisited, show gray
if (unvisitedCount > 0) return '#95a5a6';
// Otherwise, find dominant outcome
let dominant: string | null = null;
let maxCount = 0;
for (const [outcome, count] of Object.entries(outcomeCounts)) {
if (count > maxCount) {
maxCount = count;
dominant = outcome;
}
}
return dominant ? VISIT_OUTCOME_COLORS[dominant as VisitOutcome] ?? '#95a5a6' : '#95a5a6';
}
// Inline SVG for house icon
function houseSvg(color: string, size: number, selected: boolean): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" role="img" aria-label="Single-family home">
<title>Single-family home</title>
${selected ? `<circle cx="12" cy="12" r="12" fill="none" stroke="white" stroke-width="2" opacity="0.5"/>` : ''}
<path d="M12 3L4 10v10a1 1 0 001 1h4v-6h6v6h4a1 1 0 001-1V10z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
<path d="M12 3L4 10h16z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
</svg>`;
}
// Inline SVG for apartment/building icon
function apartmentSvg(color: string, size: number, selected: boolean): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" role="img" aria-label="Multi-unit apartment building">
<title>Multi-unit apartment building</title>
${selected ? `<circle cx="12" cy="12" r="12" fill="none" stroke="white" stroke-width="2" opacity="0.5"/>` : ''}
<rect x="4" y="3" width="16" height="18" rx="1" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
<rect x="7" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="14" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="7" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="14" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="10" y="16" width="4" height="5" rx="0.3" fill="rgba(255,255,255,0.4)"/>
</svg>`;
}
/** Compact dropdown UI for multi-unit buildings (replaces long scrollable list) */
function MultiUnitPopup({
group,
addresses,
selectedAddressId,
onAddressClick,
token,
}: {
group: AddressGroup;
addresses: CanvassAddress[];
selectedAddressId: string | null;
onAddressClick: (addressId: string) => void;
token: ReturnType<typeof theme.useToken>['token'];
}) {
// Default to the selected address in this building, or the first address
const initialId = addresses.find((a) => a.id === selectedAddressId)?.id ?? addresses[0]?.id ?? '';
const [viewingId, setViewingId] = useState(initialId);
const viewingAddr = addresses.find((a) => a.id === viewingId) ?? addresses[0];
const visitedCount = addresses.filter((a) => a.lastVisit).length;
return (
<>
{/* Header */}
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: `2px solid ${token.colorPrimary}` }}>
<div style={{ fontWeight: 600, fontSize: 14, color: token.colorPrimary }}>
🏢 {group.baseAddress}
</div>
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{addresses.length} units · {visitedCount} visited
</div>
</div>
{/* Building notes */}
{group.buildingNotes && (
<Alert
message="Building Notes"
description={
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(group.buildingNotes) }} />
}
type="info"
showIcon
style={{ marginBottom: 12, fontSize: 11 }}
/>
)}
{/* Native <select> dropdown — Ant Design Select doesn't work inside Leaflet popups */}
<select
value={viewingId}
onChange={(e) => setViewingId(e.target.value)}
style={{
width: '100%',
padding: '8px 10px',
fontSize: 13,
borderRadius: 6,
border: '1px solid #d9d9d9',
background: '#fafafa',
marginBottom: 10,
cursor: 'pointer',
appearance: 'auto',
}}
aria-label="Select unit"
>
{addresses.map((addr) => {
const status = addr.lastVisit
? VISIT_OUTCOME_LABELS[addr.lastVisit.outcome]
: 'Not visited';
return (
<option key={addr.id} value={addr.id}>
Unit {addr.unitNumber || '—'} {status}
</option>
);
})}
</select>
{/* Selected unit detail card */}
{viewingAddr && (
<button
type="button"
onClick={() => onAddressClick(viewingAddr.id)}
aria-label={`Record visit for unit ${viewingAddr.unitNumber || 'main'}`}
style={{
all: 'unset',
display: 'block',
width: '100%',
boxSizing: 'border-box',
padding: 10,
borderRadius: 8,
border: `1px solid ${viewingAddr.id === selectedAddressId ? token.colorPrimary : '#e8e8e8'}`,
background: viewingAddr.id === selectedAddressId ? 'rgba(52, 152, 219, 0.06)' : '#fafafa',
cursor: 'pointer',
}}
>
{viewingAddr.unitNumber && (
<div style={{ fontSize: 13, fontWeight: 600, color: '#333', marginBottom: 4 }}>
Unit {viewingAddr.unitNumber}
</div>
)}
{viewingAddr.firstName && (
<div style={{ fontSize: 12, color: '#555', marginBottom: 4 }}>
{viewingAddr.firstName} {viewingAddr.lastName}
</div>
)}
<div style={{ fontSize: 12, marginBottom: viewingAddr.notes ? 4 : 0 }}>
{viewingAddr.lastVisit ? (
<>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(viewingAddr),
marginRight: 5,
verticalAlign: 'middle',
}}
/>
<span style={{ verticalAlign: 'middle' }}>
{VISIT_OUTCOME_LABELS[viewingAddr.lastVisit.outcome]}
</span>
{viewingAddr.lastVisit.visitorName && (
<span style={{ fontSize: 11, color: '#888', marginLeft: 6 }}>
by {viewingAddr.lastVisit.visitorName}
</span>
)}
</>
) : (
<span style={{ color: '#999' }}>Not visited</span>
)}
</div>
{viewingAddr.notes && (
<div style={{ fontSize: 11, color: '#888', fontStyle: 'italic' }}>
Note: {viewingAddr.notes}
</div>
)}
<div style={{ fontSize: 11, color: '#1890ff', marginTop: 8, textAlign: 'center' }}>
Tap to record visit
</div>
</button>
)}
</>
);
}
function CanvassMarkerGroup({ group, selectedAddressId, onAddressClick }: CanvassMarkerGroupProps) {
const addresses = group.addresses;
const isMultiUnit = group.isMultiUnit;
const hasAnySelected = addresses.some((addr) => addr.id === selectedAddressId);
const size = hasAnySelected ? MARKER_SIZE_SELECTED : MARKER_SIZE_DEFAULT;
const { token } = theme.useToken();
// For multi-unit, use dominant outcome color; for single-unit, use that address's color
const color = useMemo(() => {
if (isMultiUnit) {
return getDominantOutcomeColor(addresses);
} else {
return getMarkerColor(addresses[0]!);
}
}, [addresses, isMultiUnit]);
const icon = useMemo(() => {
const svgHtml = isMultiUnit
? apartmentSvg(color, size, hasAnySelected)
: houseSvg(color, size, hasAnySelected);
return L.divIcon({
html: `<div style="width:${MARKER_TOUCH_TARGET}px;height:${MARKER_TOUCH_TARGET}px;display:flex;align-items:center;justify-content:center">${svgHtml}</div>`,
iconSize: [MARKER_TOUCH_TARGET, MARKER_TOUCH_TARGET],
iconAnchor: [MARKER_ANCHOR_OFFSET, MARKER_ANCHOR_OFFSET],
className: '',
});
}, [color, size, hasAnySelected, isMultiUnit]);
return (
<Marker
position={[group.latitude, group.longitude]}
icon={icon}
// @ts-expect-error - Pass group data for cluster icon access
addressGroup={group}
>
<Popup maxWidth={350} minWidth={250}>
<div style={{ minWidth: 230, maxWidth: 330 }}>
{isMultiUnit ? (
// Multi-unit building display — compact dropdown
<MultiUnitPopup
group={group}
addresses={addresses}
selectedAddressId={selectedAddressId}
onAddressClick={onAddressClick}
token={token}
/>
) : (
// Single unit display
<div style={{ cursor: 'pointer' }} onClick={() => onAddressClick(addresses[0]!.id)}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
{group.baseAddress}
</div>
{addresses[0]?.unitNumber && (
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
Unit {addresses[0].unitNumber}
</div>
)}
{addresses[0]?.firstName && (
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{addresses[0].firstName} {addresses[0].lastName}
</div>
)}
{addresses[0]?.lastVisit ? (
<>
<div style={{ fontSize: 12, marginTop: 4 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(addresses[0]),
marginRight: 4,
}}
/>
{VISIT_OUTCOME_LABELS[addresses[0].lastVisit.outcome]}
</div>
{addresses[0].lastVisit.visitorName && (
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
by {addresses[0].lastVisit.visitorName}
</div>
)}
</>
) : (
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>Not visited</div>
)}
{addresses[0]?.notes && (
<div style={{ fontSize: 11, color: '#888', marginTop: 4, fontStyle: 'italic' }}>
Note: {addresses[0].notes}
</div>
)}
<div style={{ fontSize: 11, color: '#1890ff', marginTop: 8, textAlign: 'center' }}>
Click to record visit
</div>
</div>
)}
</div>
</Popup>
</Marker>
);
}
// Memoize component to prevent re-renders when props haven't changed
export default React.memo(CanvassMarkerGroup, (prevProps, nextProps) => {
// Only re-render if these specific props change
return (
prevProps.group.locationId === nextProps.group.locationId &&
prevProps.group.addresses === nextProps.group.addresses &&
prevProps.selectedAddressId === nextProps.selectedAddressId &&
prevProps.onAddressClick === nextProps.onAddressClick
);
});

View File

@ -3,12 +3,12 @@ import { Drawer, Form, Input, Select, Switch, Button, message } from 'antd';
import type { CanvassLocation } from '@/types/canvass';
import type { SupportLevel } from '@/types/api';
import { SUPPORT_LEVEL_LABELS } from '@/types/api';
import { useCanvassStore } from '@/stores/canvass.store';
interface LocationEditDrawerProps {
open: boolean;
onClose: () => void;
location: CanvassLocation | null;
zIndex?: number;
}
const supportLevelOptions = (['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'] as SupportLevel[]).map(
@ -19,9 +19,10 @@ export default function LocationEditDrawer({
open,
onClose,
location,
zIndex = 1000,
}: LocationEditDrawerProps) {
const [form] = Form.useForm();
const { updateLocationFields } = useCanvassStore();
// TODO: Update to work with Address model instead of deprecated CanvassLocation
useEffect(() => {
if (location && open) {
@ -40,20 +41,16 @@ export default function LocationEditDrawer({
const handleSave = async () => {
if (!location) return;
try {
const values = await form.validateFields();
await updateLocationFields(location.id, values);
message.success('Location updated');
onClose();
} catch {
message.error('Failed to update location');
}
message.warning('Location editing temporarily disabled - needs Address model update');
onClose();
// TODO: Implement address update API call
};
return (
<Drawer
placement="bottom"
open={open}
zIndex={zIndex}
onClose={onClose}
height="auto"
styles={{

View File

@ -1,17 +1,23 @@
import { useState } from 'react';
import { Button, Input, Space, Typography, message } from 'antd';
import type { VisitOutcome, RecordVisitPayload, CanvassLocation } from '@/types/canvass';
import { Button, Input, Space, Typography, message, Alert, Dropdown, Modal, Row, Col, Grid } from 'antd';
import { FormOutlined, ArrowRightOutlined, WarningOutlined } from '@ant-design/icons';
import type { VisitOutcome, RecordVisitPayload, BulkRecordVisitPayload, CanvassAddress } from '@/types/canvass';
import type { SupportLevel, UserRole } from '@/types/api';
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
import { sanitizeHtml } from '@/utils/sanitize';
interface VisitRecordingFormProps {
location: CanvassLocation;
address: CanvassAddress;
sessionId?: string;
shiftId?: string;
onRecord: (payload: RecordVisitPayload) => Promise<void>;
onBulkRecord?: (payload: BulkRecordVisitPayload) => Promise<void>;
onNextUnit?: () => void;
recording: boolean;
userRole?: UserRole;
isMultiUnit?: boolean;
unvisitedCountInBuilding?: number;
}
const outcomeKeys: VisitOutcome[] = [
@ -27,18 +33,25 @@ const outcomeKeys: VisitOutcome[] = [
const supportLevelKeys: SupportLevel[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'];
export default function VisitRecordingForm({
location,
address,
sessionId,
shiftId,
onRecord,
onBulkRecord,
onNextUnit,
recording,
userRole,
isMultiUnit = false,
unvisitedCountInBuilding = 0,
}: VisitRecordingFormProps) {
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
const [supportLevel, setSupportLevel] = useState<SupportLevel | undefined>(undefined);
const [signRequested, setSignRequested] = useState(false);
const [signSize, setSignSize] = useState<string | undefined>(undefined);
const [notes, setNotes] = useState('');
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const isNarrow = !screens.sm;
const showDetailFields = userRole !== 'TEMP';
@ -49,7 +62,7 @@ export default function VisitRecordingForm({
}
await onRecord({
locationId: location.id,
addressId: address.id, // Changed from locationId
outcome,
supportLevel,
signRequested,
@ -59,6 +72,11 @@ export default function VisitRecordingForm({
shiftId,
});
// Auto-advance to next unit if multi-unit
if (isMultiUnit && unvisitedCountInBuilding > 1 && onNextUnit) {
onNextUnit();
}
// Reset form
setOutcome(null);
setSupportLevel(undefined);
@ -67,81 +85,166 @@ export default function VisitRecordingForm({
setNotes('');
};
const handleBulkRecord = (bulkOutcome: 'NOT_HOME' | 'REFUSED' | 'MOVED') => {
if (!onBulkRecord) return;
Modal.confirm({
title: (
<span style={{ color: '#ff4d4f' }}>
Bulk Record Visit
</span>
),
icon: <WarningOutlined style={{ color: '#ff4d4f' }} />,
content: (
<div>
<p>
This will mark <strong style={{ color: '#ff4d4f' }}>ALL {unvisitedCountInBuilding} unvisited units</strong> in this building as:
</p>
<p style={{ fontSize: 16, fontWeight: 600, color: '#ff4d4f', margin: '12px 0' }}>
{bulkOutcome.replace(/_/g, ' ')}
</p>
<p style={{ fontSize: 12, color: '#666', marginTop: 8 }}>
This action will record {unvisitedCountInBuilding} separate visit entries and cannot be easily undone.
</p>
</div>
),
okText: `Record ${unvisitedCountInBuilding} Visits`,
okType: 'danger',
okButtonProps: { danger: true },
cancelText: 'Cancel',
onOk: async () => {
await onBulkRecord({
locationId: address.location.id,
outcome: bulkOutcome,
notes,
sessionId,
shiftId,
});
},
});
};
return (
<div style={{ padding: '0 4px' }}>
<Typography.Text strong style={{ fontSize: 15, display: 'block', marginBottom: 8 }}>
{location.address || 'Unknown Address'}
{location.unitNumber && ` #${location.unitNumber}`}
</Typography.Text>
{location.firstName && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
{location.firstName} {location.lastName}
{address.firstName && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{address.firstName} {address.lastName}
</Typography.Text>
)}
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Outcome
{/* Building notes for multi-unit */}
{address.location.buildingNotes && (
<Alert
message="Building Notes"
description={
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(address.location.buildingNotes) }} />
}
type="info"
showIcon
banner
closable
style={{ marginBottom: 12, fontSize: 11 }}
/>
)}
<Typography.Text
strong
style={{
display: 'block',
marginBottom: 8,
fontSize: 13,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
Visit Outcome
</Typography.Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
<Row gutter={[6, 6]} style={{ marginBottom: 12 }}>
{outcomeKeys.map((key) => {
const color = VISIT_OUTCOME_COLORS[key];
const selected = outcome === key;
return (
<Button
<Col
key={key}
size="middle"
type={selected ? 'primary' : 'default'}
style={{
borderColor: color,
background: selected ? color : 'transparent',
color: selected ? '#fff' : color,
fontSize: 12,
}}
onClick={() => setOutcome(key)}
xs={isNarrow ? 12 : 8}
sm={8}
md={6}
>
{VISIT_OUTCOME_LABELS[key]}
</Button>
<Button
block
size="large"
type={selected ? 'primary' : 'default'}
style={{
borderColor: color,
background: selected ? color : 'transparent',
color: selected ? '#fff' : color,
fontSize: 12,
fontWeight: key === 'SPOKE_WITH' ? 600 : 400,
}}
onClick={() => setOutcome(key)}
>
{VISIT_OUTCOME_LABELS[key]}
</Button>
</Col>
);
})}
</div>
</Row>
{showDetailFields && outcome === 'SPOKE_WITH' && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
<Typography.Text
strong
style={{
display: 'block',
marginTop: 16,
marginBottom: 8,
fontSize: 13,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
Support Level
</Typography.Text>
<Space style={{ marginBottom: 12 }}>
<Row gutter={[8, 8]} justify="space-between" style={{ marginBottom: 12 }}>
{supportLevelKeys.map((key) => (
<Button
key={key}
shape="circle"
size="large"
type={supportLevel === key ? 'primary' : 'default'}
style={{
width: 44,
height: 44,
background: supportLevel === key ? SUPPORT_LEVEL_COLORS[key] : undefined,
borderColor: SUPPORT_LEVEL_COLORS[key],
color: supportLevel === key ? '#fff' : SUPPORT_LEVEL_COLORS[key],
fontWeight: 700,
}}
onClick={() => setSupportLevel(supportLevel === key ? undefined : key)}
>
{key.replace('LEVEL_', '')}
</Button>
<Col key={key} xs={6} sm={6}>
<div style={{ textAlign: 'center' }}>
<Button
shape="circle"
size="large"
type={supportLevel === key ? 'primary' : 'default'}
style={{
width: 48,
height: 48,
background: supportLevel === key ? SUPPORT_LEVEL_COLORS[key] : undefined,
borderColor: SUPPORT_LEVEL_COLORS[key],
color: supportLevel === key ? '#fff' : SUPPORT_LEVEL_COLORS[key],
fontWeight: 700,
}}
onClick={() => setSupportLevel(supportLevel === key ? undefined : key)}
>
{key.replace('LEVEL_', '')}
</Button>
<div style={{ fontSize: 11, marginTop: 4, color: 'rgba(255,255,255,0.6)' }}>
{SUPPORT_LEVEL_LABELS[key]}
</div>
</div>
</Col>
))}
</Space>
<div style={{ marginBottom: 4 }}>
{supportLevel && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{SUPPORT_LEVEL_LABELS[supportLevel]}
</Typography.Text>
)}
</div>
</Row>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Sign
<Typography.Text
strong
style={{
display: 'block',
marginTop: 16,
marginBottom: 8,
fontSize: 13,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
Sign Request
</Typography.Text>
<Space style={{ marginBottom: 12 }}>
<Button
@ -170,8 +273,18 @@ export default function VisitRecordingForm({
{showDetailFields && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Notes
<Typography.Text
strong
style={{
display: 'block',
marginTop: 16,
marginBottom: 8,
fontSize: 13,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
Notes (Optional)
</Typography.Text>
<Input.TextArea
value={notes}
@ -183,16 +296,63 @@ export default function VisitRecordingForm({
</>
)}
<Button
type="primary"
block
size="large"
onClick={handleSubmit}
loading={recording}
disabled={!outcome}
>
Record Visit
</Button>
<Space style={{ width: '100%' }} direction="vertical" size="small">
<Button
type="primary"
block
size="large"
onClick={handleSubmit}
loading={recording}
disabled={!outcome}
>
Record Visit
</Button>
{/* Bulk record and next unit buttons for multi-unit */}
{isMultiUnit && unvisitedCountInBuilding > 1 && (
<Row gutter={[8, 8]} style={{ width: '100%' }}>
{/* Bulk record dropdown */}
{onBulkRecord && (
<Col xs={24} sm={12}>
<Dropdown
menu={{
items: [
{
key: 'NOT_HOME',
label: `All Not Home (${unvisitedCountInBuilding})`,
onClick: () => handleBulkRecord('NOT_HOME'),
},
{
key: 'REFUSED',
label: `All Refused (${unvisitedCountInBuilding})`,
onClick: () => handleBulkRecord('REFUSED'),
},
{
key: 'MOVED',
label: `All Moved (${unvisitedCountInBuilding})`,
onClick: () => handleBulkRecord('MOVED'),
},
],
}}
>
<Button icon={<FormOutlined />} size="middle" block>
Record All Units
</Button>
</Dropdown>
</Col>
)}
{/* Next unit button */}
{onNextUnit && (
<Col xs={24} sm={12}>
<Button icon={<ArrowRightOutlined />} onClick={onNextUnit} size="middle" block>
Next Unit
</Button>
</Col>
)}
</Row>
)}
</Space>
</div>
);
}

View File

@ -1,12 +1,16 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List } from 'antd';
import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List, Grid, Alert } from 'antd';
import {
HistoryOutlined,
LogoutOutlined,
PlayCircleOutlined,
AimOutlined,
StopOutlined,
ClockCircleOutlined,
CloseOutlined,
} from '@ant-design/icons';
import SessionTimer from './SessionTimer';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import type { MyAssignment, MyCanvassStats } from '@/types/canvass';
@ -15,21 +19,35 @@ import type { PublicCut } from '@/types/api';
interface VolunteerMapDrawerProps {
open: boolean;
onClose: () => void;
drawerBodyRef?: React.RefObject<HTMLDivElement>;
cuts: PublicCut[];
onStartSession: (cutId: string, shiftId?: string) => void;
sessionActive?: boolean;
sessionCutName?: string;
sessionStartedAt?: string;
onEndSession?: () => void;
endingSession?: boolean;
}
export default function VolunteerMapDrawer({
open,
onClose,
drawerBodyRef,
cuts,
onStartSession,
sessionActive = false,
sessionCutName,
sessionStartedAt,
onEndSession,
endingSession = false,
}: VolunteerMapDrawerProps) {
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const [stats, setStats] = useState<MyCanvassStats | null>(null);
const [assignments, setAssignments] = useState<MyAssignment[]>([]);
const [freeCutId, setFreeCutId] = useState<string | null>(null);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
useEffect(() => {
if (!open) return;
@ -45,15 +63,93 @@ export default function VolunteerMapDrawer({
return (
<Drawer
placement="left"
placement="bottom"
open={open}
onClose={onClose}
width={300}
height="auto"
closable={false}
mask={false}
maskClosable={false}
zIndex={1150}
styles={{
body: { padding: '16px', display: 'flex', flexDirection: 'column' },
wrapper: {
bottom: 0, // Sits at bottom, footer will push up
},
body: {
padding: isMobile ? '12px' : '16px',
maxHeight: '60vh',
overflowY: 'auto',
},
header: { display: 'none' },
}}
>
<div ref={drawerBodyRef} style={{ width: '100%' }}>
{/* Header with drag handle and close button */}
<div style={{ position: 'relative', marginBottom: 16 }}>
{/* Drag handle at top center */}
<div
style={{
width: 40,
height: 4,
background: 'rgba(255,255,255,0.3)',
borderRadius: 2,
margin: '0 auto',
}}
/>
{/* Close button at top right */}
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
style={{
position: 'absolute',
top: -8,
right: -8,
color: 'rgba(255,255,255,0.6)',
}}
size="small"
/>
</div>
{/* Active session alert */}
{sessionActive && sessionCutName && (
<>
<Alert
message={
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong style={{ fontSize: 13 }}>
Active Session: {sessionCutName}
</Typography.Text>
{sessionStartedAt && (
<Space size={4}>
<ClockCircleOutlined style={{ fontSize: 12 }} />
<SessionTimer startedAt={sessionStartedAt} />
</Space>
)}
</Space>
}
type="info"
showIcon={false}
action={
onEndSession && (
<Button
danger
size="small"
icon={<StopOutlined />}
onClick={onEndSession}
loading={endingSession}
>
End
</Button>
)
}
style={{ marginBottom: 16 }}
/>
<Divider style={{ margin: '0 0 16px 0' }} />
</>
)}
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 4 }}>
{user?.name || user?.email || 'Volunteer'}
</Typography.Text>
@ -71,8 +167,8 @@ export default function VolunteerMapDrawer({
<Divider style={{ margin: '8px 0' }} />
{/* Assignments */}
{assignments.length > 0 && (
{/* Assignments (hidden when session active) */}
{!sessionActive && assignments.length > 0 && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
My Assignments
@ -111,28 +207,32 @@ export default function VolunteerMapDrawer({
</>
)}
{/* Free session — pick a cut */}
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
Start Session (Any Cut)
</Typography.Text>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Select
placeholder="Select a cut..."
style={{ flex: 1 }}
value={freeCutId}
onChange={setFreeCutId}
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
allowClear
/>
<Button
type="primary"
icon={<AimOutlined />}
disabled={!freeCutId}
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
>
Go
</Button>
</Space.Compact>
{/* Free session — pick a cut (hidden when session active) */}
{!sessionActive && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
Start Session (Any Cut)
</Typography.Text>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Select
placeholder="Select a cut..."
style={{ flex: 1 }}
value={freeCutId}
onChange={setFreeCutId}
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
allowClear
/>
<Button
type="primary"
icon={<AimOutlined />}
disabled={!freeCutId}
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
>
Go
</Button>
</Space.Compact>
</>
)}
{/* Navigation links */}
<Button
@ -158,6 +258,7 @@ export default function VolunteerMapDrawer({
>
Logout
</Button>
</div>
</Drawer>
);
}

View File

@ -26,7 +26,8 @@ export default function VolunteerSessionBar({
<div
style={{
position: 'absolute',
bottom: 60,
bottom: `max(60px, calc(56px + 4px + env(safe-area-inset-bottom)))`,
paddingBottom: 'env(safe-area-inset-bottom)',
left: 0,
right: 0,
height: 40,

View File

@ -0,0 +1,150 @@
import L from 'leaflet';
import type { AddressGroup, VisitOutcome } from '@/types/canvass';
import { VISIT_OUTCOME_COLORS } from '@/types/canvass';
// Outcome priority order for cluster color aggregation
// Higher priority outcomes take precedence when determining cluster color
const OUTCOME_PRIORITY: Record<VisitOutcome | 'UNVISITED', number> = {
UNVISITED: 1, // Highest priority - if any address unvisited, show gray
COME_BACK_LATER: 2,
NOT_HOME: 3,
SPOKE_WITH: 4,
LEFT_LITERATURE: 5,
REFUSED: 6,
ALREADY_VOTED: 7,
MOVED: 8, // Lowest priority
};
const UNVISITED_COLOR = '#95a5a6'; // Gray
// Cluster metadata cache to avoid repeated calculations
interface ClusterMetadata {
totalAddresses: number;
hasUnvisited: boolean;
outcomeCounts: Record<VisitOutcome, number>;
dominantColor: string;
}
/**
* Creates cluster metadata from marker group with single pass computation.
* Calculates total addresses, visit status, outcome counts, and dominant color.
*
* @param markers - Array of Leaflet markers in the cluster
* @returns Cluster metadata with all computed values
*/
function computeClusterMetadata(markers: L.Marker[]): ClusterMetadata {
let totalAddresses = 0;
let hasUnvisited = false;
const outcomeCounts: Record<VisitOutcome, number> = {
NOT_HOME: 0,
REFUSED: 0,
MOVED: 0,
ALREADY_VOTED: 0,
SPOKE_WITH: 0,
LEFT_LITERATURE: 0,
COME_BACK_LATER: 0,
};
for (const marker of markers) {
const group = (marker.options as any).addressGroup as AddressGroup | undefined;
if (!group) continue;
totalAddresses += group.addresses.length;
for (const address of group.addresses) {
if (!address.lastVisit) {
hasUnvisited = true;
} else {
outcomeCounts[address.lastVisit.outcome]++;
}
}
}
// Calculate dominant color
let dominantColor = UNVISITED_COLOR;
if (hasUnvisited) {
dominantColor = UNVISITED_COLOR;
} else {
let highestPriority = Infinity;
for (const [outcome, count] of Object.entries(outcomeCounts)) {
if (count > 0) {
const priority = OUTCOME_PRIORITY[outcome as VisitOutcome];
if (priority < highestPriority) {
highestPriority = priority;
dominantColor = VISIT_OUTCOME_COLORS[outcome as VisitOutcome];
}
}
}
}
return { totalAddresses, hasUnvisited, outcomeCounts, dominantColor };
}
/**
* Determines the dominant outcome color for a cluster of markers.
* Priority: Unvisited > Come Back Later > Not Home > Spoke With > etc.
*
* @param markers - Array of Leaflet markers in the cluster
* @returns Hex color code for the cluster icon
*/
export function getClusterColor(markers: L.Marker[]): string {
// Compute once, return color
const metadata = computeClusterMetadata(markers);
return metadata.dominantColor;
}
/**
* Creates a custom cluster icon showing total address count and dominant outcome color.
*
* @param cluster - Leaflet marker cluster group
* @returns Custom DivIcon for the cluster
*/
export function createClusterIcon(cluster: any): L.DivIcon {
const markers = cluster.getAllChildMarkers();
// Single pass computation - calculates all metadata at once
const metadata = computeClusterMetadata(markers);
// Generate circular badge HTML
const html = `
<div style="
width: 40px;
height: 40px;
border-radius: 50%;
background-color: ${metadata.dominantColor};
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
font-weight: bold;
font-size: 14px;
color: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
">
${metadata.totalAddresses}
</div>
`;
return L.divIcon({
html,
className: 'marker-cluster-custom',
iconSize: [40, 40],
});
}
/**
* Configuration for MarkerClusterGroup component.
* Optimized for performance and UX with address canvassing.
*/
export const CLUSTER_CONFIG = {
maxClusterRadius: 50, // 50px clustering radius
disableClusteringAtZoom: 18, // Disable at street zoom level (buildings clearly visible)
spiderfyOnMaxZoom: false, // Zoom to bounds instead of spider
showCoverageOnHover: false, // No polygon on hover (reduces visual clutter)
zoomToBoundsOnClick: true, // Click cluster → zoom in
animate: true, // Smooth zoom/pan animations
animateAddingMarkers: false, // Skip animation when adding bulk markers (performance)
removeOutsideVisibleBounds: true, // Remove offscreen markers from DOM (memory optimization)
chunkedLoading: true, // Load markers in chunks to prevent UI freeze
};

View File

@ -0,0 +1,49 @@
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Cell,
} from 'recharts';
import { Typography } from 'antd';
import type { ContainerResource } from '@/types/api';
const { Text } = Typography;
interface ContainerMemoryChartProps {
containers: ContainerResource[];
height?: number;
}
function memColor(mb: number, maxMb: number): string {
const ratio = maxMb > 0 ? mb / maxMb : 0;
if (ratio > 0.7) return '#ff4d4f';
if (ratio > 0.4) return '#faad14';
return '#52c41a';
}
export default function ContainerMemoryChart({ containers, height = 180 }: ContainerMemoryChartProps) {
const sorted = [...containers]
.filter(c => c.memoryMB > 0)
.sort((a, b) => b.memoryMB - a.memoryMB);
if (sorted.length === 0) {
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 16 }}>No container data</Text>;
}
const maxMem = sorted[0]?.memoryMB ?? 1;
const chartData = sorted.map(c => ({ name: c.label, memory: c.memoryMB }));
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 16, left: 4, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10 }} unit="MB" />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={70} />
<Tooltip formatter={(v) => `${v} MB`} contentStyle={{ fontSize: 12, borderRadius: 6 }} />
<Bar dataKey="memory" radius={[0, 4, 4, 0]}>
{chartData.map((entry, i) => (
<Cell key={i} fill={memColor(entry.memory, maxMem)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,58 @@
import { Popover, Progress, Typography, Space, Flex } from 'antd';
import type { ContainerResource } from '@/types/api';
const { Text } = Typography;
interface ContainerPopoverProps {
resource?: ContainerResource;
children: React.ReactNode;
}
export default function ContainerPopover({ resource, children }: ContainerPopoverProps) {
if (!resource) return <>{children}</>;
const memPct = resource.memoryLimitMB > 0
? Math.round((resource.memoryMB / resource.memoryLimitMB) * 100)
: 0;
const content = (
<Space direction="vertical" size={4} style={{ width: 200 }}>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>CPU</Text>
<Text style={{ fontSize: 12 }}>{resource.cpuPercent.toFixed(1)}%</Text>
</Flex>
<Progress
percent={Math.min(resource.cpuPercent, 100)}
size="small"
showInfo={false}
strokeColor={resource.cpuPercent > 80 ? '#ff4d4f' : resource.cpuPercent > 50 ? '#faad14' : '#52c41a'}
/>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>Memory</Text>
<Text style={{ fontSize: 12 }}>{resource.memoryMB}MB{resource.memoryLimitMB > 0 ? ` / ${resource.memoryLimitMB}MB` : ''}</Text>
</Flex>
{resource.memoryLimitMB > 0 && (
<Progress
percent={memPct}
size="small"
showInfo={false}
strokeColor={memPct > 80 ? '#ff4d4f' : memPct > 60 ? '#faad14' : '#52c41a'}
/>
)}
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>Network Rx</Text>
<Text style={{ fontSize: 12 }}>{resource.networkRxKBps.toFixed(1)} KB/s</Text>
</Flex>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>Network Tx</Text>
<Text style={{ fontSize: 12 }}>{resource.networkTxKBps.toFixed(1)} KB/s</Text>
</Flex>
</Space>
);
return (
<Popover content={content} title={resource.label} trigger="hover" placement="top">
{children}
</Popover>
);
}

View File

@ -0,0 +1,50 @@
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer,
} from 'recharts';
import { Typography } from 'antd';
import type { TimeSeriesResult } from '@/types/api';
const { Text } = Typography;
interface LatencyBandsChartProps {
data: TimeSeriesResult;
height?: number;
}
function formatTime(ts: number): string {
const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export default function LatencyBandsChart({ data, height = 200 }: LatencyBandsChartProps) {
const p50 = data.latency_p50;
const p95 = data.latency_p95;
const p99 = data.latency_p99;
if (!p50?.timestamps?.length) {
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 24 }}>No latency data</Text>;
}
const chartData = p50.timestamps.map((ts, i) => ({
time: formatTime(ts),
p50: Math.round((p50.values[i] || 0) * 1000),
p95: Math.round((p95?.values[i] || 0) * 1000),
p99: Math.round((p99?.values[i] || 0) * 1000),
}));
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} unit="ms" />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} formatter={(v) => `${v}ms`} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area type="monotone" dataKey="p99" stroke="#ff4d4f" fill="#ff4d4f" fillOpacity={0.15} />
<Area type="monotone" dataKey="p95" stroke="#faad14" fill="#faad14" fillOpacity={0.25} />
<Area type="monotone" dataKey="p50" stroke="#52c41a" fill="#52c41a" fillOpacity={0.4} />
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,49 @@
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
interface DonutDatum {
name: string;
value: number;
color: string;
}
interface MiniDonutChartProps {
data: DonutDatum[];
height?: number;
innerRadius?: number;
outerRadius?: number;
}
export default function MiniDonutChart({
data,
height = 120,
innerRadius = 28,
outerRadius = 48,
}: MiniDonutChartProps) {
const filtered = data.filter(d => d.value > 0);
if (filtered.length === 0) return null;
return (
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={filtered}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{filtered.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value, name) => [`${value}`, `${name}`]}
contentStyle={{ fontSize: 12, padding: '4px 8px', borderRadius: 6 }}
/>
</PieChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,50 @@
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer,
} from 'recharts';
import { Typography } from 'antd';
import type { TimeSeriesResult } from '@/types/api';
const { Text } = Typography;
interface RequestTrafficChartProps {
data: TimeSeriesResult;
height?: number;
}
function formatTime(ts: number): string {
const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export default function RequestTrafficChart({ data, height = 200 }: RequestTrafficChartProps) {
const series2xx = data.request_rate_2xx;
const series4xx = data.request_rate_4xx;
const series5xx = data.request_rate_5xx;
if (!series2xx?.timestamps?.length) {
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 24 }}>No traffic data</Text>;
}
const chartData = series2xx.timestamps.map((ts, i) => ({
time: formatTime(ts),
'2xx': series2xx.values[i] || 0,
'4xx': series4xx?.values[i] || 0,
'5xx': series5xx?.values[i] || 0,
}));
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area type="monotone" dataKey="2xx" stackId="1" stroke="#52c41a" fill="#52c41a" fillOpacity={0.6} />
<Area type="monotone" dataKey="4xx" stackId="1" stroke="#faad14" fill="#faad14" fillOpacity={0.6} />
<Area type="monotone" dataKey="5xx" stackId="1" stroke="#ff4d4f" fill="#ff4d4f" fillOpacity={0.6} />
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,58 @@
import { Progress, Flex, Typography } from 'antd';
import type { SystemInfo } from '@/types/api';
const { Text } = Typography;
function gaugeColor(percent: number): string {
if (percent > 90) return '#ff4d4f';
if (percent > 70) return '#faad14';
return '#52c41a';
}
interface SystemGaugesProps {
systemInfo: SystemInfo;
}
export default function SystemGauges({ systemInfo }: SystemGaugesProps) {
const cpuPercent = Math.min(
Math.round(((systemInfo.cpu.loadAvg[0] ?? 0) / systemInfo.cpu.cores) * 100),
100,
);
return (
<Flex justify="space-around" align="center" wrap="wrap" gap={8}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={cpuPercent}
size={72}
strokeColor={gaugeColor(cpuPercent)}
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
/>
<div><Text type="secondary" style={{ fontSize: 11 }}>CPU</Text></div>
</div>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={systemInfo.memory.usagePercent}
size={72}
strokeColor={gaugeColor(systemInfo.memory.usagePercent)}
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
/>
<div><Text type="secondary" style={{ fontSize: 11 }}>Memory</Text></div>
</div>
{systemInfo.disk && (
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={systemInfo.disk.usagePercent}
size={72}
strokeColor={gaugeColor(systemInfo.disk.usagePercent)}
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
/>
<div><Text type="secondary" style={{ fontSize: 11 }}>Disk</Text></div>
</div>
)}
</Flex>
);
}

View File

@ -0,0 +1,481 @@
import { useState, useEffect, useCallback } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Button,
Space,
Typography,
message,
Spin,
Tag,
Grid,
Result,
theme,
Input,
Tabs,
App,
} from 'antd';
import {
ArrowLeftOutlined,
SaveOutlined,
SendOutlined,
MailOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { api } from '@/lib/api';
import type { EmailTemplate, EmailTemplateCategory } from '@/types/api';
import TestEmailModal from '@/components/email-templates/TestEmailModal';
import VariablesPanel from '@/components/email-templates/VariablesPanel';
const { Text } = Typography;
interface EmailTemplateEditorProps {
templateId: string;
onClose: () => void;
}
export default function EmailTemplateEditor({
templateId,
onClose,
}: EmailTemplateEditorProps) {
const [template, setTemplate] = useState<EmailTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [subjectLine, setSubjectLine] = useState('');
const [htmlContent, setHtmlContent] = useState('');
const [textContent, setTextContent] = useState('');
const [activeEditorTab, setActiveEditorTab] = useState<'html' | 'text'>('html');
const [activeTab, setActiveTab] = useState('variables');
const [testModalOpen, setTestModalOpen] = useState(false);
const [sampleData, setSampleData] = useState<Record<string, string>>({});
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const { modal } = App.useApp();
const { setPageHeader } = useOutletContext<{ setPageHeader: (config: { fullBleed: boolean } | null) => void }>();
// Enable fullBleed mode on mount, disable on unmount
useEffect(() => {
setPageHeader({ fullBleed: true });
return () => setPageHeader(null);
}, [setPageHeader]);
useEffect(() => {
const fetchTemplate = async () => {
try {
const { data } = await api.get<EmailTemplate>(`/email-templates/${templateId}`);
setTemplate(data);
setSubjectLine(data.subjectLine);
setHtmlContent(data.htmlContent);
setTextContent(data.textContent);
// Initialize sample data from variables
const initialSampleData: Record<string, string> = {};
data.variables.forEach((v) => {
initialSampleData[v.key] = v.sampleValue || '';
});
setSampleData(initialSampleData);
} catch {
message.error('Failed to load template');
onClose();
} finally {
setLoading(false);
}
};
fetchTemplate();
}, [templateId, onClose]);
const handleSave = useCallback(async () => {
if (!template) return;
setSaving(true);
try {
const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${templateId}`, {
subjectLine,
htmlContent,
textContent,
});
setTemplate(updated);
message.success('Template saved successfully');
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
'Failed to save template';
message.error(msg);
} finally {
setSaving(false);
}
}, [template, templateId, subjectLine, htmlContent, textContent]);
// Keyboard shortcut for saving (Ctrl+S)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handleSave]);
const handleClose = () => {
// Check if there are unsaved changes
if (template && (
subjectLine !== template.subjectLine ||
htmlContent !== template.htmlContent ||
textContent !== template.textContent
)) {
modal.confirm({
title: 'Unsaved Changes',
content: 'You have unsaved changes. Are you sure you want to discard them?',
okText: 'Discard',
cancelText: 'Continue Editing',
okType: 'danger',
onOk: () => onClose(),
});
} else {
onClose();
}
};
const processTemplate = (content: string, data: Record<string, string>): string => {
let processed = content;
// Process VIDEO variables first (render as video player in preview)
if (template) {
template.variables.forEach((variable) => {
if (variable.type === 'VIDEO' && variable.videoId) {
const mediaApiUrl = import.meta.env.VITE_MEDIA_API_URL || 'http://localhost:4100';
const videoHtml = `
<div style="max-width: 600px; margin: 20px auto; text-align: center;">
<video
controls
style="width: 100%; max-width: 600px; border-radius: 8px;"
src="${mediaApiUrl}/api/videos/${variable.videoId}/stream"
poster="${mediaApiUrl}/api/videos/${variable.videoId}/thumbnail"
>
Your browser does not support video playback.
</video>
<p style="margin-top: 8px; font-size: 12px; color: #999;">
In actual emails, this will display as a thumbnail with a link
</p>
</div>
`;
processed = processed.replace(
new RegExp(`\\{\\{${variable.key}\\}\\}`, 'g'),
videoHtml
);
}
});
}
// Process TEXT variables
Object.entries(data).forEach(([key, value]) => {
processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);
});
return processed;
};
const getCategoryColor = (category: EmailTemplateCategory): string => {
const colors: Record<EmailTemplateCategory, string> = {
INFLUENCE: 'blue',
MAP: 'green',
SYSTEM: 'purple',
};
return colors[category];
};
if (isMobile) {
return (
<div style={{ padding: 24 }}>
<Result
status="warning"
title="Desktop Required"
subTitle="The email template editor requires a desktop browser."
extra={
<Button type="primary" onClick={onClose}>
Back to Templates
</Button>
}
/>
</div>
);
}
if (loading || !template) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
const processedHtml = processTemplate(htmlContent, sampleData);
const processedText = processTemplate(textContent, sampleData);
return (
<div style={{
height: 'calc(100vh - 64px)',
display: 'flex',
flexDirection: 'column',
backgroundColor: token.colorBgContainer,
}}>
{/* Top Toolbar with Subject Line */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={handleClose}
/>
<Text strong>{template.name}</Text>
<Tag color={getCategoryColor(template.category)}>{template.category}</Tag>
{template.isSystem && <Tag color="blue">SYSTEM</Tag>}
</Space>
<div style={{ flex: 1, maxWidth: 600, margin: '0 24px' }}>
<Input
value={subjectLine}
onChange={(e) => setSubjectLine(e.target.value)}
placeholder="Subject Line (use {{VARIABLES}})"
prefix={<MailOutlined />}
size="small"
/>
</div>
<Space>
<Button onClick={() => setTestModalOpen(true)} icon={<SendOutlined />}>
Test
</Button>
<Button type="primary" loading={saving} onClick={handleSave} icon={<SaveOutlined />}>
Save
</Button>
</Space>
</div>
{/* Main Editor Layout - 60/40 Tabbed */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left: Tabbed Editors (60%) */}
<div
style={{
flex: '0 0 60%',
borderRight: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexDirection: 'column',
}}
>
<Tabs
activeKey={activeEditorTab}
onChange={(key) => setActiveEditorTab(key as 'html' | 'text')}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
tabBarStyle={{ margin: 0, paddingLeft: 12, backgroundColor: token.colorBgLayout, minHeight: 38 }}
size="small"
items={[
{
key: 'html',
label: 'HTML Content',
children: (
<Editor
height="calc(100vh - 110px)"
language="html"
theme="vs-dark"
value={htmlContent}
onChange={(value) => setHtmlContent(value || '')}
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
scrollBeyondLastLine: false,
}}
/>
),
},
{
key: 'text',
label: 'Plain Text Content',
children: (
<Editor
height="calc(100vh - 110px)"
language="plaintext"
theme="vs-dark"
value={textContent}
onChange={(value) => setTextContent(value || '')}
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
scrollBeyondLastLine: false,
}}
/>
),
},
]}
/>
</div>
{/* Right: Utilities Panel (40%) */}
<div
style={{
flex: '0 0 40%',
display: 'flex',
flexDirection: 'column',
backgroundColor: token.colorBgContainer,
}}
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
tabBarStyle={{ margin: 0, paddingLeft: 12, minHeight: 38 }}
size="small"
items={[
{
key: 'variables',
label: 'Variables',
children: (
<VariablesPanel
template={template}
sampleData={sampleData}
onSampleDataChange={setSampleData}
/>
),
},
{
key: 'htmlPreview',
label: 'HTML Preview',
children: (
<iframe
srcDoc={processedHtml}
style={{
width: '100%',
height: 'calc(100vh - 110px)',
border: 'none',
display: 'block',
}}
sandbox="allow-same-origin"
title="HTML Preview"
/>
),
},
{
key: 'textPreview',
label: 'Text Preview',
children: (
<pre
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 12,
lineHeight: 1.5,
padding: 16,
backgroundColor: token.colorBgLayout,
margin: 0,
height: 'calc(100vh - 110px)',
overflow: 'auto',
boxSizing: 'border-box',
}}
>
{processedText}
</pre>
),
},
{
key: 'splitPreview',
label: 'Split Preview',
children: (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 110px)',
gap: 8,
}}
>
<div style={{ flex: '1 1 60%', display: 'flex', flexDirection: 'column' }}>
<div
style={{
padding: '4px 8px',
backgroundColor: token.colorBgLayout,
borderBottom: `1px solid ${token.colorBorder}`,
flexShrink: 0,
}}
>
<Text strong style={{ fontSize: 11 }}>
HTML
</Text>
</div>
<iframe
srcDoc={processedHtml}
style={{
flex: 1,
width: '100%',
border: 'none',
display: 'block',
}}
sandbox="allow-same-origin"
title="HTML Preview"
/>
</div>
<div style={{ flex: '1 1 40%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div
style={{
padding: '4px 8px',
backgroundColor: token.colorBgLayout,
borderBottom: `1px solid ${token.colorBorder}`,
flexShrink: 0,
}}
>
<Text strong style={{ fontSize: 11 }}>
Plain Text
</Text>
</div>
<pre
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.4,
padding: 8,
backgroundColor: token.colorBgLayout,
margin: 0,
flex: 1,
overflow: 'auto',
}}
>
{processedText}
</pre>
</div>
</div>
),
},
]}
/>
</div>
</div>
{/* Test Email Modal */}
{testModalOpen && (
<TestEmailModal
open={testModalOpen}
template={template}
onClose={() => setTestModalOpen(false)}
onSuccess={() => {
message.success('Test email sent successfully');
setTestModalOpen(false);
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,470 @@
# Email Template Editor Layout Redesign
## Current Issues
- 40/40/20 split wastes horizontal space by showing both editors simultaneously
- Variables table cramped in narrow 20% panel (~300-400px)
- Preview iframes too small to be useful
- No visual focus indication for active editor
## Recommended Solution: 60/40 Tabbed Layout
### New Structure
```
┌─────────────────────────────────────────────────────────────┐
│ Toolbar: Back | Name | Category | [Subject Input] | Test Save│
├──────────────────────────────┬──────────────────────────────┤
│ Editor Tabs (60%) │ Utilities Tabs (40%) │
│ ┌──────────┬──────────┐ │ ┌─────────┬──────┬──────┐ │
│ │ HTML │ Text │ │ │Variables│HTML │Text │ │
│ └──────────┴──────────┘ │ └─────────┴──────┴──────┘ │
│ │ │
│ [Monaco Editor - Full Width] │ [Variables Table - 50%] │
│ │ [Sample Data Inputs - 50%] │
│ │ │
└───────────────────────────────┴──────────────────────────────┘
```
### Benefits
1. **Editor space**: 60% width (up from 40%) for active editor
2. **Variables table**: 40% width (~768px on 1920px screen, up from ~384px)
3. **Preview usability**: 40% width iframes are actually readable
4. **Focused editing**: Only one editor visible at a time (matches typical workflow)
5. **Vertical space**: Move subject to toolbar (saves 52px)
### Implementation Files
#### 1. Extract VariablesPanel Component
**File**: `admin/src/components/email-templates/VariablesPanel.tsx`
```tsx
import { Table, Input, Typography, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { EmailTemplate, EmailTemplateVariable } from '@/types/api';
const { Text } = Typography;
interface VariablesPanelProps {
template: EmailTemplate;
sampleData: Record<string, string>;
onSampleDataChange: (data: Record<string, string>) => void;
}
export default function VariablesPanel({
template,
sampleData,
onSampleDataChange,
}: VariablesPanelProps) {
const variableColumns: ColumnsType<EmailTemplateVariable> = [
{
title: 'Variable',
dataIndex: 'key',
width: 140,
render: (key: string) => (
<Text code style={{ fontSize: 11 }}>
{'{{' + key + '}}'}
</Text>
),
},
{
title: 'Label',
dataIndex: 'label',
width: 120,
},
{
title: 'Description',
dataIndex: 'description',
ellipsis: { showTitle: false },
render: (desc: string | null) => (
<Text
type="secondary"
ellipsis={{ tooltip: desc || undefined }}
style={{ fontSize: 12 }}
>
{desc || '—'}
</Text>
),
},
{
title: 'Req',
dataIndex: 'isRequired',
width: 50,
align: 'center',
render: (isRequired: boolean) =>
isRequired ? (
<Tag color="red" style={{ fontSize: 10, margin: 0 }}>
Req
</Tag>
) : null,
},
];
return (
<div
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
{/* Variables Reference Table - Top Half */}
<div
style={{
flex: '0 0 50%',
overflow: 'auto',
borderBottom: '1px solid #d9d9d9',
}}
>
<div style={{ padding: '8px 12px', backgroundColor: '#fafafa' }}>
<Text strong>Available Variables</Text>
</div>
<Table
dataSource={template.variables}
columns={variableColumns}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 'calc(50vh - 200px)' }}
/>
</div>
{/* Sample Data Inputs - Bottom Half */}
<div style={{ flex: '0 0 50%', padding: 16, overflow: 'auto' }}>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
Sample Data (for preview)
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: template.variables.length > 4 ? '1fr 1fr' : '1fr',
gap: 12,
}}
>
{template.variables.map((v) => (
<div key={v.key}>
<Text
type="secondary"
style={{
fontSize: 11,
display: 'block',
marginBottom: 4,
}}
>
{v.label}
</Text>
<Input
size="small"
value={sampleData[v.key] || ''}
onChange={(e) =>
onSampleDataChange({ ...sampleData, [v.key]: e.target.value })
}
placeholder={v.sampleValue || ''}
/>
</div>
))}
</div>
</div>
</div>
);
}
```
#### 2. Update EmailTemplateEditor Component
**File**: `admin/src/components/email-templates/EmailTemplateEditor.tsx`
**Changes needed**:
1. **Add new state** (after line 45):
```tsx
const [activeEditorTab, setActiveEditorTab] = useState<'html' | 'text'>('html');
```
2. **Import VariablesPanel** (after line 25):
```tsx
import VariablesPanel from '@/components/email-templates/VariablesPanel';
```
3. **Replace toolbar** (lines 205-233) with subject-in-toolbar version:
```tsx
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<Space>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleClose} />
<Text strong>{template.name}</Text>
<Tag color={getCategoryColor(template.category)}>{template.category}</Tag>
{template.isSystem && <Tag color="blue">SYSTEM</Tag>}
</Space>
<div style={{ flex: 1, maxWidth: 600, margin: '0 24px' }}>
<Input
value={subjectLine}
onChange={(e) => setSubjectLine(e.target.value)}
placeholder="Subject Line (use {{VARIABLES}})"
prefix={<MailOutlined />}
size="small"
/>
</div>
<Space>
<Button onClick={() => setTestModalOpen(true)} icon={<SendOutlined />}>
Test
</Button>
<Button type="primary" loading={saving} onClick={handleSave} icon={<SaveOutlined />}>
Save
</Button>
</Space>
</div>
```
4. **Remove standalone subject line row** (delete lines 236-244)
5. **Replace main layout** (lines 247-376) with new 60/40 tabbed layout:
```tsx
{/* Main Editor Layout */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left: Tabbed Editors (60%) */}
<div
style={{
flex: '0 0 60%',
borderRight: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexDirection: 'column',
}}
>
<Tabs
activeKey={activeEditorTab}
onChange={(key) => setActiveEditorTab(key as 'html' | 'text')}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
tabBarStyle={{ margin: 0, paddingLeft: 12, backgroundColor: token.colorBgLayout }}
items={[
{
key: 'html',
label: 'HTML Content',
children: (
<div style={{ height: 'calc(100vh - 160px)' }}>
<Editor
height="100%"
language="html"
theme="vs-dark"
value={htmlContent}
onChange={(value) => setHtmlContent(value || '')}
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
scrollBeyondLastLine: false,
}}
/>
</div>
),
},
{
key: 'text',
label: 'Plain Text Content',
children: (
<div style={{ height: 'calc(100vh - 160px)' }}>
<Editor
height="100%"
language="plaintext"
theme="vs-dark"
value={textContent}
onChange={(value) => setTextContent(value || '')}
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
scrollBeyondLastLine: false,
}}
/>
</div>
),
},
]}
/>
</div>
{/* Right: Utilities Panel (40%) */}
<div
style={{
flex: '0 0 40%',
display: 'flex',
flexDirection: 'column',
backgroundColor: token.colorBgContainer,
}}
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
tabBarStyle={{ margin: 0, paddingLeft: 12 }}
items={[
{
key: 'variables',
label: 'Variables',
children: (
<VariablesPanel
template={template}
sampleData={sampleData}
onSampleDataChange={setSampleData}
/>
),
},
{
key: 'htmlPreview',
label: 'HTML Preview',
children: (
<div style={{ height: '100%', padding: 12 }}>
<iframe
srcDoc={processedHtml}
style={{
width: '100%',
height: '100%',
border: `1px solid ${token.colorBorder}`,
borderRadius: 4,
}}
sandbox="allow-same-origin"
title="HTML Preview"
/>
</div>
),
},
{
key: 'textPreview',
label: 'Text Preview',
children: (
<div style={{ height: '100%', padding: 12, overflow: 'auto' }}>
<pre
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 12,
lineHeight: 1.5,
padding: 12,
backgroundColor: token.colorBgLayout,
borderRadius: 4,
border: `1px solid ${token.colorBorder}`,
}}
>
{processedText}
</pre>
</div>
),
},
{
key: 'splitPreview',
label: 'Split Preview',
children: (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
padding: 12,
}}
>
<div
style={{
flex: '0 0 60%',
borderBottom: `1px solid ${token.colorBorder}`,
marginBottom: 12,
}}
>
<div
style={{
padding: '4px 8px',
backgroundColor: token.colorBgLayout,
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
}}
>
<Text strong style={{ fontSize: 11 }}>
HTML
</Text>
</div>
<iframe
srcDoc={processedHtml}
style={{
width: '100%',
height: 'calc(100% - 28px)',
border: `1px solid ${token.colorBorder}`,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
}}
sandbox="allow-same-origin"
title="HTML Preview"
/>
</div>
<div style={{ flex: '0 0 40%', overflow: 'auto' }}>
<div
style={{
padding: '4px 8px',
backgroundColor: token.colorBgLayout,
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
}}
>
<Text strong style={{ fontSize: 11 }}>
Plain Text
</Text>
</div>
<pre
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.4,
padding: 8,
backgroundColor: token.colorBgLayout,
border: `1px solid ${token.colorBorder}`,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
margin: 0,
}}
>
{processedText}
</pre>
</div>
</div>
),
},
]}
/>
</div>
</div>
```
### Expected Outcomes
**Before**: 40% HTML / 40% Text / 20% Utilities
- Variables table: ~384px width (on 1920px screen)
- Preview iframe: ~384px width
- Both editors always visible (one is always unused)
**After**: 60% Active Editor / 40% Utilities
- Variables table: ~768px width (2x improvement)
- Preview iframe: ~768px width (2x improvement)
- Active editor: ~1152px width (28% improvement)
- Sample data inputs: 2-column grid (saves vertical scrolling)
- Subject line in toolbar (saves 52px vertical space)
### Migration Steps
1. Create `VariablesPanel.tsx` component
2. Update `EmailTemplateEditor.tsx` imports
3. Add `activeEditorTab` state
4. Replace toolbar section (add subject to toolbar)
5. Remove standalone subject row
6. Replace 3-column layout with 2-column tabbed layout
7. Test all functionality (save, preview, test email)
8. Consider adding "Split Preview" tab (optional enhancement)
### Backward Compatibility
- No API changes required
- No state structure changes
- All existing functionality preserved
- Only UI layout changes

View File

@ -0,0 +1,247 @@
import { useState, useEffect } from 'react';
import { Modal, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import type { EmailTemplate, EmailTemplateTestLog } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
interface TestEmailModalProps {
open: boolean;
template: EmailTemplate;
onClose: () => void;
onSuccess: () => void;
}
export default function TestEmailModal({ open, template, onClose, onSuccess }: TestEmailModalProps) {
const { user } = useAuthStore();
const [form] = Form.useForm();
const [sending, setSending] = useState(false);
const [testData, setTestData] = useState<Record<string, string>>({});
const [testLogs, setTestLogs] = useState<EmailTemplateTestLog[]>([]);
const [loadingLogs, setLoadingLogs] = useState(false);
useEffect(() => {
if (open) {
// Initialize form with current user email
form.setFieldsValue({
recipientEmail: user?.email || '',
});
// Initialize test data with sample values
const initialData: Record<string, string> = {};
template.variables.forEach((v) => {
initialData[v.key] = v.sampleValue || '';
});
setTestData(initialData);
// Fetch test logs
fetchTestLogs();
}
}, [open, template, user, form]);
const fetchTestLogs = async () => {
setLoadingLogs(true);
try {
const { data } = await api.get<EmailTemplateTestLog[]>(`/email-templates/${template.id}/test-logs`, {
params: { limit: 10 },
});
setTestLogs(data);
} catch {
// Silently fail - logs are optional
} finally {
setLoadingLogs(false);
}
};
const handleSend = async () => {
try {
const values = await form.validateFields();
setSending(true);
await api.post(`/email-templates/${template.id}/test`, {
recipientEmail: values.recipientEmail,
testData,
});
onSuccess();
fetchTestLogs(); // Refresh logs
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
'Failed to send test email';
message.error(msg);
} finally {
setSending(false);
}
};
const processTemplate = (content: string): string => {
let processed = content;
Object.entries(testData).forEach(([key, value]) => {
processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);
});
return processed;
};
const renderedHtml = processTemplate(template.htmlContent);
const renderedText = processTemplate(template.textContent);
const renderedSubject = processTemplate(template.subjectLine);
const logColumns: ColumnsType<EmailTemplateTestLog> = [
{
title: 'Recipient',
dataIndex: 'recipientEmail',
key: 'recipientEmail',
},
{
title: 'Status',
dataIndex: 'success',
key: 'success',
render: (success: boolean) => (
<Tag color={success ? 'success' : 'error'}>{success ? 'Sent' : 'Failed'}</Tag>
),
},
{
title: 'Sent At',
dataIndex: 'sentAt',
key: 'sentAt',
render: (date: string) => dayjs(date).fromNow(),
},
];
return (
<Modal
title={`Send Test Email: ${template.name}`}
open={open}
onCancel={onClose}
width={900}
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button key="send" type="primary" loading={sending} onClick={handleSend}>
Send Test Email
</Button>,
]}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Form form={form} layout="vertical">
<Form.Item
label="Recipient Email"
name="recipientEmail"
rules={[
{ required: true, message: 'Please enter recipient email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input placeholder="Enter email address" />
</Form.Item>
<div style={{ marginTop: 16 }}>
<Text strong>Sample Data (for template variables):</Text>
</div>
{template.variables.map((variable) => (
<Form.Item
key={variable.key}
label={
<Space>
<Text>{variable.label}</Text>
<Text code style={{ fontSize: 11 }}>
{'{{' + variable.key + '}}'}
</Text>
{variable.isRequired && <Tag color="red" style={{ fontSize: 10 }}>Required</Tag>}
</Space>
}
extra={variable.description}
>
<Input
value={testData[variable.key] || ''}
onChange={(e) => setTestData({ ...testData, [variable.key]: e.target.value })}
placeholder={variable.sampleValue || ''}
/>
</Form.Item>
))}
</Form>
<div>
<Text strong>Preview Subject:</Text>
<div
style={{
padding: 8,
backgroundColor: '#f5f5f5',
borderRadius: 4,
marginTop: 8,
fontWeight: 500,
}}
>
{renderedSubject}
</div>
</div>
<Tabs
items={[
{
key: 'html',
label: 'HTML Preview',
children: (
<iframe
srcDoc={renderedHtml}
style={{
width: '100%',
height: 400,
border: '1px solid #d9d9d9',
borderRadius: 4,
}}
sandbox="allow-same-origin"
title="HTML Preview"
/>
),
},
{
key: 'text',
label: 'Text Preview',
children: (
<pre
style={{
whiteSpace: 'pre-wrap',
maxHeight: 400,
overflow: 'auto',
padding: 12,
backgroundColor: '#f5f5f5',
borderRadius: 4,
fontFamily: 'monospace',
fontSize: 12,
}}
>
{renderedText}
</pre>
),
},
{
key: 'logs',
label: `Test History (${testLogs.length})`,
children: (
<Table
dataSource={testLogs}
columns={logColumns}
rowKey="id"
size="small"
loading={loadingLogs}
pagination={false}
locale={{ emptyText: 'No test emails sent yet' }}
/>
),
},
]}
/>
</Space>
</Modal>
);
}

View File

@ -0,0 +1,187 @@
import { Table, Input, Typography, Tag, Button } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { EmailTemplate, EmailTemplateVariable } from '@/types/api';
import { VideoCameraOutlined } from '@ant-design/icons';
const { Text } = Typography;
interface VariablesPanelProps {
template: EmailTemplate;
sampleData: Record<string, string>;
onSampleDataChange: (data: Record<string, string>) => void;
}
export default function VariablesPanel({
template,
sampleData,
onSampleDataChange,
}: VariablesPanelProps) {
const variableColumns: ColumnsType<EmailTemplateVariable> = [
{
title: 'Type',
dataIndex: 'type',
width: 60,
render: (type: string) => {
if (type === 'VIDEO') {
return (
<Tag color="purple" icon={<VideoCameraOutlined />} style={{ fontSize: 10, margin: 0 }}>
Video
</Tag>
);
}
return (
<Tag style={{ fontSize: 10, margin: 0 }}>
Text
</Tag>
);
},
},
{
title: 'Variable',
dataIndex: 'key',
width: 120,
render: (key: string) => (
<Text code style={{ fontSize: 11 }}>
{'{{' + key + '}}'}
</Text>
),
},
{
title: 'Label',
dataIndex: 'label',
width: 100,
},
{
title: 'Description',
dataIndex: 'description',
ellipsis: { showTitle: false },
render: (desc: string | null) => (
<Text
type="secondary"
ellipsis={{ tooltip: desc || undefined }}
style={{ fontSize: 12 }}
>
{desc || '—'}
</Text>
),
},
{
title: 'Req',
dataIndex: 'isRequired',
width: 40,
align: 'center',
render: (isRequired: boolean) =>
isRequired ? (
<Tag color="red" style={{ fontSize: 10, margin: 0 }}>
Req
</Tag>
) : null,
},
];
return (
<div
style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 110px)' }}
>
{/* Variables Reference Table - Top Half */}
<div
style={{
flex: '1 1 50%',
display: 'flex',
flexDirection: 'column',
borderBottom: '1px solid #d9d9d9',
overflow: 'hidden',
}}
>
<div style={{ padding: '6px 12px', backgroundColor: '#fafafa', flexShrink: 0 }}>
<Text strong style={{ fontSize: 12 }}>Available Variables</Text>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<Table
dataSource={template.variables}
columns={variableColumns}
rowKey="id"
size="small"
pagination={false}
/>
</div>
</div>
{/* Sample Data Inputs - Bottom Half */}
<div style={{ flex: '1 1 50%', padding: 12, overflow: 'auto' }}>
<Text strong style={{ display: 'block', marginBottom: 8, fontSize: 12 }}>
Sample Data (for preview)
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: template.variables.length > 4 ? '1fr 1fr' : '1fr',
gap: 10,
}}
>
{template.variables.map((v) => {
// VIDEO variables show preview link instead of input
if (v.type === 'VIDEO') {
const mediaApiUrl = import.meta.env.VITE_MEDIA_API_URL || 'http://localhost:4100';
return (
<div key={v.key}>
<Text
type="secondary"
style={{
fontSize: 11,
display: 'block',
marginBottom: 3,
}}
>
{v.label}
</Text>
<Input
size="small"
value={`Video ID: ${v.videoId}`}
disabled
addonAfter={
<Button
size="small"
type="link"
onClick={() => {
window.open(`${mediaApiUrl}/api/videos/${v.videoId}/metadata`, '_blank');
}}
style={{ padding: 0 }}
>
Preview
</Button>
}
/>
</div>
);
}
// TEXT variables show standard input
return (
<div key={v.key}>
<Text
type="secondary"
style={{
fontSize: 11,
display: 'block',
marginBottom: 3,
}}
>
{v.label}
</Text>
<Input
size="small"
value={sampleData[v.key] || ''}
onChange={(e) =>
onSampleDataChange({ ...sampleData, [v.key]: e.target.value })
}
placeholder={v.sampleValue || ''}
/>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,156 @@
import { useState, useEffect } from 'react';
import { Drawer, Timeline, Space, Tag, Typography, Button, Popconfirm, Input, message, Spin } from 'antd';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { EmailTemplateVersion } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const { TextArea } = Input;
interface VersionHistoryDrawerProps {
open: boolean;
templateId: string;
templateName: string;
onClose: () => void;
onRollbackSuccess: () => void;
}
export default function VersionHistoryDrawer({
open,
templateId,
templateName,
onClose,
onRollbackSuccess,
}: VersionHistoryDrawerProps) {
const [versions, setVersions] = useState<EmailTemplateVersion[]>([]);
const [loading, setLoading] = useState(false);
const [rollbackNotes, setRollbackNotes] = useState('');
const [rollingBack, setRollingBack] = useState<number | null>(null);
useEffect(() => {
if (open) {
fetchVersions();
}
}, [open, templateId]);
const fetchVersions = async () => {
setLoading(true);
try {
const { data } = await api.get<EmailTemplateVersion[]>(`/email-templates/${templateId}/versions`);
setVersions(data);
} catch {
message.error('Failed to load version history');
} finally {
setLoading(false);
}
};
const handleRollback = async (versionNumber: number) => {
setRollingBack(versionNumber);
try {
await api.post(`/email-templates/${templateId}/rollback`, {
versionNumber,
changeNotes: rollbackNotes || `Rolled back to version ${versionNumber}`,
});
message.success(`Rolled back to version ${versionNumber}`);
setRollbackNotes('');
onRollbackSuccess();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
'Rollback failed';
message.error(msg);
} finally {
setRollingBack(null);
}
};
return (
<Drawer
title={`Version History: ${templateName}`}
open={open}
onClose={onClose}
width={600}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : (
<Timeline>
{versions.map((version) => (
<Timeline.Item key={version.id}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Space>
<Tag color="blue">v{version.versionNumber}</Tag>
<Text type="secondary">{dayjs(version.createdAt).fromNow()}</Text>
<Text type="secondary">({dayjs(version.createdAt).format('YYYY-MM-DD HH:mm:ss')})</Text>
</Space>
<div style={{ marginTop: 4 }}>
<Text strong>Subject:</Text>
<div style={{ marginTop: 4, padding: 8, backgroundColor: '#f5f5f5', borderRadius: 4 }}>
<Text>{version.subjectLine}</Text>
</div>
</div>
{version.changeNotes && (
<div style={{ marginTop: 4 }}>
<Text strong>Notes:</Text>
<div style={{ marginTop: 4 }}>
<Text italic>{version.changeNotes}</Text>
</div>
</div>
)}
<Space style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Created by {version.createdBy.name || version.createdBy.email}
</Text>
</Space>
<Space style={{ marginTop: 8 }}>
<Popconfirm
title="Rollback to this version?"
description={
<div style={{ marginTop: 8 }}>
<Text>This will create a new version with this content.</Text>
<TextArea
rows={3}
placeholder="Change notes (optional)"
value={rollbackNotes}
onChange={(e) => setRollbackNotes(e.target.value)}
style={{ marginTop: 8 }}
/>
</div>
}
onConfirm={() => handleRollback(version.versionNumber)}
okText="Rollback"
cancelText="Cancel"
>
<Button
size="small"
type="primary"
loading={rollingBack === version.versionNumber}
>
Rollback to v{version.versionNumber}
</Button>
</Popconfirm>
</Space>
</Space>
</Timeline.Item>
))}
</Timeline>
)}
{!loading && versions.length === 0 && (
<div style={{ textAlign: 'center', padding: 40 }}>
<Text type="secondary">No version history available</Text>
</div>
)}
</Drawer>
);
}

View File

@ -0,0 +1,178 @@
import React, { useState } from 'react';
import { Modal, Form, Input, Button, Alert } from 'antd';
import { VideoPickerModal } from '../media/VideoPickerModal';
import type { Video } from '../media/VideoPickerModal';
interface VideoVariableEditorProps {
open: boolean;
onClose: () => void;
onSave: (variable: {
key: string;
label: string;
description: string;
videoId: number;
type: 'VIDEO';
}) => void;
existingKeys: string[];
}
export const VideoVariableEditor: React.FC<VideoVariableEditorProps> = ({
open,
onClose,
onSave,
existingKeys,
}) => {
const [form] = Form.useForm();
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [showVideoPicker, setShowVideoPicker] = useState(false);
const handleSave = async () => {
try {
const values = await form.validateFields();
if (!selectedVideo) {
Modal.error({ content: 'Please select a video' });
return;
}
onSave({
key: values.key.toUpperCase().replace(/\s+/g, '_'),
label: values.label,
description: values.description || '',
videoId: selectedVideo.id,
type: 'VIDEO',
});
form.resetFields();
setSelectedVideo(null);
onClose();
} catch (err) {
console.error('Validation failed:', err);
}
};
const handleVideoSelect = (video: Video) => {
setSelectedVideo(video);
// Auto-fill label if empty
const currentLabel = form.getFieldValue('label');
if (!currentLabel) {
form.setFieldsValue({ label: video.title });
}
// Auto-generate key if empty
const currentKey = form.getFieldValue('key');
if (!currentKey) {
const generatedKey = video.title
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
form.setFieldsValue({ key: generatedKey });
}
};
return (
<>
<Modal
open={open}
onCancel={onClose}
title="Add Video Variable"
width={600}
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button key="save" type="primary" onClick={handleSave}>
Add Variable
</Button>,
]}
>
<Alert
message="Video Variables in Emails"
description="Videos will be displayed as thumbnail images with play buttons that link to the video player. Most email clients don't support embedded video playback."
type="info"
style={{ marginBottom: 16 }}
/>
<Form form={form} layout="vertical">
{/* Video Selection */}
<Form.Item label="Select Video">
{selectedVideo ? (
<div
style={{
padding: 12,
border: '1px solid #d9d9d9',
borderRadius: 8,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>
<strong>{selectedVideo.title}</strong>
<br />
<small style={{ color: '#999' }}>
ID: {selectedVideo.id} {selectedVideo.orientation === 'H' ? 'Horizontal' : 'Vertical'}
</small>
</span>
<Button onClick={() => setShowVideoPicker(true)}>Change</Button>
</div>
) : (
<Button block onClick={() => setShowVideoPicker(true)}>
Choose Video from Library
</Button>
)}
</Form.Item>
{/* Variable Key */}
<Form.Item
name="key"
label="Variable Key"
rules={[
{ required: true, message: 'Please enter a variable key' },
{
pattern: /^[A-Z_]+$/,
message: 'Key must be uppercase letters and underscores only',
},
{
validator: (_, value) => {
if (value && existingKeys.includes(value.toUpperCase())) {
return Promise.reject('This key already exists');
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="VIDEO_INTRO" />
</Form.Item>
{/* Variable Label */}
<Form.Item
name="label"
label="Display Label"
rules={[{ required: true, message: 'Please enter a label' }]}
>
<Input placeholder="Introduction Video" />
</Form.Item>
{/* Description */}
<Form.Item name="description" label="Description (Optional)">
<Input.TextArea
rows={2}
placeholder="Video explaining the campaign goals..."
/>
</Form.Item>
</Form>
</Modal>
{/* Video Picker Modal */}
<VideoPickerModal
open={showVideoPicker}
onClose={() => setShowVideoPicker(false)}
onSelect={handleVideoSelect}
mode="single"
/>
</>
);
};

View File

@ -1,6 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Switch, Space, Typography, message, Spin, Tag, Grid, Result, theme } from 'antd';
import { Button, Switch, Space, Typography, message, Spin, Tag, Grid, Result, theme, App } from 'antd';
import { ArrowLeftOutlined, SaveOutlined, EyeOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { api } from '@/lib/api';
@ -10,9 +9,12 @@ import type { LandingPage, PageBlock } from '@/types/api';
const { Text } = Typography;
export default function PageEditorPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
interface LandingPageEditorProps {
pageId: string;
onClose: () => void;
}
export default function LandingPageEditor({ pageId, onClose }: LandingPageEditorProps) {
const [page, setPage] = useState<LandingPage | null>(null);
const [blocks, setBlocks] = useState<PageBlock[]>([]);
const [loading, setLoading] = useState(true);
@ -22,6 +24,7 @@ export default function PageEditorPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const { modal } = App.useApp();
const isCodeMode = page?.editorMode === 'CODE';
@ -30,12 +33,12 @@ export default function PageEditorPage() {
try {
if (isCodeMode) {
// CODE mode only needs page data
const pageRes = await api.get<LandingPage>(`/pages/${id}`);
const pageRes = await api.get<LandingPage>(`/pages/${pageId}`);
setPage(pageRes.data);
setCodeContent(pageRes.data.htmlOutput || '');
} else {
const [pageRes, blocksRes] = await Promise.all([
api.get<LandingPage>(`/pages/${id}`),
api.get<LandingPage>(`/pages/${pageId}`),
api.get<PageBlock[]>('/page-blocks'),
]);
setPage(pageRes.data);
@ -44,13 +47,13 @@ export default function PageEditorPage() {
}
} catch {
message.error('Failed to load page');
navigate('/app/pages');
onClose();
} finally {
setLoading(false);
}
};
fetchData();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [pageId, isCodeMode, onClose]);
const handleSaveVisual = useCallback(async (data: { projectData: Record<string, unknown>; html: string; css: string }) => {
if (!page) return;
@ -112,9 +115,25 @@ export default function PageEditorPage() {
return () => window.removeEventListener('keydown', handler);
}, [isCodeMode, handleSaveCode]);
const handleClose = () => {
// Check if there are unsaved changes
if (page && isCodeMode && codeContent !== page.htmlOutput) {
modal.confirm({
title: 'Unsaved Changes',
content: 'You have unsaved changes. Are you sure you want to discard them?',
okText: 'Discard',
cancelText: 'Continue Editing',
okType: 'danger',
onOk: () => onClose(),
});
} else {
onClose();
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 'calc(100vh - 64px)' }}>
<Spin size="large" />
</div>
);
@ -124,13 +143,13 @@ export default function PageEditorPage() {
if (isMobile) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 24, background: token.colorBgBase }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: 'calc(100vh - 64px)', padding: 24 }}>
<Result
status="info"
title="Desktop Required"
subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}
extra={
<Button type="primary" onClick={() => navigate('/app/pages')}>
<Button type="primary" onClick={onClose}>
Back to Pages
</Button>
}
@ -140,7 +159,7 @@ export default function PageEditorPage() {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: token.colorBgBase }}>
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', background: token.colorBgBase }}>
{/* Toolbar */}
<div
style={{
@ -158,8 +177,8 @@ export default function PageEditorPage() {
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/pages')}
aria-label="Back to pages list"
onClick={handleClose}
aria-label="Close editor"
style={{ color: '#fff' }}
/>
<Text strong style={{ color: '#fff', fontSize: 16 }}>{page.title}</Text>

View File

@ -2,12 +2,23 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Spin, Checkbox, Button, Typography, message } from 'antd';
import { EditOutlined, DragOutlined } from '@ant-design/icons';
import { MapContainer, CircleMarker, Popup, Marker, useMap, useMapEvents } from 'react-leaflet';
import MarkerClusterGroup from 'react-leaflet-cluster';
import type { Map as LeafletMap } from 'leaflet';
import L from 'leaflet';
import { useDebounce } from '@/hooks/useDebounce';
// Extend Leaflet Map type to include private animation properties
declare module 'leaflet' {
interface Map {
_animatingZoom?: boolean;
_moving?: boolean;
}
}
import { api } from '@/lib/api';
import type { Location, MapSettings, SupportLevel, Cut } from '@/types/api';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
import { groupLocations, getMarkerColor } from './mapUtils';
import { createLocationIcon } from './mapIcons';
import MapLegend from './MapLegend';
import MapControls from './MapControls';
import AddLocationMode from './AddLocationMode';
@ -38,6 +49,32 @@ const homeIcon = L.divIcon({
className: '',
});
// Cluster icon factory for admin map
const createClusterCustomIcon = (cluster: any) => {
const count = cluster.getChildCount();
let size = 'small';
if (count >= 100) size = 'large';
else if (count >= 25) size = 'medium';
return L.divIcon({
html: `<div class="cluster-marker cluster-${size}"><span>${count}</span></div>`,
className: 'custom-cluster-icon',
iconSize: L.point(40, 40, true),
});
};
// Static cluster configuration (extracted to prevent re-mount on zoom changes)
const STATIC_CLUSTER_CONFIG = {
disableClusteringAtZoom: 18,
spiderfyOnMaxZoom: false,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
animate: true,
animateAddingMarkers: false,
removeOutsideVisibleBounds: true,
chunkedLoading: true,
};
interface Props {
locations: Location[];
loading: boolean;
@ -76,19 +113,28 @@ function FullscreenInvalidator() {
return null;
}
function MapEventsHandler({ onMove }: { onMove?: (map: LeafletMap) => void }) {
function MapEventsHandler({
onMove,
setMapInstance,
setCurrentZoom
}: {
onMove?: (map: LeafletMap) => void;
setMapInstance: (map: LeafletMap | null) => void;
setCurrentZoom: (zoom: number) => void;
}) {
const map = useMapEvents({
moveend: () => {
// Only trigger if not animating to prevent Leaflet state corruption
if (!map._animatingZoom && !map._moving) {
onMove?.(map);
setMapInstance(map); // Trigger debounced callback
}
},
zoomend: () => {
setCurrentZoom(map.getZoom()); // Track current zoom
// Wait a tick for Leaflet to finish internal zoom state updates
setTimeout(() => {
if (!map._animatingZoom && !map._moving) {
onMove?.(map);
setMapInstance(map);
}
}, 100);
},
@ -104,6 +150,22 @@ function FlyToPosition({ position }: { position: [number, number] }) {
return null;
}
function CenterOnSettings({ settings }: { settings: MapSettings | null }) {
const map = useMap();
useEffect(() => {
if (!settings?.latitude || !settings?.longitude) return;
const lat = parseFloat(settings.latitude);
const lng = parseFloat(settings.longitude);
const zoom = settings.zoom ?? 12;
// Use setView to imperatively update map center
map.setView([lat, lng], zoom);
}, [map, settings]);
return null;
}
export default function AdminMapView({
locations,
loading,
@ -127,6 +189,11 @@ export default function AdminMapView({
const containerRef = useRef<HTMLDivElement>(null);
const autoRefreshRef = useRef<ReturnType<typeof setInterval>>(undefined);
// Debounced map state for performance
const [mapInstance, setMapInstance] = useState<LeafletMap | null>(null);
const debouncedMapInstance = useDebounce(mapInstance, 1500);
const [currentZoom, setCurrentZoom] = useState(12); // Default zoom, updated from map events
// Cuts state
const [cuts, setCuts] = useState<Cut[]>([]);
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
@ -135,6 +202,20 @@ export default function AdminMapView({
api.get<MapSettings>('/map/settings').then(({ data }) => setSettings(data)).catch(() => {});
}, []);
// Sync currentZoom with settings when they load
useEffect(() => {
if (settings?.zoom) {
setCurrentZoom(settings.zoom);
}
}, [settings?.zoom]);
// Trigger onMapMove when debounced map instance updates
useEffect(() => {
if (debouncedMapInstance && onMapMove) {
onMapMove(debouncedMapInstance);
}
}, [debouncedMapInstance, onMapMove]);
// Fetch cuts
useEffect(() => {
api.get<{ cuts: Cut[] }>('/map/cuts', { params: { limit: 100 } })
@ -172,12 +253,12 @@ export default function AdminMapView({
};
}, []);
const groups = useMemo(() => groupLocations(locations), [locations]);
const groups = useMemo(() => groupLocations(locations as any), [locations]);
const filteredGroups = useMemo(() => {
return groups.filter((g) =>
g.locations.some((loc) => {
const level = loc.supportLevel || 'NONE';
g.location.addresses.some((addr) => {
const level = addr.supportLevel || 'NONE';
return visibleLevels.has(level);
})
);
@ -265,6 +346,203 @@ export default function AdminMapView({
});
}, []);
// Memoized cluster config with zoom-aware radius
const clusterConfig = useMemo(() => {
// Zoom-aware cluster radius (30/50/80px like volunteer map)
const maxClusterRadius = currentZoom >= 15 ? 30 : currentZoom >= 12 ? 50 : 80;
return {
...STATIC_CLUSTER_CONFIG,
maxClusterRadius,
iconCreateFunction: createClusterCustomIcon,
};
}, [currentZoom]);
// Memoized marker rendering to prevent unnecessary re-renders
const markers = useMemo(() => {
return filteredGroups.map((group, idx) => {
const color = getMarkerColor(group.dominantLevel);
const icon = createLocationIcon(color, group.isMultiUnit);
return (
<Marker
key={`${group.location.id}-${idx}`}
position={[group.latitude, group.longitude]}
icon={icon}
>
<Popup>
<div style={{ minWidth: 200, maxWidth: 300 }}>
{group.isMultiUnit ? (
// Multi-unit building display
<>
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: '2px solid #a02c8d' }}>
<div style={{ fontWeight: 600, fontSize: 14, color: '#a02c8d' }}>
🏢 {group.location.address || 'Multi-Unit Building'}
</div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.6)', marginTop: 2 }}>
{group.location.addresses.length} units
</div>
</div>
{group.location.addresses
.sort((a, b) => {
const aUnit = a.unitNumber || '';
const bUnit = b.unitNumber || '';
return aUnit.localeCompare(bUnit, undefined, { numeric: true });
})
.map((addr, i) => {
const name = [addr.firstName, addr.lastName].filter(Boolean).join(' ');
return (
<div
key={addr.id}
style={{
marginBottom: i < group.location.addresses.length - 1 ? 8 : 0,
paddingBottom: i < group.location.addresses.length - 1 ? 8 : 0,
borderBottom: i < group.location.addresses.length - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
}}
>
{addr.unitNumber && (
<div style={{ fontSize: 12, fontWeight: 600, color: '#555', marginBottom: 2 }}>
Unit {addr.unitNumber}
</div>
)}
{name && <div style={{ fontSize: 12, marginBottom: 2 }}>{name}</div>}
{addr.email && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{addr.email}</div>}
{addr.phone && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{addr.phone}</div>}
{addr.supportLevel && (
<div style={{ fontSize: 12, marginTop: 4 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(addr.supportLevel),
marginRight: 4,
}}
/>
{SUPPORT_LEVEL_LABELS[addr.supportLevel]}
</div>
)}
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)', marginTop: 2 }}>
{addr.sign && <>Sign{addr.signSize ? ` (${addr.signSize})` : ''} &middot; </>}
{group.location.geocodeConfidence != null && <>Confidence: {group.location.geocodeConfidence}%</>}
</div>
{addr.notes && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 4, fontStyle: 'italic' }}>
{addr.notes}
</div>
)}
<div style={{ marginTop: 4, display: 'flex', gap: 8 }}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
onEditLocation(group.location);
}}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
>
Edit
</Button>
{onMoveLocation && (
<Button
type="link"
size="small"
icon={<DragOutlined />}
onClick={(e) => {
e.stopPropagation();
startMoveMode(group.location.id);
}}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
>
Move
</Button>
)}
</div>
</div>
);
})}
</>
) : (
// Single unit display
<>
{(() => {
const addr = group.location.addresses[0];
const name = addr ? [addr.firstName, addr.lastName].filter(Boolean).join(' ') : '';
return (
<div>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
{group.location.address || 'Unknown address'}
{addr?.unitNumber && <Text type="secondary" style={{ fontSize: 12 }}> Unit {addr.unitNumber}</Text>}
</div>
{name && <div style={{ fontSize: 12, marginBottom: 2 }}>{name}</div>}
{addr?.email && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{addr.email}</div>}
{addr?.phone && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{addr.phone}</div>}
{addr?.supportLevel && (
<div style={{ fontSize: 12, marginTop: 4 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(addr.supportLevel),
marginRight: 4,
}}
/>
{SUPPORT_LEVEL_LABELS[addr.supportLevel]}
</div>
)}
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)', marginTop: 2 }}>
{addr?.sign && <>Sign{addr.signSize ? ` (${addr.signSize})` : ''} &middot; </>}
{group.location.geocodeConfidence != null && <>Confidence: {group.location.geocodeConfidence}%</>}
</div>
{addr?.notes && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 4, fontStyle: 'italic' }}>
{addr.notes}
</div>
)}
<div style={{ marginTop: 4, display: 'flex', gap: 8 }}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
onEditLocation(group.location);
}}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
>
Edit
</Button>
{onMoveLocation && (
<Button
type="link"
size="small"
icon={<DragOutlined />}
onClick={(e) => {
e.stopPropagation();
startMoveMode(group.location.id);
}}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
>
Move
</Button>
)}
</div>
</div>
);
})()}
</>
)}
</div>
</Popup>
</Marker>
);
});
}, [filteredGroups, onEditLocation, onMoveLocation, startMoveMode]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
@ -277,7 +555,7 @@ export default function AdminMapView({
<div
ref={containerRef}
id="admin-map-container"
style={{ position: 'relative', width: '100%', height: 'calc(100vh - 340px)', minHeight: 500, background: '#1a1025' }}
style={{ position: 'relative', width: '100%', height: 'calc(100vh - 240px)', minHeight: 500, background: '#1a1025' }}
>
{/* Support level filter overlay */}
<div
@ -286,13 +564,14 @@ export default function AdminMapView({
style={{
position: 'absolute',
top: 10,
left: 10,
left: 70,
zIndex: 1000,
background: 'rgba(26, 16, 37, 0.92)',
borderRadius: 8,
padding: '8px 12px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.12)',
maxWidth: 160,
}}
>
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.65)', marginBottom: 4 }}>
@ -365,6 +644,39 @@ export default function AdminMapView({
.admin-map .leaflet-popup-content {
margin: 10px 14px;
}
.custom-cluster-icon {
background: transparent;
}
.cluster-marker {
background-color: rgba(157, 78, 221, 0.8);
border: 2px solid #fff;
border-radius: 50%;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
width: 40px;
height: 40px;
}
.cluster-marker.cluster-medium {
width: 50px;
height: 50px;
font-size: 16px;
background-color: rgba(157, 78, 221, 0.9);
}
.cluster-marker.cluster-large {
width: 60px;
height: 60px;
font-size: 18px;
background-color: rgba(157, 78, 221, 1);
}
.location-icon-marker {
background: transparent;
border: none;
}
`}</style>
<MapContainer
@ -376,7 +688,12 @@ export default function AdminMapView({
>
<InvalidateSizeOnVisible visible={visible} />
<FullscreenInvalidator />
<MapEventsHandler onMove={onMapMove} />
<CenterOnSettings settings={settings} />
<MapEventsHandler
onMove={onMapMove}
setMapInstance={setMapInstance}
setCurrentZoom={setCurrentZoom}
/>
{flyTo && <FlyToPosition position={flyTo} />}
<DynamicTileLayer config={getTileConfig(tileKey)} />
@ -416,104 +733,13 @@ export default function AdminMapView({
/>
)}
{/* Location markers */}
{filteredGroups.map((group, idx) => {
const color = getMarkerColor(group.dominantLevel);
const radius = group.isMultiUnit ? 10 : 7;
return (
<CircleMarker
key={idx}
center={[group.latitude, group.longitude]}
radius={radius}
pathOptions={{
fillColor: color,
fillOpacity: 0.8,
color: '#fff',
weight: group.isMultiUnit ? 2 : 1,
opacity: 0.9,
}}
>
<Popup>
<div style={{ minWidth: 200, maxWidth: 280 }}>
{group.locations.map((loc, i) => {
const name = [loc.firstName, loc.lastName].filter(Boolean).join(' ');
return (
<div
key={loc.id}
style={{
marginBottom: i < group.locations.length - 1 ? 10 : 0,
paddingBottom: i < group.locations.length - 1 ? 10 : 0,
borderBottom: i < group.locations.length - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
}}
>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
{loc.address || 'Unknown address'}
{loc.unitNumber && <Text type="secondary" style={{ fontSize: 12 }}> Unit {loc.unitNumber}</Text>}
</div>
{name && <div style={{ fontSize: 12, marginBottom: 2 }}>{name}</div>}
{loc.email && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{loc.email}</div>}
{loc.phone && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{loc.phone}</div>}
{loc.supportLevel && (
<div style={{ fontSize: 12, marginTop: 4 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(loc.supportLevel),
marginRight: 4,
}}
/>
{SUPPORT_LEVEL_LABELS[loc.supportLevel]}
</div>
)}
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)', marginTop: 2 }}>
{loc.sign && <>Sign{loc.signSize ? ` (${loc.signSize})` : ''} &middot; </>}
{loc.geocodeConfidence != null && <>Confidence: {loc.geocodeConfidence}%</>}
</div>
{loc.notes && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 4, fontStyle: 'italic' }}>
{loc.notes}
</div>
)}
<div style={{ marginTop: 4, display: 'flex', gap: 8 }}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
onEditLocation(loc);
}}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
>
Edit
</Button>
{onMoveLocation && (
<Button
type="link"
size="small"
icon={<DragOutlined />}
onClick={(e) => {
e.stopPropagation();
startMoveMode(loc.id);
}}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
>
Move
</Button>
)}
</div>
</div>
);
})}
</div>
</Popup>
</CircleMarker>
);
})}
{/* Location markers with clustering */}
<MarkerClusterGroup
key={currentZoom >= 18 ? 'unclustered' : 'clustered'}
{...clusterConfig}
>
{markers}
</MarkerClusterGroup>
</MapContainer>
<MapLegend variant="admin" />
@ -521,7 +747,7 @@ export default function AdminMapView({
<TileLayerToggle
activeKey={tileKey}
onChange={(key) => { setTileKey(key); persistTileLayer(key); }}
position="bottom-right"
position="bottom-left"
/>
{/* Cut overlay controls */}
@ -531,6 +757,7 @@ export default function AdminMapView({
visibleCutIds={visibleCutIds}
onToggleCut={toggleCut}
variant="admin"
style={{ top: 180, left: 10, bottom: 'auto' }}
/>
)}
</div>

View File

@ -0,0 +1,586 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Steps,
Button,
Space,
Card,
Checkbox,
Select,
Slider,
InputNumber,
Statistic,
Alert,
Progress,
Tag,
Row,
Col,
Typography,
Spin,
Result,
} from 'antd';
import {
GlobalOutlined,
DatabaseOutlined,
CompassOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
MinusCircleOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type {
Cut,
MapSettings,
AreaImportPreviewResult,
AreaImportProgress,
AreaImportSourceStatus,
} from '@/types/api';
const { Text, Title } = Typography;
interface AreaImportWizardProps {
cuts: Cut[];
onComplete?: () => void;
}
const SOURCE_STATUS_ICONS: Record<AreaImportSourceStatus, React.ReactNode> = {
pending: <MinusCircleOutlined style={{ color: '#8c8c8c' }} />,
running: <LoadingOutlined style={{ color: '#1890ff' }} spin />,
complete: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
skipped: <MinusCircleOutlined style={{ color: '#d9d9d9' }} />,
};
export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardProps) {
const [currentStep, setCurrentStep] = useState(0);
// Step 0: Define area
const [areaType, setAreaType] = useState<'cut' | 'viewport'>('cut');
const [selectedCutId, setSelectedCutId] = useState<string | undefined>();
const [mapSettings, setMapSettings] = useState<MapSettings | null>(null);
const [mapSettingsLoading, setMapSettingsLoading] = useState(false);
// Step 1: Sources
const [osmEnabled, setOsmEnabled] = useState(true);
const [narEnabled, setNarEnabled] = useState(true);
const [narResidentialOnly, setNarResidentialOnly] = useState(true);
const [rgEnabled, setRgEnabled] = useState(false);
const [rgSpacing, setRgSpacing] = useState(100);
const [rgMaxPoints, setRgMaxPoints] = useState(500);
// Step 2: Preview
const [preview, setPreview] = useState<AreaImportPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
// Step 3: Progress
const [progress, setProgress] = useState<AreaImportProgress | null>(null);
const [importing, setImporting] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
// Load map settings for viewport mode
useEffect(() => {
if (areaType === 'viewport' && !mapSettings) {
setMapSettingsLoading(true);
api.get('/map/settings')
.then(({ data }) => setMapSettings(data))
.catch(() => {})
.finally(() => setMapSettingsLoading(false));
}
}, [areaType, mapSettings]);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const buildRequestBody = useCallback(() => {
const sources: Record<string, unknown> = {
osm: osmEnabled,
nar: narEnabled ? { residentialOnly: narResidentialOnly } : false,
reverseGeocode: rgEnabled ? { gridSpacingMeters: rgSpacing, maxPoints: rgMaxPoints } : false,
};
const body: Record<string, unknown> = { sources };
if (areaType === 'cut') {
body.areaType = 'cut';
body.cutId = selectedCutId;
} else {
body.areaType = 'viewport';
body.center = {
lat: mapSettings?.latitude ? Number(mapSettings.latitude) : 53.5,
lng: mapSettings?.longitude ? Number(mapSettings.longitude) : -113.5,
};
body.zoom = mapSettings?.zoom ?? 13;
}
return body;
}, [areaType, selectedCutId, mapSettings, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]);
const fetchPreview = async () => {
setPreviewLoading(true);
setPreviewError(null);
try {
const { data } = await api.post('/map/area-import/preview', buildRequestBody());
setPreview(data);
} catch (err: any) {
setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed');
} finally {
setPreviewLoading(false);
}
};
const startImport = async () => {
setImporting(true);
try {
const body = { ...buildRequestBody(), deduplicateRadius: 5, batchSize: 1000 };
const { data } = await api.post('/map/area-import', body);
const currentImportId = data.importId;
setCurrentStep(3);
// Start polling
pollRef.current = setInterval(async () => {
try {
const { data: prog } = await api.get(`/map/area-import/status/${currentImportId}`);
setProgress(prog);
if (prog.status === 'complete' || prog.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current);
}
} catch {
// Ignore polling errors
}
}, 2000);
} catch (err: any) {
setPreviewError(err?.response?.data?.error?.message || 'Failed to start import');
setImporting(false);
}
};
const canProceedStep0 = areaType === 'cut' ? !!selectedCutId : (!!mapSettings?.latitude && !!mapSettings?.longitude);
const canProceedStep1 = osmEnabled || narEnabled || rgEnabled;
const steps = [
{
title: 'Define Area',
content: (
<div>
<div style={{ marginBottom: 16 }}>
<Text strong>Area Source:</Text>
<Select
value={areaType}
onChange={(val) => setAreaType(val)}
style={{ width: 200, marginLeft: 12 }}
options={[
{ value: 'cut', label: 'From Cut Boundary' },
{ value: 'viewport', label: 'From Map Settings' },
]}
/>
</div>
{areaType === 'cut' && (
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Select a cut polygon to define the import area.
</Text>
<Select
placeholder="Select a cut..."
value={selectedCutId}
onChange={setSelectedCutId}
style={{ width: '100%' }}
showSearch
optionFilterProp="label"
options={cuts.map((c) => ({ value: c.id, label: c.name }))}
/>
</div>
)}
{areaType === 'viewport' && (
<div>
{mapSettingsLoading ? (
<Spin size="small" />
) : mapSettings?.latitude && mapSettings?.longitude ? (
<Card size="small">
<Row gutter={16}>
<Col span={8}>
<Statistic title="Center Lat" value={Number(mapSettings.latitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="Center Lng" value={Number(mapSettings.longitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="Zoom" value={mapSettings.zoom ?? 13} valueStyle={{ fontSize: 16 }} />
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: 'block' }}>
A bounding box will be derived from the map center and zoom level.
</Text>
</Card>
) : (
<Alert
message="Map settings not configured"
description="Please set a center and zoom level in Map Settings first."
type="warning"
showIcon
/>
)}
</div>
)}
</div>
),
},
{
title: 'Sources',
content: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Card
size="small"
style={{ borderColor: osmEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
onClick={() => setOsmEnabled(!osmEnabled)}
>
<Checkbox checked={osmEnabled} onChange={(e) => { e.stopPropagation(); setOsmEnabled(e.target.checked); }}>
<Space>
<GlobalOutlined />
<Text strong>OpenStreetMap (Overpass API)</Text>
</Space>
</Checkbox>
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
Fetches address nodes and building footprints from OSM. Best for urban areas with good community mapping.
</Text>
</Card>
<Card
size="small"
style={{ borderColor: narEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
onClick={() => setNarEnabled(!narEnabled)}
>
<Checkbox checked={narEnabled} onChange={(e) => { e.stopPropagation(); setNarEnabled(e.target.checked); }}>
<Space>
<DatabaseOutlined />
<Text strong>NAR (National Address Register)</Text>
</Space>
</Checkbox>
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
Official Canadian address data. Requires NAR files on server. Highest priority for deduplication.
</Text>
{narEnabled && (
<div style={{ marginLeft: 24, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
<Checkbox checked={narResidentialOnly} onChange={(e) => setNarResidentialOnly(e.target.checked)}>
Residential only
</Checkbox>
</div>
)}
</Card>
<Card
size="small"
style={{ borderColor: rgEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
onClick={() => setRgEnabled(!rgEnabled)}
>
<Checkbox checked={rgEnabled} onChange={(e) => { e.stopPropagation(); setRgEnabled(e.target.checked); }}>
<Space>
<CompassOutlined />
<Text strong>Reverse Geocode Grid</Text>
</Space>
</Checkbox>
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
Lays a grid of points and reverse geocodes each one. Slow but fills gaps not covered by other sources. Low confidence (40).
</Text>
{rgEnabled && (
<div style={{ marginLeft: 24, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text style={{ fontSize: 12 }}>Grid spacing (meters):</Text>
<Slider min={20} max={500} step={10} value={rgSpacing} onChange={setRgSpacing} />
</div>
<div>
<Text style={{ fontSize: 12 }}>Max points: </Text>
<InputNumber min={10} max={2000} value={rgMaxPoints} onChange={(v) => v && setRgMaxPoints(v)} size="small" />
</div>
</Space>
</div>
)}
</Card>
{!canProceedStep1 && (
<Alert message="Select at least one source to continue." type="info" showIcon />
)}
</div>
),
},
{
title: 'Preview',
content: (
<div>
{previewLoading && (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin size="large" />
<div style={{ marginTop: 12 }}>
<Text type="secondary">Estimating import size...</Text>
</div>
</div>
)}
{previewError && (
<Alert message="Preview Error" description={previewError} type="error" showIcon style={{ marginBottom: 16 }} />
)}
{preview && !previewLoading && (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card size="small">
<Statistic title="Area" value={preview.areaSqKm.toFixed(2)} suffix="km2" valueStyle={{ fontSize: 16 }} />
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic title="Existing Locations" value={preview.existingLocations} valueStyle={{ fontSize: 16 }} />
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic
title="Est. Total"
value={
(preview.estimates.osm >= 0 ? preview.estimates.osm : 0) +
preview.estimates.nar +
preview.estimates.reverseGeocode
}
valueStyle={{ fontSize: 16 }}
/>
</Card>
</Col>
</Row>
<Card size="small" title="Estimated Candidates by Source" style={{ marginBottom: 16 }}>
<Row gutter={16}>
{osmEnabled && (
<Col span={8}>
<Statistic
title={<Space><GlobalOutlined /> OSM</Space>}
value={preview.estimates.osm >= 0 ? preview.estimates.osm : '?'}
valueStyle={{ fontSize: 16 }}
/>
</Col>
)}
{narEnabled && (
<Col span={8}>
<Statistic
title={<Space><DatabaseOutlined /> NAR</Space>}
value={preview.estimates.nar}
suffix={preview.narProvincesDetected.length > 0 ? '' : undefined}
valueStyle={{ fontSize: 16 }}
/>
{preview.narProvincesDetected.length > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}>
Provinces: {preview.narProvincesDetected.join(', ')}
</Text>
)}
{preview.narProvincesDetected.length === 0 && (
<Text type="warning" style={{ fontSize: 11 }}>No NAR data for this area</Text>
)}
</Col>
)}
{rgEnabled && (
<Col span={8}>
<Statistic
title={<Space><CompassOutlined /> Rev. Geocode</Space>}
value={preview.estimates.reverseGeocode}
suffix="points"
valueStyle={{ fontSize: 16 }}
/>
</Col>
)}
</Row>
</Card>
{(preview.estimates.osm + preview.estimates.nar + preview.estimates.reverseGeocode) > 10000 && (
<Alert
message="Large Import"
description="Estimated candidates exceed 10,000. This may take a while. Consider narrowing the area or disabling reverse geocode."
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{preview.areaSqKm > 100 && osmEnabled && (
<Alert
message="Large OSM Query"
description={`Area is ${preview.areaSqKm.toFixed(0)} km2. Large Overpass queries may be slow or fail. Consider using a private Overpass instance.`}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Button
type="primary"
size="large"
block
onClick={startImport}
loading={importing}
>
Start Import
</Button>
</>
)}
</div>
),
},
{
title: 'Progress',
content: (
<div>
{progress ? (
<>
{progress.status === 'complete' ? (
<Result
status="success"
title="Import Complete"
subTitle={`${progress.locationsCreated} locations and ${progress.addressesCreated} addresses created`}
extra={[
<Button key="done" type="primary" onClick={() => onComplete?.()}>
Done
</Button>,
]}
/>
) : progress.status === 'failed' ? (
<Result
status="error"
title="Import Failed"
subTitle={progress.error || 'An unknown error occurred'}
extra={[
<Button key="back" onClick={() => { setCurrentStep(2); setImporting(false); }}>
Back to Preview
</Button>,
]}
/>
) : (
<>
<Title level={5} style={{ marginBottom: 16 }}>
{progress.status === 'initializing' ? 'Initializing...' :
progress.status === 'creating-records' ? 'Creating records...' : 'Running sources...'}
</Title>
<Card size="small" title="Source Progress" style={{ marginBottom: 16 }}>
{(['osm', 'nar', 'reverseGeocode'] as const).map((source) => {
const sp = progress.sources[source];
const labels = { osm: 'OpenStreetMap', nar: 'NAR', reverseGeocode: 'Reverse Geocode' };
return (
<div key={source} style={{ marginBottom: 8 }}>
<Space>
{SOURCE_STATUS_ICONS[sp.status]}
<Text strong>{labels[source]}</Text>
<Tag>{sp.status}</Tag>
{sp.candidatesFound > 0 && (
<Text type="secondary">{sp.candidatesFound} found</Text>
)}
</Space>
{sp.message && (
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
{sp.message}
</Text>
)}
{sp.error && (
<Text type="danger" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
{sp.error}
</Text>
)}
</div>
);
})}
</Card>
<Row gutter={16}>
<Col span={8}>
<Statistic title="Locations Created" value={progress.locationsCreated} valueStyle={{ fontSize: 16, color: '#52c41a' }} />
</Col>
<Col span={8}>
<Statistic title="Addresses Created" value={progress.addressesCreated} valueStyle={{ fontSize: 16, color: '#52c41a' }} />
</Col>
<Col span={8}>
<Statistic title="Duplicates Skipped" value={progress.skippedDuplicate} valueStyle={{ fontSize: 16, color: '#faad14' }} />
</Col>
</Row>
{progress.status === 'creating-records' && progress.totalCandidates > 0 && (
<Progress
percent={Math.round((progress.locationsCreated / progress.totalCandidates) * 100)}
style={{ marginTop: 16 }}
status="active"
/>
)}
</>
)}
</>
) : (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin size="large" />
<div style={{ marginTop: 12 }}>
<Text type="secondary">Starting import...</Text>
</div>
</div>
)}
</div>
),
},
];
const handleNext = () => {
if (currentStep === 1) {
// Moving to preview step — fetch preview
setCurrentStep(2);
// Fetch preview after state update
setTimeout(() => fetchPreview(), 0);
} else {
setCurrentStep(currentStep + 1);
}
};
return (
<div>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={steps.map((s) => ({ title: s.title }))}
/>
<div style={{ minHeight: 200 }}>
{steps[currentStep]?.content}
</div>
{currentStep < 2 && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'space-between' }}>
<Button disabled={currentStep === 0} onClick={() => setCurrentStep(currentStep - 1)}>
Back
</Button>
<Button
type="primary"
onClick={handleNext}
disabled={currentStep === 0 ? !canProceedStep0 : !canProceedStep1}
>
Next
</Button>
</div>
)}
{currentStep === 2 && !previewLoading && !preview && !previewError && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'space-between' }}>
<Button onClick={() => setCurrentStep(1)}>Back</Button>
<Button type="primary" onClick={fetchPreview}>Load Preview</Button>
</div>
)}
{currentStep === 2 && (preview || previewError) && !importing && (
<div style={{ marginTop: 16 }}>
<Button onClick={() => { setCurrentStep(1); setPreview(null); setPreviewError(null); }}>Back</Button>
</div>
)}
</div>
);
}

View File

@ -24,6 +24,22 @@ function InvalidateOnMount() {
return null;
}
function CenterOnSettings({ settings }: { settings: MapSettings | null }) {
const map = useMap();
useEffect(() => {
if (!settings?.latitude || !settings?.longitude) return;
const lat = parseFloat(settings.latitude);
const lng = parseFloat(settings.longitude);
const zoom = settings.zoom ?? 12;
// Use setView to imperatively update map center
map.setView([lat, lng], zoom);
}, [map, settings]);
return null;
}
export default function CutEditorMap({ cuts, onFinishDraw }: Props) {
const [settings, setSettings] = useState<MapSettings | null>(null);
const [drawing, setDrawing] = useState(false);
@ -41,7 +57,7 @@ export default function CutEditorMap({ cuts, onFinishDraw }: Props) {
const allCutIds = new Set(cuts.map((c) => c.id));
return (
<div style={{ position: 'relative', width: '100%', height: 'calc(100vh - 200px)', minHeight: 400 }}>
<div style={{ position: 'relative', width: '100%', height: 'calc(100vh - 64px)' }}>
{/* Drawing toolbar */}
<div
style={{
@ -99,6 +115,7 @@ export default function CutEditorMap({ cuts, onFinishDraw }: Props) {
className="cut-editor-map"
>
<InvalidateOnMount />
<CenterOnSettings settings={settings} />
<DynamicTileLayer config={getTileConfig(tileKey)} />
<CutOverlays cuts={cuts} visibleCutIds={allCutIds} />
<CutDrawingMode

View File

@ -1,6 +1,7 @@
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
import type { SupportLevel } from '@/types/api';
import { NO_LEVEL_COLOR } from './mapUtils';
import { houseSvg, apartmentSvg } from './mapIcons';
const entries: { level: SupportLevel; label: string; color: string }[] = [
{ level: 'LEVEL_1', label: SUPPORT_LEVEL_LABELS.LEVEL_1, color: SUPPORT_LEVEL_COLORS.LEVEL_1 },
@ -32,8 +33,21 @@ export default function MapLegend({ variant = 'public' }: Props) {
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.12)',
minWidth: 140,
maxWidth: 180,
}}
>
{/* Building type icons */}
<div style={{ display: 'flex', gap: 12, marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid rgba(255,255,255,0.15)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div dangerouslySetInnerHTML={{ __html: houseSvg('#888', 20) }} />
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.85)' }}>Single</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div dangerouslySetInnerHTML={{ __html: apartmentSvg('#888', 20) }} />
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.85)' }}>Multi-Unit</span>
</div>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: '#fff', marginBottom: 6 }}>
Support Level
</div>

View File

@ -5,6 +5,7 @@ interface Props {
activeKey: string;
onChange: (key: string) => void;
position?: 'bottom-left' | 'bottom-right';
style?: React.CSSProperties;
}
const icons: Record<string, React.ReactNode> = {
@ -13,10 +14,10 @@ const icons: Record<string, React.ReactNode> = {
satellite: <GlobalOutlined style={{ transform: 'rotate(45deg)' }} />,
};
export default function TileLayerToggle({ activeKey, onChange, position = 'bottom-right' }: Props) {
export default function TileLayerToggle({ activeKey, onChange, position = 'bottom-right', style }: Props) {
const posStyle = position === 'bottom-left'
? { left: 10, bottom: 80 }
: { right: 10, bottom: 80 };
? { left: 10, bottom: 16 }
: { right: 10, bottom: 140 }; // Increased from 80 to 140 for legend clearance
return (
<div
@ -27,6 +28,7 @@ export default function TileLayerToggle({ activeKey, onChange, position = 'botto
display: 'flex',
flexDirection: 'column',
gap: 4,
...style,
}}
>
{TILE_LAYERS.map((layer) => (

View File

@ -0,0 +1,68 @@
import L from 'leaflet';
/**
* SVG icon for single-unit houses
* Shows a peaked roof with walls and door
*/
export function houseSvg(color: string, size: number = 26): string {
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
<!-- House with peaked roof -->
<path d="M12 3L4 10v10a1 1 0 001 1h4v-6h6v6h4a1 1 0 001-1V10z"
fill="${color}"
stroke="rgba(0,0,0,0.3)"
stroke-width="0.5"/>
<path d="M12 3L4 10h16z"
fill="${color}"
stroke="rgba(0,0,0,0.3)"
stroke-width="0.5"/>
</svg>
`;
}
/**
* SVG icon for multi-unit apartment buildings
* Shows a rectangular building with windows and door
*/
export function apartmentSvg(color: string, size: number = 26): string {
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
<!-- Rectangular building -->
<rect x="4" y="3" width="16" height="18" rx="1"
fill="${color}"
stroke="rgba(0,0,0,0.3)"
stroke-width="0.5"/>
<!-- Windows grid (2x2) -->
<rect x="7" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="14" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="7" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="14" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<!-- Door -->
<rect x="10" y="16" width="4" height="5" rx="0.3" fill="rgba(255,255,255,0.4)"/>
</svg>
`;
}
/**
* Creates a Leaflet DivIcon for a location marker
* @param color - Fill color for the icon (based on support level)
* @param isMultiUnit - Whether this is a multi-unit building (apartment) or single-unit (house)
* @param size - Icon size in pixels (default 26)
*/
export function createLocationIcon(
color: string,
isMultiUnit: boolean,
size: number = 26
): L.DivIcon {
const svgHtml = isMultiUnit
? apartmentSvg(color, size)
: houseSvg(color, size);
return L.divIcon({
html: svgHtml,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -size / 2],
className: 'location-icon-marker',
});
}

View File

@ -1,4 +1,4 @@
import type { Location, SupportLevel } from '@/types/api';
import type { Location, Address, SupportLevel } from '@/types/api';
import { SUPPORT_LEVEL_COLORS } from '@/types/api';
export const NO_LEVEL_COLOR = '#3498db';
@ -8,50 +8,57 @@ export function getMarkerColor(level: SupportLevel | null): string {
return SUPPORT_LEVEL_COLORS[level] ?? NO_LEVEL_COLOR;
}
// Location with addresses for map display
export interface GroupableLocation extends Location {
addresses: Address[];
}
// Get the dominant support level from all addresses in a location
function getDominantSupportLevel(addresses: Address[]): SupportLevel | null {
if (addresses.length === 0) return null;
const levelCounts: Record<string, number> = {};
for (const addr of addresses) {
const level = addr.supportLevel || 'NONE';
levelCounts[level] = (levelCounts[level] || 0) + 1;
}
let dominant: SupportLevel | null = null;
let maxCount = 0;
for (const [level, count] of Object.entries(levelCounts)) {
if (count > maxCount) {
maxCount = count;
dominant = level === 'NONE' ? null : (level as SupportLevel);
}
}
return dominant;
}
export interface LocationGroup {
latitude: number;
longitude: number;
locations: Location[];
location: GroupableLocation;
isMultiUnit: boolean;
dominantLevel: SupportLevel | null;
}
export function groupLocations(locations: Location[]): LocationGroup[] {
const groups = new Map<string, Location[]>();
export function groupLocations(locations: GroupableLocation[]): LocationGroup[] {
return locations
.filter(loc => loc.latitude != null && loc.longitude != null)
.map(loc => {
const lat = typeof loc.latitude === 'string' ? parseFloat(loc.latitude) : loc.latitude!;
const lng = typeof loc.longitude === 'string' ? parseFloat(loc.longitude) : loc.longitude!;
for (const loc of locations) {
if (loc.latitude == null || loc.longitude == null) continue;
const key = `${parseFloat(loc.latitude).toFixed(6)},${parseFloat(loc.longitude).toFixed(6)}`;
const existing = groups.get(key);
if (existing) {
existing.push(loc);
} else {
groups.set(key, [loc]);
}
}
// Defensive: ensure addresses array exists
const addresses = Array.isArray(loc.addresses) ? loc.addresses : [];
return Array.from(groups.entries()).map(([key, locs]) => {
const [lat, lng] = key.split(',');
const levelCounts: Record<string, number> = {};
for (const loc of locs) {
const level = loc.supportLevel || 'NONE';
levelCounts[level] = (levelCounts[level] || 0) + 1;
}
let dominant: SupportLevel | null = null;
let maxCount = 0;
for (const [level, count] of Object.entries(levelCounts)) {
if (count > maxCount) {
maxCount = count;
dominant = level === 'NONE' ? null : (level as SupportLevel);
}
}
return {
latitude: parseFloat(lat!),
longitude: parseFloat(lng!),
locations: locs,
isMultiUnit: locs.length > 1,
dominantLevel: dominant,
};
});
return {
latitude: lat,
longitude: lng,
location: loc,
isMultiUnit: addresses.length > 1,
dominantLevel: getDominantSupportLevel(addresses),
};
});
}

View File

@ -0,0 +1,242 @@
import { useState, useEffect } from 'react';
import { Modal, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PlaylistSummary } from '@/types/media';
const { Text } = Typography;
interface AddToPlaylistModalProps {
videoId: number;
open: boolean;
onClose: () => void;
}
interface PlaylistWithSelected extends PlaylistSummary {
hasVideo: boolean;
}
export default function AddToPlaylistModal({
videoId,
open,
onClose,
}: AddToPlaylistModalProps) {
const { token } = theme.useToken();
const [playlists, setPlaylists] = useState<PlaylistWithSelected[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [selections, setSelections] = useState<Record<number, boolean>>({});
// Inline create state
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
// Fetch user's playlists and check which ones contain the video
useEffect(() => {
if (!open) return;
const fetchPlaylists = async () => {
try {
setLoading(true);
const { data } = await mediaApi.get('/playlists/my');
const userPlaylists: PlaylistSummary[] = data.data || [];
// For each playlist, check if it contains the video
const withSelection = await Promise.all(
userPlaylists.map(async (p) => {
try {
const { data: detail } = await mediaPublicApi.get(
`/playlists/${p.id}`
);
const hasVideo = (detail.videos || []).some(
(v: any) => v.mediaId === videoId
);
return { ...p, hasVideo };
} catch {
return { ...p, hasVideo: false };
}
})
);
setPlaylists(withSelection);
// Initialize selections from current state
const initial: Record<number, boolean> = {};
withSelection.forEach((p) => {
initial[p.id] = p.hasVideo;
});
setSelections(initial);
} catch {
message.error('Failed to load playlists');
} finally {
setLoading(false);
}
};
fetchPlaylists();
}, [open, videoId]);
const handleToggle = (playlistId: number, checked: boolean) => {
setSelections((prev) => ({ ...prev, [playlistId]: checked }));
};
const handleSave = async () => {
try {
setSaving(true);
const promises: Promise<any>[] = [];
for (const playlist of playlists) {
const wasInPlaylist = playlist.hasVideo;
const shouldBeInPlaylist = selections[playlist.id];
if (shouldBeInPlaylist && !wasInPlaylist) {
// Add to playlist
promises.push(
mediaApi.post(`/playlists/${playlist.id}/videos`, {
mediaId: videoId,
})
);
} else if (!shouldBeInPlaylist && wasInPlaylist) {
// Remove from playlist
promises.push(
mediaApi.delete(`/playlists/${playlist.id}/videos/${videoId}`)
);
}
}
await Promise.all(promises);
message.success('Playlists updated');
onClose();
} catch {
message.error('Failed to update playlists');
} finally {
setSaving(false);
}
};
const handleCreateNew = async () => {
if (!newName.trim()) return;
try {
setCreating(true);
const { data } = await mediaApi.post('/playlists/', {
name: newName.trim(),
isPublic: false,
});
// Add video to the new playlist
await mediaApi.post(`/playlists/${data.id}/videos`, { mediaId: videoId });
message.success(`Created "${data.name}" and added video`);
setNewName('');
setShowCreate(false);
// Refresh the list
setPlaylists((prev) => [
...prev,
{ ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
]);
setSelections((prev) => ({ ...prev, [data.id]: true }));
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else {
message.error('Failed to create playlist');
}
} finally {
setCreating(false);
}
};
return (
<Modal
title="Add to Playlist"
open={open}
onOk={handleSave}
onCancel={onClose}
confirmLoading={saving}
okText="Save"
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
) : (
<>
{playlists.length === 0 && !showCreate ? (
<div style={{ textAlign: 'center', padding: 24 }}>
<UnorderedListOutlined
style={{ fontSize: 36, color: token.colorTextSecondary, marginBottom: 12 }}
/>
<Text
type="secondary"
style={{ display: 'block', marginBottom: 16 }}
>
You don't have any playlists yet
</Text>
</div>
) : (
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{playlists.map((p) => (
<div
key={p.id}
style={{
padding: '8px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
}}
>
<Checkbox
checked={selections[p.id] ?? false}
onChange={(e) => handleToggle(p.id, e.target.checked)}
>
<Space>
<Text>{p.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
({p.videoCount} videos)
</Text>
</Space>
</Checkbox>
</div>
))}
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{showCreate ? (
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="New playlist name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onPressEnter={handleCreateNew}
maxLength={100}
autoFocus
/>
<Button
type="primary"
onClick={handleCreateNew}
loading={creating}
disabled={!newName.trim()}
>
Create
</Button>
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
</Space.Compact>
) : (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setShowCreate(true)}
block
>
Create New Playlist
</Button>
)}
</>
)}
</Modal>
);
}

View File

@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { Button, message, Tooltip } from 'antd';
import {
LikeOutlined,
HeartOutlined,
SmileOutlined,
FrownOutlined,
ThunderboltOutlined,
FireOutlined,
LikeFilled,
HeartFilled,
SmileFilled,
FrownFilled,
ThunderboltFilled,
FireFilled,
} from '@ant-design/icons';
import { VideoPlayer, VideoPlayerProps, VideoMetadata } from './VideoPlayer';
import { mediaApi } from '../../lib/media-api';
export interface AdvancedVideoPlayerProps extends VideoPlayerProps {
showReactions?: boolean;
showComments?: boolean;
userId?: string | number;
onReactionChange?: (reactionType: string, isActive: boolean) => void;
}
interface ReactionType {
type: string;
icon: React.ReactNode;
iconFilled: React.ReactNode;
color: string;
label: string;
}
const REACTIONS: ReactionType[] = [
{
type: 'like',
icon: <LikeOutlined />,
iconFilled: <LikeFilled />,
color: '#1890ff',
label: 'Like',
},
{
type: 'love',
icon: <HeartOutlined />,
iconFilled: <HeartFilled />,
color: '#eb2f96',
label: 'Love',
},
{
type: 'laugh',
icon: <SmileOutlined />,
iconFilled: <SmileFilled />,
color: '#faad14',
label: 'Laugh',
},
{
type: 'wow',
icon: <ThunderboltOutlined />,
iconFilled: <ThunderboltFilled />,
color: '#52c41a',
label: 'Wow',
},
{
type: 'sad',
icon: <FrownOutlined />,
iconFilled: <FrownFilled />,
color: '#8c8c8c',
label: 'Sad',
},
{
type: 'angry',
icon: <FireOutlined />,
iconFilled: <FireFilled />,
color: '#ff4d4f',
label: 'Angry',
},
];
interface ReactionCounts {
[key: string]: number;
}
interface UserReactions {
[key: string]: boolean;
}
/**
* Advanced video player with reaction system
* Includes all standard player features plus reaction buttons and counts
*/
export const AdvancedVideoPlayer: React.FC<AdvancedVideoPlayerProps> = ({
videoId,
showReactions = true,
showComments = false,
userId,
onReactionChange,
...playerProps
}) => {
const [reactionCounts, setReactionCounts] = useState<ReactionCounts>({});
const [userReactions, setUserReactions] = useState<UserReactions>({});
const [loadingReaction, setLoadingReaction] = useState<string | null>(null);
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
useEffect(() => {
if (showReactions) {
fetchReactions();
}
}, [videoId, showReactions]);
const fetchReactions = async () => {
try {
// Fetch reaction counts
const response = await mediaApi.get(`/api/reactions/${videoId}/counts`);
setReactionCounts(response.data || {});
// Fetch user's reactions if userId provided
if (userId) {
const userResponse = await mediaApi.get(`/api/reactions/${videoId}/user/${userId}`);
const userReactionData = userResponse.data || [];
// Convert array to object for easier lookup
const reactions: UserReactions = {};
userReactionData.forEach((reaction: { reactionType: string }) => {
reactions[reaction.reactionType] = true;
});
setUserReactions(reactions);
}
} catch (error) {
console.error('Failed to fetch reactions:', error);
}
};
const handleReaction = async (reactionType: string) => {
if (!userId) {
message.warning('Please log in to react to videos');
return;
}
setLoadingReaction(reactionType);
try {
const isCurrentlyActive = userReactions[reactionType] || false;
if (isCurrentlyActive) {
// Remove reaction
await mediaApi.delete(`/api/reactions/${videoId}`, {
data: { userId, reactionType },
});
setUserReactions((prev) => ({
...prev,
[reactionType]: false,
}));
setReactionCounts((prev) => ({
...prev,
[reactionType]: Math.max(0, (prev[reactionType] || 0) - 1),
}));
if (onReactionChange) {
onReactionChange(reactionType, false);
}
} else {
// Add reaction
await mediaApi.post(`/api/reactions`, {
videoId,
userId,
reactionType,
});
setUserReactions((prev) => ({
...prev,
[reactionType]: true,
}));
setReactionCounts((prev) => ({
...prev,
[reactionType]: (prev[reactionType] || 0) + 1,
}));
if (onReactionChange) {
onReactionChange(reactionType, true);
}
}
} catch (error) {
console.error('Failed to update reaction:', error);
message.error('Failed to update reaction. Please try again.');
} finally {
setLoadingReaction(null);
}
};
const handleMetadataLoaded = (meta: VideoMetadata) => {
setMetadata(meta);
if (playerProps.onLoadedMetadata) {
playerProps.onLoadedMetadata(meta);
}
};
return (
<div style={{ width: playerProps.width || '100%' }}>
{/* Video Player */}
<VideoPlayer
{...playerProps}
videoId={videoId}
onLoadedMetadata={handleMetadataLoaded}
/>
{/* Reactions Bar */}
{showReactions && (
<div
style={{
marginTop: 12,
padding: '8px 12px',
background: '#fafafa',
borderRadius: 8,
display: 'flex',
gap: 8,
flexWrap: 'wrap',
alignItems: 'center',
}}
>
{REACTIONS.map((reaction) => {
const count = reactionCounts[reaction.type] || 0;
const isActive = userReactions[reaction.type] || false;
const isLoading = loadingReaction === reaction.type;
return (
<Tooltip key={reaction.type} title={reaction.label}>
<Button
type={isActive ? 'primary' : 'default'}
icon={isActive ? reaction.iconFilled : reaction.icon}
loading={isLoading}
disabled={!userId}
onClick={() => handleReaction(reaction.type)}
style={{
borderColor: isActive ? reaction.color : undefined,
background: isActive ? reaction.color : undefined,
color: isActive ? '#fff' : reaction.color,
}}
>
{count > 0 && <span style={{ marginLeft: 4 }}>{count}</span>}
</Button>
</Tooltip>
);
})}
{/* Video Info */}
{metadata && (
<div
style={{
marginLeft: 'auto',
fontSize: 12,
color: '#999',
display: 'flex',
gap: 12,
}}
>
{metadata.orientation && (
<span>
{metadata.width}×{metadata.height}
</span>
)}
{metadata.durationSeconds && (
<span>
{Math.floor(metadata.durationSeconds / 60)}:
{String(metadata.durationSeconds % 60).padStart(2, '0')}
</span>
)}
</div>
)}
</div>
)}
{/* Comments Section (Future) */}
{showComments && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
<p style={{ margin: 0, color: '#999', textAlign: 'center' }}>
Comments coming soon...
</p>
</div>
)}
</div>
);
};
export default AdvancedVideoPlayer;

View File

@ -0,0 +1,116 @@
import { LineChart, Line, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { theme } from 'antd';
const { useToken } = theme;
interface AnalyticsChartProps {
type: 'line' | 'area' | 'bar' | 'pie';
data: any[];
dataKey: string;
xKey?: string;
title?: string;
color?: string;
height?: number;
}
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d'];
export default function AnalyticsChart({
type,
data,
dataKey,
xKey = 'name',
title,
color = '#1890ff',
height = 300,
}: AnalyticsChartProps) {
const { token } = useToken();
if (!data || data.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 48, color: '#999' }}>
No data available
</div>
);
}
const commonProps = {
data,
margin: { top: 5, right: 30, left: 20, bottom: 5 },
};
const renderChart = () => {
switch (type) {
case 'line':
return (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xKey} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey={dataKey} stroke={color} activeDot={{ r: 8 }} />
</LineChart>
);
case 'area':
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xKey} />
<YAxis />
<Tooltip />
<Legend />
<Area type="monotone" dataKey={dataKey} stroke={color} fill={color} fillOpacity={0.6} />
</AreaChart>
);
case 'bar':
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xKey} />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey={dataKey} fill={color} />
</BarChart>
);
case 'pie':
return (
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill={color}
dataKey={dataKey}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
);
default:
return null;
}
};
return (
<div>
{title && (
<h4 style={{ marginBottom: 16, textAlign: 'center' }}>{title}</h4>
)}
<ResponsiveContainer width="100%" height={height}>
{renderChart()}
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { Space, Button } from 'antd';
import type { ReactNode } from 'react';
interface BulkAction {
key: string;
label: string;
icon?: ReactNode;
onClick: () => void;
danger?: boolean;
type?: 'primary' | 'default';
}
interface BulkActionsBarProps {
selectedCount: number;
actions: BulkAction[];
}
export default function BulkActionsBar({ selectedCount, actions }: BulkActionsBarProps) {
if (selectedCount === 0) return null;
return (
<div
style={{
position: 'fixed',
bottom: 24,
left: '50%',
transform: 'translateX(-50%)',
background: '#1f1f1f',
border: '1px solid #434343',
borderRadius: 8,
padding: '12px 24px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)',
zIndex: 1000,
}}
>
<Space size="middle">
<span style={{ fontWeight: 500 }}>{selectedCount} selected</span>
{actions.map((action) => (
<Button
key={action.key}
type={action.type}
danger={action.danger}
icon={action.icon}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
);
}

View File

@ -0,0 +1,188 @@
import { useState, useEffect } from 'react';
import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import type { PlaylistSummary } from '@/types/media';
const { Text } = Typography;
interface BulkAddToPlaylistModalProps {
videoIds: number[];
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export default function BulkAddToPlaylistModal({
videoIds,
open,
onClose,
onSuccess,
}: BulkAddToPlaylistModalProps) {
const [playlists, setPlaylists] = useState<PlaylistSummary[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedPlaylistId, setSelectedPlaylistId] = useState<number | null>(null);
// Inline create state
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!open) return;
const fetchPlaylists = async () => {
try {
setLoading(true);
const { data } = await mediaApi.get('/playlists/my');
setPlaylists(data.data || []);
} catch {
message.error('Failed to load playlists');
} finally {
setLoading(false);
}
};
fetchPlaylists();
setSelectedPlaylistId(null);
setShowCreate(false);
setNewName('');
}, [open]);
const handleAdd = async () => {
if (!selectedPlaylistId || videoIds.length === 0) return;
try {
setSaving(true);
let added = 0;
let skipped = 0;
for (const mediaId of videoIds) {
try {
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
added++;
} catch (error: any) {
if (error.response?.status === 409) {
skipped++;
} else {
throw error;
}
}
}
const parts: string[] = [];
if (added > 0) parts.push(`${added} video${added > 1 ? 's' : ''} added`);
if (skipped > 0) parts.push(`${skipped} already in playlist`);
message.success(parts.join(', '));
onSuccess?.();
} catch {
message.error('Failed to add videos to playlist');
} finally {
setSaving(false);
}
};
const handleCreateNew = async () => {
if (!newName.trim()) return;
try {
setCreating(true);
const { data } = await mediaApi.post('/playlists/', {
name: newName.trim(),
isPublic: false,
});
setPlaylists((prev) => [...prev, data]);
setSelectedPlaylistId(data.id);
setNewName('');
setShowCreate(false);
message.success(`Created "${data.name}"`);
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else {
message.error('Failed to create playlist');
}
} finally {
setCreating(false);
}
};
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
return (
<Modal
title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`}
open={open}
onOk={handleAdd}
onCancel={onClose}
confirmLoading={saving}
okText="Add"
okButtonProps={{ disabled: !selectedPlaylistId }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
) : (
<>
<Select
placeholder="Select a playlist"
value={selectedPlaylistId}
onChange={setSelectedPlaylistId}
style={{ width: '100%', marginBottom: 12 }}
options={playlists.map((p) => ({
value: p.id,
label: `${p.name} (${p.videoCount} videos)`,
}))}
showSearch
filterOption={(input, option) =>
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
{selectedPlaylist && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
{selectedPlaylist.isPublic ? 'Public' : 'Private'} playlist
{selectedPlaylist.videoCount > 0 && ` with ${selectedPlaylist.videoCount} videos`}
</Text>
)}
<Divider style={{ margin: '12px 0' }} />
{showCreate ? (
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="New playlist name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onPressEnter={handleCreateNew}
maxLength={100}
autoFocus
/>
<Button
type="primary"
onClick={handleCreateNew}
loading={creating}
disabled={!newName.trim()}
>
Create
</Button>
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
</Space.Compact>
) : (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setShowCreate(true)}
block
>
Create New Playlist
</Button>
)}
</>
)}
</Modal>
);
}

View File

@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react';
import { notification, Button, Space, Typography } from 'antd';
import { MessageOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import type { ChatNotification } from '@/hooks/useChatNotifications';
const { Text } = Typography;
interface ChatNotificationToastProps {
notifications: ChatNotification[];
clearNotification: (id: string) => void;
}
export default function ChatNotificationToast({
notifications,
clearNotification,
}: ChatNotificationToastProps) {
const [api, contextHolder] = notification.useNotification();
const navigate = useNavigate();
const shownRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const notif of notifications) {
if (shownRef.current.has(notif.id)) continue;
shownRef.current.add(notif.id);
api.info({
key: notif.id,
message: (
<Space size={4}>
<MessageOutlined />
<Text strong>{notif.commenterName}</Text>
<Text type="secondary">replied</Text>
</Space>
),
description: (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
on {notif.videoTitle}
</Text>
<div style={{ marginTop: 4 }}>
<Text style={{ fontSize: 13 }}>{notif.contentPreview}</Text>
</div>
</div>
),
placement: 'bottomRight',
duration: 8,
btn: (
<Button
type="primary"
size="small"
onClick={() => {
navigate(`/gallery/watch/${notif.videoId}`);
api.destroy(notif.id);
clearNotification(notif.id);
}}
>
View
</Button>
),
onClose: () => {
clearNotification(notif.id);
},
});
}
}, [notifications, api, clearNotification, navigate]);
// Cleanup shown IDs when notifications are cleared
useEffect(() => {
const currentIds = new Set(notifications.map((n) => n.id));
for (const id of shownRef.current) {
if (!currentIds.has(id)) {
shownRef.current.delete(id);
}
}
}, [notifications]);
return <>{contextHolder}</>;
}

View File

@ -0,0 +1,226 @@
import { useState, useEffect } from 'react';
import {
List,
Input,
Button,
Space,
Typography,
message,
Empty,
Spin,
Avatar,
theme,
} from 'antd';
import { UserOutlined, SendOutlined } from '@ant-design/icons';
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const { TextArea } = Input;
const { Text } = Typography;
interface Comment {
id: number;
content: string;
userId: number | null;
sessionId: string;
createdAt: string;
safetyStatus: string;
}
interface CommentSectionProps {
videoId: number;
}
export default function CommentSection({ videoId }: CommentSectionProps) {
const { token } = theme.useToken();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [commentText, setCommentText] = useState('');
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const limit = 20;
const fetchComments = async (append = false) => {
try {
setLoading(true);
const currentOffset = append ? offset : 0;
const response = await mediaPublicApi.get(`/public/${videoId}/comments`, {
params: { limit, offset: currentOffset },
});
if (append) {
setComments((prev) => [...prev, ...response.data.comments]);
} else {
setComments(response.data.comments);
}
setHasMore(response.data.pagination.hasMore);
setOffset(currentOffset + limit);
} catch (error) {
console.error('Failed to fetch comments:', error);
message.error('Failed to load comments');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchComments();
}, [videoId]);
const handleSubmit = async () => {
if (!commentText.trim()) {
message.warning('Please enter a comment');
return;
}
if (commentText.length > 1000) {
message.error('Comment is too long (max 1000 characters)');
return;
}
// Check if user is logged in
if (!useAuthStore.getState().isAuthenticated) {
message.warning('Please log in to comment');
return;
}
try {
setSubmitting(true);
const sessionId = getOrCreateSessionId();
const response = await mediaPublicApi.post(`/public/${videoId}/comments`, {
sessionId,
content: commentText.trim(),
});
// Add new comment to top of list
setComments((prev) => [response.data.comment, ...prev]);
setCommentText('');
message.success('Comment posted!');
} catch (error: any) {
if (error.response?.status === 401) {
message.error('Please log in to comment');
} else {
message.error('Failed to post comment');
}
console.error('Failed to post comment:', error);
} finally {
setSubmitting(false);
}
};
const handleLoadMore = () => {
fetchComments(true);
};
return (
<div>
{/* Comment form */}
<div
style={{
marginBottom: 24,
padding: 16,
background: token.colorBgContainer,
borderRadius: 12,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<TextArea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Add a comment..."
autoSize={{ minRows: 3, maxRows: 6 }}
maxLength={1000}
showCount
style={{ marginBottom: 12 }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={submitting}
onClick={handleSubmit}
disabled={!commentText.trim()}
>
Post Comment
</Button>
</div>
</div>
{/* Comments list */}
<div>
<Text strong style={{ fontSize: 16, marginBottom: 16, display: 'block' }}>
Comments ({comments.length})
</Text>
{loading && comments.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : comments.length === 0 ? (
<Empty
description="No comments yet. Be the first to comment!"
style={{ padding: 40 }}
/>
) : (
<>
<List
dataSource={comments}
renderItem={(comment) => (
<List.Item
style={{
padding: '16px 0',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<List.Item.Meta
avatar={
<Avatar
icon={<UserOutlined />}
style={{
background: token.colorPrimary,
}}
/>
}
title={
<Space size={8}>
<Text strong>
{comment.userId ? `User #${comment.userId}` : 'Anonymous'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{dayjs(comment.createdAt).fromNow()}
</Text>
</Space>
}
description={
<Text style={{ fontSize: 14, whiteSpace: 'pre-wrap' }}>
{comment.content}
</Text>
}
/>
</List.Item>
)}
/>
{/* Load more button */}
{hasMore && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Button onClick={handleLoadMore} loading={loading}>
Load More Comments
</Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,99 @@
import { useState } from 'react';
import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
interface CreatePlaylistModalProps {
open: boolean;
onClose: () => void;
onCreated?: (playlist: any) => void;
}
export default function CreatePlaylistModal({
open,
onClose,
onCreated,
}: CreatePlaylistModalProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const { data } = await mediaApi.post('/playlists/', {
name: values.name,
description: values.description || undefined,
isPublic: values.isPublic ?? false,
});
message.success('Playlist created');
form.resetFields();
onCreated?.(data);
onClose();
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else if (!error.errorFields) {
message.error('Failed to create playlist');
}
} finally {
setLoading(false);
}
};
return (
<Drawer
title="Create Playlist"
open={open}
onClose={() => {
form.resetFields();
onClose();
}}
placement="right"
width={420}
style={{ top: 64 }}
styles={{ body: { paddingTop: 24 } }}
extra={
<Space>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="Name"
rules={[
{ required: true, message: 'Please enter a playlist name' },
{ max: 100, message: 'Name must be 100 characters or less' },
]}
>
<Input placeholder="My Playlist" maxLength={100} />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea
placeholder="Optional description..."
rows={3}
maxLength={500}
/>
</Form.Item>
<Form.Item
name="isPublic"
label="Public"
valuePropName="checked"
initialValue={false}
>
<Switch checkedChildren="Public" unCheckedChildren="Private" />
</Form.Item>
</Form>
</Drawer>
);
}

View File

@ -0,0 +1,58 @@
import { useState } from 'react';
import { Modal, Input, Alert } from 'antd';
interface DeleteConfirmModalProps {
open: boolean;
count: number;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
}
export default function DeleteConfirmModal({ open, count, onConfirm, onCancel, loading }: DeleteConfirmModalProps) {
const [confirmText, setConfirmText] = useState('');
const handleOk = () => {
if (confirmText === 'DELETE') {
onConfirm();
setConfirmText('');
}
};
const handleCancel = () => {
setConfirmText('');
onCancel();
};
return (
<Modal
title="Confirm Deletion"
open={open}
onOk={handleOk}
onCancel={handleCancel}
okText="Delete"
okButtonProps={{ danger: true, disabled: confirmText !== 'DELETE' }}
confirmLoading={loading}
>
<Alert
message="Warning"
description={`You are about to delete ${count} video(s). This action cannot be undone.`}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
<div>
<label style={{ display: 'block', marginBottom: 8 }}>
Type <strong>DELETE</strong> to confirm:
</label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="DELETE"
autoFocus
/>
</div>
</Modal>
);
}

View File

@ -0,0 +1,260 @@
import { useState, useEffect } from 'react';
import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme } from 'antd';
import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PlaylistVideoItem } from '@/types/media';
const { Text } = Typography;
interface EditPlaylistModalProps {
playlistId: number | null;
open: boolean;
onClose: () => void;
onUpdated?: () => void;
}
function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export default function EditPlaylistModal({
playlistId,
open,
onClose,
onUpdated,
}: EditPlaylistModalProps) {
const [form] = Form.useForm();
const { token } = theme.useToken();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [videos, setVideos] = useState<PlaylistVideoItem[]>([]);
useEffect(() => {
if (!open || !playlistId) return;
const fetchPlaylist = async () => {
try {
setLoading(true);
const { data } = await mediaPublicApi.get(`/playlists/${playlistId}`);
setVideos(data.videos || []);
form.setFieldsValue({
name: data.name,
description: data.description,
isPublic: data.isPublic,
});
} catch {
message.error('Failed to load playlist');
} finally {
setLoading(false);
}
};
fetchPlaylist();
}, [open, playlistId]);
const handleSaveDetails = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await mediaApi.put(`/playlists/${playlistId}`, {
name: values.name,
description: values.description || undefined,
isPublic: values.isPublic,
});
message.success('Playlist updated');
onUpdated?.();
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else if (!error.errorFields) {
message.error('Failed to update playlist');
}
} finally {
setSaving(false);
}
};
const handleRemoveVideo = async (mediaId: number) => {
try {
await mediaApi.delete(`/playlists/${playlistId}/videos/${mediaId}`);
setVideos((prev) => prev.filter((v) => v.mediaId !== mediaId));
message.success('Video removed');
onUpdated?.();
} catch {
message.error('Failed to remove video');
}
};
const handleMoveVideo = async (index: number, direction: 'up' | 'down') => {
const newVideos = [...videos];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newVideos.length) return;
const temp = newVideos[index]!;
newVideos[index] = newVideos[targetIndex]!;
newVideos[targetIndex] = temp;
// Update positions
const reordered = newVideos.map((v, i) => ({
...v,
position: i,
}));
setVideos(reordered);
try {
await mediaApi.put(`/playlists/${playlistId}/videos/reorder`, {
items: reordered.map((v) => ({ mediaId: v.mediaId, position: v.position })),
});
} catch {
message.error('Failed to reorder');
}
};
return (
<Drawer
title="Edit Playlist"
open={open}
onClose={() => {
form.resetFields();
onClose();
}}
placement="right"
width={520}
style={{ top: 64 }}
loading={loading}
>
<Tabs
items={[
{
key: 'details',
label: 'Details',
children: (
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
name="name"
label="Name"
rules={[
{ required: true, message: 'Please enter a playlist name' },
{ max: 100, message: 'Name must be 100 characters or less' },
]}
>
<Input maxLength={100} />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} maxLength={500} />
</Form.Item>
<Form.Item
name="isPublic"
label="Public"
valuePropName="checked"
>
<Switch checkedChildren="Public" unCheckedChildren="Private" />
</Form.Item>
<Button type="primary" onClick={handleSaveDetails} loading={saving}>
Save Changes
</Button>
</Form>
),
},
{
key: 'videos',
label: `Videos (${videos.length})`,
children: (
<List
dataSource={videos}
locale={{ emptyText: 'No videos in this playlist' }}
renderItem={(item, index) => {
const title = item.video.title || item.video.filename.replace(/\.[^/.]+$/, '');
return (
<List.Item
style={{ padding: '8px 0' }}
actions={[
<Button
key="up"
type="text"
size="small"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => handleMoveVideo(index, 'up')}
/>,
<Button
key="down"
type="text"
size="small"
icon={<ArrowDownOutlined />}
disabled={index === videos.length - 1}
onClick={() => handleMoveVideo(index, 'down')}
/>,
<Button
key="remove"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleRemoveVideo(item.mediaId)}
/>,
]}
>
<Space>
<div
style={{
width: 28,
height: 28,
borderRadius: 4,
background: token.colorBgTextHover,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
}}
>
{index + 1}
</div>
{item.video.thumbnailUrl && (
<img
src={`/media${item.video.thumbnailUrl}`}
alt=""
style={{
width: 48,
height: 28,
objectFit: 'cover',
borderRadius: 4,
flexShrink: 0,
}}
/>
)}
<div style={{ minWidth: 0 }}>
<Text
ellipsis
style={{ fontSize: 13, display: 'block', maxWidth: 280 }}
>
{title}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDuration(item.video.durationSeconds)}
</Text>
</div>
</Space>
</List.Item>
);
}}
/>
),
},
]}
/>
</Drawer>
);
}

View File

@ -0,0 +1,162 @@
import { Drawer, Form, Input, Select, Switch, Button, Space, message, Spin } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import { mediaApi } from '@/lib/media-api';
import type { Video } from '@/types/media';
interface EditVideoDrawerProps {
video: Video | null;
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
const CATEGORY_OPTIONS = [
{ value: 'videos', label: 'Videos' },
{ value: 'curated', label: 'Curated' },
{ value: 'compilations', label: 'Compilations' },
{ value: 'playback', label: 'Playback' },
{ value: 'highlights', label: 'Highlights' },
];
export default function EditVideoModal({ video, open, onClose, onSuccess }: EditVideoDrawerProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
// Fetch full video details when drawer opens (list query omits some fields)
useEffect(() => {
if (open && video) {
setFetching(true);
mediaApi
.get<{ video: Video }>(`/videos/${video.id}`)
.then(({ data }) => {
const v = data.video;
form.setFieldsValue({
title: v.title || '',
producer: v.producer || '',
creator: v.creator || '',
category: v.category || undefined,
tags: Array.isArray(v.tags) ? v.tags : [],
quality: v.quality || '',
isShort: v.isShort ?? false,
});
})
.catch(() => {
// Fallback to data we already have from the list
form.setFieldsValue({
title: video.title || '',
producer: video.producer || '',
creator: video.creator || '',
category: video.category || undefined,
tags: Array.isArray(video.tags) ? video.tags : [],
quality: video.quality || '',
isShort: video.isShort ?? false,
});
})
.finally(() => setFetching(false));
}
}, [open, video, form]);
const handleSave = async () => {
if (!video) return;
try {
const values = await form.validateFields();
setLoading(true);
const payload: Record<string, unknown> = {};
if (values.title) payload.title = values.title;
// Allow clearing optional fields by sending null
payload.producer = values.producer || null;
payload.creator = values.creator || null;
payload.category = values.category || null;
payload.tags = values.tags && values.tags.length > 0 ? values.tags : null;
if (values.isShort !== undefined) payload.isShort = values.isShort;
await mediaApi.patch(`/videos/${video.id}`, payload);
message.success('Video updated successfully');
onSuccess?.();
onClose();
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message);
}
// form validation errors are shown inline
} finally {
setLoading(false);
}
};
return (
<Drawer
title={
<span>
<EditOutlined style={{ marginRight: 8 }} />
Edit Video
</span>
}
open={open}
onClose={onClose}
width={480}
destroyOnClose
extra={
<Space>
<Button onClick={onClose}>Cancel</Button>
<Button type="primary" onClick={handleSave} loading={loading}>
Save
</Button>
</Space>
}
>
{fetching ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
) : (
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input placeholder="Video title" maxLength={500} />
</Form.Item>
<Form.Item name="producer" label="Producer">
<Input placeholder="Producer name" maxLength={200} />
</Form.Item>
<Form.Item name="creator" label="Creator">
<Input placeholder="Creator name" maxLength={200} />
</Form.Item>
<Form.Item name="category" label="Category">
<Select
placeholder="Select category"
options={CATEGORY_OPTIONS}
allowClear
/>
</Form.Item>
<Form.Item name="isShort" label="Short Video" valuePropName="checked">
<Switch checkedChildren="Yes" unCheckedChildren="No" />
</Form.Item>
<Form.Item name="tags" label="Tags">
<Select
mode="tags"
placeholder="Type to add tags"
tokenSeparators={[',']}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="quality" label="Quality">
<Input disabled placeholder="Auto-detected" />
</Form.Item>
</Form>
)}
</Drawer>
);
}

View File

@ -0,0 +1,344 @@
import { useRef, useState, useEffect } from 'react';
import { Button, Space, Tag, Grid, theme } from 'antd';
import {
CloseOutlined,
LikeOutlined,
LikeFilled,
EyeOutlined,
CommentOutlined,
OrderedListOutlined,
} from '@ant-design/icons';
import { useExpandedVideo, type VideoData } from '@/contexts/ExpandedVideoContext';
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
import { useAuthStore } from '@/stores/auth.store';
import VideoPlayer, { VideoPlayerRef } from './VideoPlayer';
import LiveChat from './LiveChat';
import ProgressBarMarkers from './ProgressBarMarkers';
import ReactionButtons from './ReactionButtons';
import AddToPlaylistModal from './AddToPlaylistModal';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
const { useBreakpoint } = Grid;
interface ExpandedVideoCardProps {
video: VideoData;
}
export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { collapseVideo } = useExpandedVideo();
const containerRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<VideoPlayerRef | null>(null);
const [hasUpvoted, setHasUpvoted] = useState(false);
const [upvoteCount, setUpvoteCount] = useState(video.upvoteCount);
const [upvoting, setUpvoting] = useState(false);
const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
const [addToPlaylistOpen, setAddToPlaylistOpen] = useState(false);
const [videoHeight, setVideoHeight] = useState<number>(0);
const [currentTime, setCurrentTime] = useState(0);
const [isExpanding, setIsExpanding] = useState(true);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// Parent padding to break out of (matches MediaPublicLayout)
const pad = isMobile ? 8 : 12;
// Extract title from filename
const title = video.filename.replace(/\.[^/.]+$/, '');
// Keyboard shortcuts
useKeyboardShortcuts({
playerRef: playerRef as React.RefObject<VideoPlayerRef>,
onClose: collapseVideo,
enabled: !isExpanding,
});
// Trigger expand animation after mount
useEffect(() => {
const timer = requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsExpanding(false);
});
});
return () => cancelAnimationFrame(timer);
}, []);
// Scroll the expanded card into view smoothly
useEffect(() => {
const timer = setTimeout(() => {
if (containerRef.current) {
containerRef.current.scrollIntoView({
behavior: isMobile ? 'auto' : 'smooth',
block: 'nearest',
});
}
}, 350);
return () => clearTimeout(timer);
}, [isMobile]);
// Track video container height for chat sizing
useEffect(() => {
const videoContainer = videoContainerRef.current;
if (!videoContainer) return;
const updateHeight = () => {
const height = videoContainer.offsetHeight;
if (height > 0) setVideoHeight(height);
};
const timer = setTimeout(updateHeight, 350);
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(videoContainer);
return () => {
clearTimeout(timer);
resizeObserver.disconnect();
};
}, [isExpanding]);
// Poll currentTime for ReactionButtons
useEffect(() => {
if (isExpanding) return;
const interval = setInterval(() => {
const el = playerRef.current?.getVideoElement();
if (el) setCurrentTime(el.currentTime);
}, 1000);
return () => clearInterval(interval);
}, [isExpanding]);
const handleUpvote = async () => {
if (upvoting || hasUpvoted) return;
try {
setUpvoting(true);
await mediaPublicApi.post(`/public/${video.id}/upvote`);
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: any) {
console.error('Upvote failed:', error);
if (error.response?.status === 401) {
alert('Please log in to upvote videos');
}
} finally {
setUpvoting(false);
}
};
const formatDuration = (seconds: number | null) => {
if (!seconds) return '';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatCount = (count: number) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
return (
<div
ref={containerRef}
style={{
gridColumn: '1 / -1',
background: token.colorBgContainer,
borderRadius: 0,
overflow: 'hidden',
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
maxHeight: isExpanding ? 0 : 3000,
opacity: isExpanding ? 0 : 1,
// Break out of parent padding to fill full content area
marginLeft: -pad,
marginRight: -pad,
width: `calc(100% + ${pad * 2}px)`,
}}
>
{/* Main content: video (left) + chat (right) */}
<div
style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
}}
>
{/* Video section */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Video player */}
<div
ref={videoContainerRef}
style={{
position: 'relative',
width: '100%',
aspectRatio: video.orientation === 'V' ? '9/16' : '16/9',
// Cap height so player controls stay above the info bar
maxHeight: isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 50px)',
background: '#000',
}}
>
<VideoPlayer
ref={playerRef}
videoId={video.id}
width="100%"
height="100%"
autoplay={!isExpanding}
controls={true}
/>
{/* Progress Bar Reaction Markers */}
{video.durationSeconds && playerRef.current?.getVideoElement() && (
<ProgressBarMarkers
videoId={video.id}
durationSeconds={video.durationSeconds}
playerRef={{ current: playerRef.current.getVideoElement()! }}
/>
)}
</div>
</div>
{/* Chat panel (desktop only, beside video) */}
{!isMobile && (
<div
style={{
width: 280,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
borderLeft: `1px solid ${token.colorBorder}`,
background: token.colorBgElevated,
height: videoHeight > 0 ? videoHeight : 'auto',
maxHeight: videoHeight > 0 ? videoHeight : 600,
}}
>
<MediaAuthProvider>
<LiveChat
videoId={video.id}
isOpen={true}
onRequestLogin={() => {}}
/>
</MediaAuthProvider>
</div>
)}
</div>
{/* Bottom info bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: isMobile ? '6px 12px' : '6px 16px',
borderTop: `1px solid ${token.colorBorder}`,
flexWrap: 'wrap',
}}
>
{/* Close button */}
<Button
type="text"
icon={<CloseOutlined />}
onClick={collapseVideo}
size="small"
style={{ flexShrink: 0 }}
/>
{/* Title */}
<div
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: isMobile ? 12 : 14,
fontWeight: 500,
color: token.colorText,
}}
>
{title}
</div>
{/* Tags + stats */}
<Space size={8} style={{ flexShrink: 0 }}>
{video.quality && (
<Tag color="purple" style={{ margin: 0, fontSize: 10 }}>{video.quality}</Tag>
)}
{video.durationSeconds && (
<Tag style={{ margin: 0, fontSize: 10 }}>{formatDuration(video.durationSeconds)}</Tag>
)}
</Space>
<Space size={12} style={{ color: token.colorTextSecondary, fontSize: 12, flexShrink: 0 }}>
<span><EyeOutlined /> {formatCount(video.viewCount)}</span>
</Space>
{/* Reaction emoji buttons */}
<ReactionButtons videoId={video.id} currentTime={currentTime} />
{/* Upvote */}
<Button
type={hasUpvoted ? 'primary' : 'text'}
icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
onClick={handleUpvote}
loading={upvoting}
disabled={hasUpvoted}
size="small"
style={{ flexShrink: 0 }}
>
{formatCount(upvoteCount)}
</Button>
{/* Add to Playlist */}
{isAuthenticated && (
<Button
type="text"
icon={<OrderedListOutlined />}
onClick={() => setAddToPlaylistOpen(true)}
size="small"
style={{ flexShrink: 0 }}
title="Add to playlist"
/>
)}
{/* Mobile chat toggle */}
{isMobile && (
<Button
type={isMobileChatOpen ? 'primary' : 'text'}
icon={<CommentOutlined />}
onClick={() => setIsMobileChatOpen(!isMobileChatOpen)}
size="small"
>
{video.commentCount}
</Button>
)}
</div>
{/* Mobile chat (collapsible, below info bar) */}
{isMobileChatOpen && isMobile && (
<div
style={{
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorder}`,
height: 250,
}}
>
<MediaAuthProvider>
<LiveChat
videoId={video.id}
isOpen={true}
onRequestLogin={() => {}}
/>
</MediaAuthProvider>
</div>
)}
{/* Add to Playlist Modal */}
<AddToPlaylistModal
videoId={video.id}
open={addToPlaylistOpen}
onClose={() => setAddToPlaylistOpen(false)}
/>
</div>
);
}

View File

@ -0,0 +1,152 @@
import { useState, useEffect, useRef } from 'react';
import { Typography, Spin, theme, Grid } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import PlaylistCard from './PlaylistCard';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PlaylistSummary } from '@/types/media';
const { useBreakpoint } = Grid;
export default function FeaturedPlaylistCarousel() {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const scrollRef = useRef<HTMLDivElement>(null);
const [playlists, setPlaylists] = useState<PlaylistSummary[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFeatured = async () => {
try {
const { data } = await mediaPublicApi.get('/playlists/featured', {
params: { limit: 12 },
});
setPlaylists(data.data || []);
} catch {
// Silent fail — not critical
} finally {
setLoading(false);
}
};
fetchFeatured();
}, []);
const scroll = (direction: 'left' | 'right') => {
if (!scrollRef.current) return;
const scrollAmount = isMobile ? 260 : 300;
scrollRef.current.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin />
</div>
);
}
if (playlists.length === 0) return null;
return (
<div style={{ marginBottom: 24 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
Featured Playlists
</Typography.Title>
{playlists.length > 3 && (
<div style={{ display: 'flex', gap: 8 }}>
<div
onClick={() => scroll('left')}
style={{
width: 32,
height: 32,
borderRadius: '50%',
border: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: token.colorTextSecondary,
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = token.colorPrimary;
e.currentTarget.style.color = token.colorPrimary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = token.colorBorderSecondary;
e.currentTarget.style.color = token.colorTextSecondary;
}}
>
<LeftOutlined style={{ fontSize: 12 }} />
</div>
<div
onClick={() => scroll('right')}
style={{
width: 32,
height: 32,
borderRadius: '50%',
border: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: token.colorTextSecondary,
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = token.colorPrimary;
e.currentTarget.style.color = token.colorPrimary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = token.colorBorderSecondary;
e.currentTarget.style.color = token.colorTextSecondary;
}}
>
<RightOutlined style={{ fontSize: 12 }} />
</div>
</div>
)}
</div>
<div
ref={scrollRef}
style={{
display: 'flex',
gap: 16,
overflowX: 'auto',
scrollSnapType: 'x mandatory',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
paddingBottom: 4,
}}
>
{playlists.map((playlist) => (
<div
key={playlist.id}
style={{
minWidth: isMobile ? 240 : 280,
maxWidth: isMobile ? 240 : 280,
scrollSnapAlign: 'start',
flexShrink: 0,
}}
>
<PlaylistCard playlist={playlist} />
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,540 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Drawer,
Input,
Button,
Space,
Card,
Tag,
Progress,
Typography,
message,
Empty,
Collapse,
List,
Tooltip,
} from 'antd';
import {
CloudDownloadOutlined,
StopOutlined,
ReloadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
ClockCircleOutlined,
ExpandOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
const { TextArea } = Input;
const { Text } = Typography;
interface FetchJob {
id: string;
urls: string[];
urlCount: number;
state: string;
progress: number;
returnvalue: {
results: Array<{
url: string;
success: boolean;
videoId?: number;
title?: string;
error?: string;
}>;
totalUrls: number;
successCount: number;
failCount: number;
} | null;
failedReason: string | null;
timestamp: number;
finishedOn: number | null;
processedOn: number | null;
}
interface FetchVideosDrawerProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
const STATE_COLORS: Record<string, string> = {
active: 'processing',
waiting: 'default',
delayed: 'warning',
completed: 'success',
failed: 'error',
};
const STATE_ICONS: Record<string, React.ReactNode> = {
active: <LoadingOutlined />,
waiting: <ClockCircleOutlined />,
completed: <CheckCircleOutlined />,
failed: <CloseCircleOutlined />,
};
export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVideosDrawerProps) {
const [urls, setUrls] = useState('');
const [submitting, setSubmitting] = useState(false);
const [jobs, setJobs] = useState<FetchJob[]>([]);
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
const [logLines, setLogLines] = useState<string[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
const prevCompletedRef = useRef<Set<string>>(new Set());
// Poll for job updates
const fetchJobs = useCallback(async () => {
try {
const { data } = await mediaApi.get<{ jobs: FetchJob[] }>('/videos/fetch/jobs');
setJobs(data.jobs);
// Check for newly completed jobs to trigger refresh
const currentCompleted = new Set(
data.jobs.filter(j => j.state === 'completed').map(j => j.id)
);
const prev = prevCompletedRef.current;
for (const id of currentCompleted) {
if (!prev.has(id)) {
// A job just completed
onSuccess?.();
break;
}
}
prevCompletedRef.current = currentCompleted;
} catch (err) {
// Silently ignore poll errors
}
}, [onSuccess]);
useEffect(() => {
if (open) {
fetchJobs();
pollRef.current = setInterval(fetchJobs, 3000);
}
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [open, fetchJobs]);
// SSE log connection
useEffect(() => {
if (!expandedJobId) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setLogLines([]);
return;
}
// Construct SSE URL through the media API proxy
const baseUrl = '/media/api/videos/fetch/jobs/' + expandedJobId + '/log';
// We need the auth token for the SSE connection
// Use fetch with EventSource-like manual parsing since ES doesn't support auth headers
const controller = new AbortController();
let cancelled = false;
const connectSSE = async () => {
try {
// Get auth token from localStorage
const stored = localStorage.getItem('auth-storage');
let token = '';
if (stored) {
try {
const parsed = JSON.parse(stored);
token = parsed?.state?.accessToken || '';
} catch {}
}
const response = await fetch(baseUrl, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
},
signal: controller.signal,
});
if (!response.ok || !response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!cancelled) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = line.slice(6);
try {
const parsed = JSON.parse(payload);
if (parsed.type === 'log' && parsed.data) {
setLogLines(prev => [...prev, parsed.data]);
} else if (parsed.type === 'status') {
// Job completed or failed
fetchJobs();
}
} catch {
// Not JSON, might be raw text
}
} else if (line.startsWith('event: progress')) {
// Progress events are handled by polling
} else if (line.startsWith('event: done')) {
fetchJobs();
}
}
}
} catch (err) {
if (!cancelled) {
// Connection error, will retry on next expand
}
}
};
setLogLines([]);
connectSSE();
return () => {
cancelled = true;
controller.abort();
};
}, [expandedJobId, fetchJobs]);
// Auto-scroll log to bottom
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logLines]);
// Clean up on close
useEffect(() => {
if (!open) {
setExpandedJobId(null);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}
}, [open]);
const handleSubmit = async () => {
const urlList = urls
.split('\n')
.map(u => u.trim())
.filter(u => u.length > 0);
if (urlList.length === 0) {
message.warning('Please enter at least one URL');
return;
}
if (urlList.length > 20) {
message.error('Maximum 20 URLs per submission');
return;
}
setSubmitting(true);
try {
const { data } = await mediaApi.post('/videos/fetch', { urls: urlList });
message.success(`Fetch job submitted with ${data.urlCount} URL(s)`);
setUrls('');
// Immediately expand the new job
setExpandedJobId(data.jobId);
fetchJobs();
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to submit fetch job');
} finally {
setSubmitting(false);
}
};
const handleCancel = async (jobId: string) => {
try {
await mediaApi.delete(`/videos/fetch/jobs/${jobId}`);
message.success('Job cancelled');
fetchJobs();
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to cancel job');
}
};
const formatTime = (ts: number | null) => {
if (!ts) return '-';
return new Date(ts).toLocaleTimeString();
};
const activeJobs = jobs.filter(j => j.state === 'active' || j.state === 'waiting');
const recentJobs = jobs.filter(j => j.state === 'completed' || j.state === 'failed');
return (
<Drawer
title={
<Space>
<CloudDownloadOutlined />
Fetch Videos
</Space>
}
open={open}
onClose={onClose}
width={560}
destroyOnClose
>
{/* URL Input Section */}
<Card
size="small"
title="Download from URL"
style={{ marginBottom: 16 }}
>
<TextArea
rows={4}
placeholder={'Paste video URLs here, one per line.\n\nSupports YouTube, Twitter/X, Reddit, Vimeo, and 1000+ sites via yt-dlp.'}
value={urls}
onChange={(e) => setUrls(e.target.value)}
disabled={submitting}
style={{ marginBottom: 12, fontFamily: 'monospace', fontSize: 12 }}
/>
<Space>
<Button
type="primary"
icon={<CloudDownloadOutlined />}
loading={submitting}
onClick={handleSubmit}
disabled={!urls.trim()}
>
Fetch
</Button>
<Text type="secondary" style={{ fontSize: 12 }}>
Max 20 URLs per submission
</Text>
</Space>
</Card>
{/* Active Jobs */}
{activeJobs.length > 0 && (
<Card
size="small"
title={`Active Jobs (${activeJobs.length})`}
style={{ marginBottom: 16 }}
>
{activeJobs.map(job => (
<div key={job.id} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<Space size="small">
<Tag color={STATE_COLORS[job.state]} icon={STATE_ICONS[job.state]}>
{job.state}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{job.urlCount} URL{job.urlCount !== 1 ? 's' : ''}
</Text>
</Space>
<Space size="small">
<Tooltip title={expandedJobId === job.id ? 'Collapse log' : 'Expand log'}>
<Button
size="small"
type="text"
icon={<ExpandOutlined />}
onClick={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}
/>
</Tooltip>
<Button
size="small"
danger
icon={<StopOutlined />}
onClick={() => handleCancel(job.id)}
>
Cancel
</Button>
</Space>
</div>
{job.state === 'active' && (
<Progress
percent={job.progress || 0}
size="small"
status="active"
/>
)}
{/* Log viewer */}
{expandedJobId === job.id && (
<div
ref={logContainerRef}
style={{
marginTop: 8,
maxHeight: 300,
overflow: 'auto',
background: '#1a1a2e',
borderRadius: 4,
padding: 8,
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.5,
color: '#e0e0e0',
}}
>
{logLines.length === 0 ? (
<Text type="secondary" style={{ fontSize: 11 }}>Waiting for output...</Text>
) : (
logLines.map((line, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line.startsWith('[stderr]') ? (
<span style={{ color: '#ff6b6b' }}>{line}</span>
) : line.startsWith('FAILED:') ? (
<span style={{ color: '#ff6b6b' }}>{line}</span>
) : line.startsWith('---') ? (
<span style={{ color: '#74b9ff' }}>{line}</span>
) : line.includes('Imported as video') ? (
<span style={{ color: '#55efc4' }}>{line}</span>
) : (
line
)}
</div>
))
)}
</div>
)}
{/* URL list */}
<div style={{ marginTop: 4 }}>
{job.urls.slice(0, 3).map((url, i) => (
<Text key={i} type="secondary" ellipsis style={{ display: 'block', fontSize: 11 }}>
{url}
</Text>
))}
{job.urls.length > 3 && (
<Text type="secondary" style={{ fontSize: 11 }}>
...and {job.urls.length - 3} more
</Text>
)}
</div>
</div>
))}
</Card>
)}
{/* Recent Jobs */}
{recentJobs.length > 0 && (
<Card size="small" title="Recent Jobs">
<Collapse
accordion
ghost
activeKey={expandedJobId && recentJobs.some(j => j.id === expandedJobId) ? expandedJobId : undefined}
onChange={(key) => setExpandedJobId(typeof key === 'string' ? key : key?.[0] || null)}
items={recentJobs.map(job => ({
key: job.id,
label: (
<Space>
<Tag color={STATE_COLORS[job.state]} icon={STATE_ICONS[job.state]}>
{job.state}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{job.urlCount} URL{job.urlCount !== 1 ? 's' : ''}
{job.returnvalue && (
<> {job.returnvalue.successCount} ok, {job.returnvalue.failCount} failed</>
)}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatTime(job.finishedOn || job.timestamp)}
</Text>
</Space>
),
children: (
<div>
{/* Results list */}
{job.returnvalue?.results && (
<List
size="small"
dataSource={job.returnvalue.results}
renderItem={(result) => (
<List.Item>
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Text ellipsis style={{ fontSize: 12, maxWidth: 450 }}>
{result.url}
</Text>
{result.success ? (
<Tag color="success" style={{ fontSize: 11 }}>
Imported: {result.title || `Video #${result.videoId}`}
</Tag>
) : (
<Tag color="error" style={{ fontSize: 11 }}>
{result.error || 'Unknown error'}
</Tag>
)}
</Space>
</List.Item>
)}
/>
)}
{job.failedReason && (
<Text type="danger" style={{ fontSize: 12 }}>
{job.failedReason}
</Text>
)}
{/* Log viewer for recent jobs */}
{expandedJobId === job.id && logLines.length > 0 && (
<div
ref={logContainerRef}
style={{
marginTop: 8,
maxHeight: 200,
overflow: 'auto',
background: '#1a1a2e',
borderRadius: 4,
padding: 8,
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.5,
color: '#e0e0e0',
}}
>
{logLines.map((line, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line}
</div>
))}
</div>
)}
</div>
),
}))}
/>
</Card>
)}
{/* Empty state */}
{jobs.length === 0 && !submitting && (
<Empty
description="No fetch jobs yet"
style={{ marginTop: 32 }}
/>
)}
{/* Refresh button */}
{jobs.length > 0 && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Button
type="text"
icon={<ReloadOutlined />}
onClick={fetchJobs}
size="small"
>
Refresh
</Button>
</div>
)}
</Drawer>
);
}

View File

@ -0,0 +1,582 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Input,
Button,
Space,
Typography,
Tag,
Alert,
Spin,
theme,
Avatar,
} from 'antd';
import {
SendOutlined,
UserOutlined,
WarningOutlined,
CheckCircleOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import { useMediaAuth } from '@/contexts/MediaAuthContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
const { Text } = Typography;
const { TextArea } = Input;
interface Comment {
id: number;
type: 'comment';
content: string;
createdAt: string;
safetyStatus?: string | null;
safetyCategories?: any;
user: {
id: string;
name: string;
} | null;
}
interface Reaction {
id: number;
type: 'reaction';
reactionType: string;
emoji: string;
videoTimestamp: number;
formattedTime: string;
createdAt: string;
user: {
id: string;
name: string;
} | null;
}
type TimelineItem = Comment | Reaction;
interface LiveChatProps {
videoId: number;
isOpen: boolean;
onRequestLogin?: () => void;
flexWidth?: boolean; // For vertical video side-by-side layout (future use)
}
export default function LiveChat({
videoId,
isOpen,
onRequestLogin,
}: LiveChatProps) {
const { token } = theme.useToken();
const { isAuthenticated, isApproved } = useMediaAuth();
// Timeline state
const [timeline, setTimeline] = useState<TimelineItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Input state
const [commentInput, setCommentInput] = useState('');
const [submitting, setSubmitting] = useState(false);
// SSE state
const [sseConnected, setSSEConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
// Scroll state
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const [showNewMessagesButton, setShowNewMessagesButton] = useState(false);
// Auto-scroll to bottom
const scrollToBottom = useCallback((smooth = true) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
setShowNewMessagesButton(false);
}
}, []);
// Check if user is near bottom (within 100px)
const checkScrollPosition = useCallback(() => {
if (!scrollContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const nearBottom = distanceFromBottom < 100;
setIsNearBottom(nearBottom);
if (!nearBottom && !showNewMessagesButton) {
setShowNewMessagesButton(true);
}
}, [showNewMessagesButton]);
// Fetch initial comments and reactions
const fetchInitialTimeline = async () => {
try {
setLoading(true);
setError(null);
// Use relative URLs to go through nginx proxy instead of direct media API access
// This avoids SSL certificate issues in production and works for both admin and public gallery
const [commentsRes, reactionsRes] = await Promise.all([
fetch(`/media/public/${videoId}/comments?limit=200`),
fetch(`/media/reactions/${videoId}/chat?limit=500`),
]);
if (!commentsRes.ok || !reactionsRes.ok) {
throw new Error('Failed to fetch timeline');
}
const commentsData = await commentsRes.json();
const reactionsData = await reactionsRes.json();
const comments: Comment[] = commentsData.comments.map((c: any) => ({
...c,
type: 'comment' as const,
}));
const reactions: Reaction[] = reactionsData.reactions.map((r: any) => ({
...r,
type: 'reaction' as const,
}));
// Merge and sort by createdAt
const merged = [...comments, ...reactions].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
setTimeline(merged);
// Scroll to bottom after loading
setTimeout(() => scrollToBottom(false), 100);
} catch (err) {
console.error('Failed to fetch timeline:', err);
setError('Failed to load chat. Please refresh the page.');
} finally {
setLoading(false);
}
};
// Setup SSE connection
const setupSSE = useCallback(() => {
if (!isOpen || eventSourceRef.current) return;
// Use relative URL to go through nginx proxy
const sseUrl = `/media/public/${videoId}/chat-stream`;
const eventSource = new EventSource(sseUrl);
eventSource.onopen = () => {
console.log('SSE connected');
setSSEConnected(true);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'connected') {
console.log('SSE connection confirmed for video', data.videoId);
return;
}
if (data.type === 'new_comment') {
const newComment: Comment = {
...data.comment,
type: 'comment',
};
setTimeline((prev) => {
// Check for duplicates
if (prev.some((item) => item.type === 'comment' && item.id === newComment.id)) {
return prev;
}
// Add to timeline (respecting max limit)
const updated = [...prev, newComment];
return updated.slice(-200); // Keep last 200 comments
});
// Auto-scroll if near bottom
if (isNearBottom) {
setTimeout(() => scrollToBottom(), 100);
} else {
setShowNewMessagesButton(true);
}
}
if (data.type === 'new_reaction') {
const newReaction: Reaction = {
...data.reaction,
type: 'reaction',
};
setTimeline((prev) => {
// Check for duplicates
if (prev.some((item) => item.type === 'reaction' && item.id === newReaction.id)) {
return prev;
}
// Add to timeline (respecting max limit)
const updated = [...prev, newReaction];
return updated.slice(-500); // Keep last 500 reactions
});
// Auto-scroll if near bottom
if (isNearBottom) {
setTimeout(() => scrollToBottom(), 100);
} else {
setShowNewMessagesButton(true);
}
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
}
};
eventSource.onerror = () => {
console.error('SSE connection error');
setSSEConnected(false);
// Auto-reconnect after 3 seconds
setTimeout(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setupSSE();
}, 3000);
};
eventSourceRef.current = eventSource;
}, [isOpen, videoId, isNearBottom, scrollToBottom]);
// Cleanup SSE on unmount or when closed
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, []);
// Mark thread as read when chat opens
const markAsRead = useCallback(async () => {
if (!isAuthenticated) return;
try {
await mediaApi.post(`/media/chat/threads/${videoId}/read`);
} catch {
// Non-critical
}
}, [videoId, isAuthenticated]);
// Fetch timeline and setup SSE when component opens
useEffect(() => {
if (isOpen) {
fetchInitialTimeline();
setupSSE();
markAsRead();
}
}, [isOpen, videoId, setupSSE, markAsRead]);
// Handle comment submission
const handleSubmitComment = async () => {
if (!commentInput.trim() || submitting) return;
if (!isAuthenticated) {
if (onRequestLogin) {
onRequestLogin();
}
return;
}
try {
setSubmitting(true);
await mediaPublicApi.post(`/public/${videoId}/comments`, {
content: commentInput.trim(),
});
// Clear input
setCommentInput('');
// Note: New comment will appear via SSE broadcast
} catch (err: any) {
console.error('Failed to submit comment:', err);
if (err.response?.status === 429) {
alert('Rate limit exceeded. Please wait a minute before commenting again.');
} else if (err.response?.status === 401) {
alert('Please log in to comment.');
if (onRequestLogin) {
onRequestLogin();
}
} else {
alert('Failed to submit comment. Please try again.');
}
} finally {
setSubmitting(false);
}
};
// Format relative time (e.g., "2m ago")
const formatRelativeTime = (isoString: string) => {
const now = new Date().getTime();
const then = new Date(isoString).getTime();
const diffSeconds = Math.floor((now - then) / 1000);
if (diffSeconds < 60) return 'just now';
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
return `${Math.floor(diffSeconds / 86400)}d ago`;
};
// Render timeline item
const renderTimelineItem = (item: TimelineItem) => {
if (item.type === 'comment') {
const isFlagged = item.safetyStatus === 'flagged';
const isApproved = item.safetyStatus === 'approved';
return (
<div
key={`comment-${item.id}`}
style={{
padding: '12px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
opacity: isFlagged ? 0.7 : 1,
}}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{/* Header: User + Time + Badges */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar size="small" icon={<UserOutlined />} />
<Text strong style={{ fontSize: 13 }}>
{item.user?.name || 'Anonymous'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatRelativeTime(item.createdAt)}
</Text>
{/* Safety/Moderation Badges */}
{isFlagged && (
<Tag
color="warning"
icon={<WarningOutlined />}
style={{ fontSize: 11, marginLeft: 'auto' }}
>
Flagged
</Tag>
)}
{isApproved && (
<Tag
color="success"
icon={<CheckCircleOutlined />}
style={{ fontSize: 11, marginLeft: isFlagged ? 0 : 'auto' }}
>
Verified
</Tag>
)}
</div>
{/* Comment Content */}
<Text style={{ fontSize: 14, whiteSpace: 'pre-wrap' }}>
{item.content}
</Text>
{/* Safety Categories */}
{isFlagged && item.safetyCategories && (
<div style={{ marginTop: 4 }}>
<Text type="warning" style={{ fontSize: 11 }}>
{JSON.stringify(item.safetyCategories)}
</Text>
</div>
)}
</Space>
</div>
);
}
if (item.type === 'reaction') {
return (
<div
key={`reaction-${item.id}`}
style={{
padding: '8px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgLayout,
}}
>
<Space size={8}>
<Text style={{ fontSize: 20 }}>{item.emoji}</Text>
<Text style={{ fontSize: 12 }}>
<Text strong>{item.user?.name || 'Anonymous'}</Text>
{' reacted at '}
<Text type="secondary">{item.formattedTime}</Text>
</Text>
</Space>
</div>
);
}
return null;
};
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: token.colorBgContainer,
}}
>
{/* Header */}
<div
style={{
padding: 16,
borderBottom: `1px solid ${token.colorBorder}`,
background: token.colorBgLayout,
}}
>
<Space>
<Text strong>Live Chat</Text>
{sseConnected && (
<Tag color="success" style={{ fontSize: 11 }}>
Live
</Tag>
)}
{!sseConnected && !loading && (
<Tag color="default" style={{ fontSize: 11 }}>
Connecting...
</Tag>
)}
</Space>
</div>
{/* Timeline */}
<div
ref={scrollContainerRef}
onScroll={checkScrollPosition}
style={{
flex: 1,
overflowY: 'auto',
position: 'relative',
}}
>
{loading && (
<div style={{ padding: 60, textAlign: 'center' }}>
<Spin size="large" tip="Loading chat..." />
</div>
)}
{error && (
<div style={{ padding: 16 }}>
<Alert message={error} type="error" showIcon />
</div>
)}
{!loading && !error && timeline.length === 0 && (
<div style={{ padding: 60, textAlign: 'center' }}>
<Text type="secondary">No messages yet. Be the first to comment!</Text>
</div>
)}
{!loading && !error && timeline.map(renderTimelineItem)}
{/* New Messages Button */}
{showNewMessagesButton && (
<div
style={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 10,
}}
>
<Button
type="primary"
icon={<ArrowDownOutlined />}
onClick={() => scrollToBottom()}
size="small"
>
New messages
</Button>
</div>
)}
</div>
{/* Comment Input */}
{!isApproved && isAuthenticated && (
<div style={{ padding: 16, borderTop: `1px solid ${token.colorBorder}` }}>
<Alert
message="Account pending approval"
description="Your account is pending approval. You'll be able to comment once approved."
type="info"
showIcon
closable
/>
</div>
)}
{!isAuthenticated && (
<div style={{ padding: 16, borderTop: `1px solid ${token.colorBorder}` }}>
<Alert
message="Login required"
description={
<span>
Please{' '}
<Button type="link" size="small" onClick={onRequestLogin} style={{ padding: 0 }}>
log in
</Button>{' '}
to join the conversation.
</span>
}
type="info"
showIcon
/>
</div>
)}
{isAuthenticated && isApproved && (
<div
style={{
padding: 16,
borderTop: `1px solid ${token.colorBorder}`,
background: token.colorBgLayout,
}}
>
<Space.Compact style={{ width: '100%' }}>
<TextArea
value={commentInput}
onChange={(e) => setCommentInput(e.target.value)}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSubmitComment();
}
}}
placeholder="Type a message... (Shift+Enter for new line)"
maxLength={1000}
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={submitting}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSubmitComment}
loading={submitting}
disabled={!commentInput.trim()}
>
Send
</Button>
</Space.Compact>
<Text type="secondary" style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
{commentInput.length}/1000
</Text>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom';
import { Input, Select, theme, Grid } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
const { useBreakpoint } = Grid;
export default function MediaBottomNav() {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Initialize from URL params
const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
const sort = (searchParams.get('sort') as 'recent' | 'popular' | 'most_viewed') || 'recent';
const isShorts = location.pathname === '/gallery/shorts';
// Debounce search → URL param (skip on shorts — search navigates to gallery on Enter)
useEffect(() => {
if (isShorts) return;
const timer = setTimeout(() => {
const params = new URLSearchParams(searchParams);
if (searchInput) {
params.set('search', searchInput);
} else {
params.delete('search');
}
setSearchParams(params, { replace: true });
}, 300);
return () => clearTimeout(timer);
}, [searchInput, isShorts]);
const handleSortChange = (value: 'recent' | 'popular' | 'most_viewed') => {
if (isShorts) {
// Navigate to gallery with sort param
navigate(`/gallery?sort=${value}`);
return;
}
const params = new URLSearchParams(searchParams);
if (value !== 'recent') {
params.set('sort', value);
} else {
params.delete('sort');
}
setSearchParams(params, { replace: true });
};
// On shorts page, Enter in search navigates to gallery with the search term
const handleSearchSubmit = () => {
if (isShorts && searchInput.trim()) {
navigate(`/gallery?search=${encodeURIComponent(searchInput.trim())}`);
}
};
return (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 48,
background: isShorts ? 'rgba(0, 0, 0, 0.75)' : token.colorBgContainer,
backdropFilter: isShorts ? 'blur(12px)' : undefined,
borderTop: isShorts ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: isMobile ? '0 8px' : '0 16px',
zIndex: 1000,
}}
>
<Input
placeholder="Search videos..."
prefix={<SearchOutlined style={{ color: token.colorTextTertiary }} />}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onPressEnter={handleSearchSubmit}
allowClear
size="small"
style={{ flex: 1 }}
/>
<Select
value={sort}
onChange={handleSortChange}
size="small"
style={{ width: isMobile ? 110 : 140, flexShrink: 0 }}
options={[
{ value: 'recent', label: 'Recent' },
{ value: 'popular', label: 'Popular' },
{ value: 'most_viewed', label: 'Most Viewed' },
]}
/>
</div>
);
}

View File

@ -0,0 +1,728 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Typography, Space, Tooltip, Badge, theme } from 'antd';
import {
HomeOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
StarOutlined,
PlayCircleOutlined,
TeamOutlined,
UserOutlined,
SettingOutlined,
LoginOutlined,
LogoutOutlined,
BarChartOutlined,
MessageOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
DownOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color';
import { mediaApi } from '@/lib/media-api';
const { Text } = Typography;
interface NavItem {
key: string;
label: string;
icon: React.ReactNode;
path: string;
}
interface ChatThread {
mediaId: number;
videoTitle: string;
unreadCount: number;
lastMessage: {
content: string;
userName: string;
createdAt: string;
} | null;
}
interface SectionState {
content: boolean;
activity: boolean;
online: boolean;
account: boolean;
}
export default function MediaSidebar() {
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
// Only hydrate auth if tokens exist (prevents 401 errors on public pages)
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const hydrate = useAuthStore((state) => state.hydrate);
useEffect(() => {
// Check if auth data exists before attempting to hydrate
const authData = localStorage.getItem('cml-auth');
if (authData) {
hydrate();
}
}, [hydrate]);
// Sidebar collapse state (persisted in localStorage)
const [collapsed, setCollapsed] = useState(() => {
const saved = localStorage.getItem('media_sidebar_collapsed');
return saved ? JSON.parse(saved) : false;
});
// Section collapse states (persisted in localStorage)
const [sections, setSections] = useState<SectionState>(() => {
const saved = localStorage.getItem('media_sidebar_sections');
return saved
? JSON.parse(saved)
: { content: true, activity: true, online: true, account: true };
});
// Chat threads state
const [chatThreads, setChatThreads] = useState<ChatThread[]>([]);
const fetchChatThreads = useCallback(async () => {
if (!user) return;
try {
const { data } = await mediaApi.get('/media/chat/threads', { params: { limit: '5' } });
setChatThreads(data.threads || []);
} catch {
// Silent fail for sidebar data
}
}, [user]);
// Fetch chat threads periodically when user is logged in
useEffect(() => {
if (user) {
fetchChatThreads();
const interval = setInterval(fetchChatThreads, 30000); // Refresh every 30s
return () => clearInterval(interval);
} else {
setChatThreads([]);
}
}, [user, fetchChatThreads]);
// Save collapse state to localStorage
useEffect(() => {
localStorage.setItem('media_sidebar_collapsed', JSON.stringify(collapsed));
}, [collapsed]);
useEffect(() => {
localStorage.setItem('media_sidebar_sections', JSON.stringify(sections));
}, [sections]);
// Derived hover colors
const hoverBg = hexToRgba(token.colorPrimary, 0.1);
const userInfoBg = hexToRgba(token.colorPrimary, 0.05);
// Navigation items
const navItems: NavItem[] = [
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
{ key: 'videos', label: 'Videos', icon: <VideoCameraOutlined />, path: '/gallery/videos' },
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
{ key: 'playback', label: 'Playback', icon: <PlayCircleOutlined />, path: '/gallery/playback' },
];
// Determine active nav item from current path (longest match wins)
const getActiveKey = () => {
const path = location.pathname;
if (path === '/gallery') return 'all';
const match = [...navItems]
.sort((a, b) => b.path.length - a.path.length)
.find((item) => path.startsWith(item.path));
return match ? match.key : 'all';
};
const activeKey = getActiveKey();
// Toggle section collapse
const toggleSection = (section: keyof SectionState) => {
setSections((prev) => ({ ...prev, [section]: !prev[section] }));
};
// Handle navigation
const handleNavigate = (path: string) => {
navigate(path);
};
// Handle logout
const handleLogout = async () => {
await logout();
navigate('/gallery');
};
// Sidebar width
const sidebarWidth = collapsed ? 64 : 256;
return (
<div
style={{
width: sidebarWidth,
height: '100vh',
background: token.colorBgContainer,
borderRight: '1px solid rgba(255,255,255,0.06)',
display: 'flex',
flexDirection: 'column',
transition: 'width 0.3s ease',
overflow: 'hidden',
position: 'fixed',
left: 0,
top: 0,
zIndex: 100,
}}
>
{/* Header */}
<div
style={{
padding: collapsed ? '16px 8px' : '16px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
textAlign: collapsed ? 'center' : 'left',
}}
>
{!collapsed && (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text
strong
style={{
fontSize: 18,
color: token.colorPrimary,
letterSpacing: '0.5px',
}}
>
Media Gallery
</Text>
<Text
type="secondary"
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.45)',
}}
>
Video Platform
</Text>
</Space>
)}
{collapsed && (
<PlayCircleOutlined
style={{
fontSize: 24,
color: token.colorPrimary,
}}
/>
)}
</div>
{/* Scrollable content */}
<div
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
}}
>
{/* Content Navigation Section */}
<div style={{ padding: collapsed ? '12px 0' : '12px' }}>
{/* Section header */}
{!collapsed && (
<div
onClick={() => toggleSection('content')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
cursor: 'pointer',
userSelect: 'none',
}}
>
<Text
strong
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.45)',
letterSpacing: '1px',
}}
>
CONTENT
</Text>
{sections.content ? (
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
) : (
<RightOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
)}
</div>
)}
{/* Nav items */}
{sections.content && (
<div style={{ marginTop: collapsed ? 0 : 8 }}>
{navItems.map((item) => {
const isActive = activeKey === item.key;
return (
<Tooltip
key={item.key}
title={collapsed ? item.label : ''}
placement="right"
>
<div
onClick={() => handleNavigate(item.path)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
background: isActive ? token.colorPrimary : 'transparent',
borderRadius: collapsed ? 0 : 8,
color: isActive
? '#fff'
: 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background = hoverBg;
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span style={{ fontSize: 18 }}>{item.icon}</span>
{!collapsed && (
<Text
style={{
fontSize: 14,
color: 'inherit',
fontWeight: isActive ? 500 : 400,
}}
>
{item.label}
</Text>
)}
</div>
</Tooltip>
);
})}
</div>
)}
</div>
{/* Activity Section */}
{!collapsed && (
<div style={{ padding: '12px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
<div
onClick={() => toggleSection('activity')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
cursor: 'pointer',
userSelect: 'none',
}}
>
<Text
strong
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.45)',
letterSpacing: '1px',
}}
>
MY CHATS
</Text>
{sections.activity ? (
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
) : (
<RightOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
)}
</div>
{sections.activity && (
<div
style={{
marginTop: 8,
maxHeight: 200,
overflowY: 'auto',
}}
>
{chatThreads.length === 0 ? (
<div style={{ padding: '12px 16px' }}>
<Text
type="secondary"
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.35)',
}}
>
{user ? 'No chat threads yet' : 'Sign in to see chats'}
</Text>
</div>
) : (
chatThreads.map((thread) => (
<div
key={thread.mediaId}
onClick={() => navigate(`/gallery/watch/${thread.mediaId}`)}
style={{
padding: '8px 16px',
fontSize: 12,
color: 'rgba(255,255,255,0.65)',
borderBottom: '1px solid rgba(255,255,255,0.03)',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<MessageOutlined style={{ fontSize: 12, color: token.colorPrimary }} />
<Text
ellipsis
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.85)',
fontWeight: thread.unreadCount > 0 ? 600 : 400,
flex: 1,
}}
>
{thread.videoTitle}
</Text>
{thread.unreadCount > 0 && (
<Badge count={thread.unreadCount} size="small" />
)}
</div>
{thread.lastMessage && (
<Text
type="secondary"
ellipsis
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.35)',
display: 'block',
paddingLeft: 18,
}}
>
{thread.lastMessage.userName}: {thread.lastMessage.content}
</Text>
)}
</div>
))
)}
</div>
)}
</div>
)}
{/* Online Section */}
{!collapsed && (
<div style={{ padding: '12px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
<div
onClick={() => toggleSection('online')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
cursor: 'pointer',
userSelect: 'none',
}}
>
<Text
strong
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.45)',
letterSpacing: '1px',
}}
>
ONLINE
</Text>
{sections.online ? (
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
) : (
<RightOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
)}
</div>
{sections.online && (
<div style={{ padding: '12px 16px' }}>
<Space>
<TeamOutlined style={{ color: token.colorPrimary }} />
<Text
style={{
fontSize: 13,
color: 'rgba(255,255,255,0.85)',
}}
>
Anonymous viewers
</Text>
</Space>
</div>
)}
</div>
)}
{/* Account Section */}
<div
style={{
padding: collapsed ? '12px 0' : '12px',
borderTop: '1px solid rgba(255,255,255,0.06)',
}}
>
{!collapsed && (
<div
onClick={() => toggleSection('account')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
cursor: 'pointer',
userSelect: 'none',
}}
>
<Text
strong
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.45)',
letterSpacing: '1px',
}}
>
ACCOUNT
</Text>
{sections.account ? (
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
) : (
<RightOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
)}
</div>
)}
{sections.account && (
<div style={{ marginTop: collapsed ? 0 : 8 }}>
{user ? (
<>
{/* User info */}
{!collapsed && (
<div
style={{
padding: '12px 16px',
marginBottom: 8,
background: userInfoBg,
borderRadius: 8,
}}
>
<Space>
<UserOutlined style={{ color: token.colorPrimary }} />
<Text
style={{
fontSize: 13,
color: 'rgba(255,255,255,0.85)',
}}
>
{user.email}
</Text>
</Space>
</div>
)}
{/* My Stats */}
{(() => {
const isActive = location.pathname === '/gallery/my-stats';
return (
<Tooltip title={collapsed ? 'My Stats' : ''} placement="right">
<div
onClick={() => navigate('/gallery/my-stats')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
background: isActive ? token.colorPrimary : 'transparent',
borderRadius: collapsed ? 0 : 8,
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<BarChartOutlined style={{ fontSize: 18 }} />
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit', fontWeight: isActive ? 500 : 400 }}>My Stats</Text>}
</div>
</Tooltip>
);
})()}
{/* Settings */}
{(() => {
const isActive = location.pathname === '/gallery/my-settings';
return (
<Tooltip title={collapsed ? 'Settings' : ''} placement="right">
<div
onClick={() => navigate('/gallery/my-settings')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
background: isActive ? token.colorPrimary : 'transparent',
borderRadius: collapsed ? 0 : 8,
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<SettingOutlined style={{ fontSize: 18 }} />
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit', fontWeight: isActive ? 500 : 400 }}>Settings</Text>}
</div>
</Tooltip>
);
})()}
{/* Sign Out */}
<Tooltip title={collapsed ? 'Sign Out' : ''} placement="right">
<div
onClick={handleLogout}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
borderRadius: collapsed ? 0 : 8,
color: 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<LogoutOutlined style={{ fontSize: 18 }} />
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>Sign Out</Text>}
</div>
</Tooltip>
</>
) : (
// Sign In button
<Tooltip title={collapsed ? 'Sign In' : ''} placement="right">
<div
onClick={() => navigate('/auth/login')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
borderRadius: collapsed ? 0 : 8,
color: token.colorPrimary,
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<LoginOutlined style={{ fontSize: 18 }} />
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>Sign In</Text>}
</div>
</Tooltip>
)}
</div>
)}
</div>
</div>
{/* Footer with collapse toggle */}
<div
style={{
padding: collapsed ? '12px 0' : '12px 16px',
borderTop: '1px solid rgba(255,255,255,0.06)',
textAlign: 'center',
}}
>
{!collapsed && (
<Text
type="secondary"
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.35)',
display: 'block',
marginBottom: 8,
}}
>
v2.0.0
</Text>
)}
<div
onClick={() => setCollapsed(!collapsed)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px',
cursor: 'pointer',
borderRadius: 8,
color: 'rgba(255,255,255,0.65)',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = hoverBg;
e.currentTarget.style.color = token.colorPrimary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'rgba(255,255,255,0.65)';
}}
>
{collapsed ? (
<MenuUnfoldOutlined style={{ fontSize: 18 }} />
) : (
<>
<MenuFoldOutlined style={{ fontSize: 16, marginRight: 8 }} />
<Text style={{ fontSize: 13, color: 'inherit' }}>Collapse</Text>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,178 @@
import { Card, Typography, Space, theme } from 'antd';
import {
PlayCircleOutlined,
EyeOutlined,
UnorderedListOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import type { PlaylistSummary } from '@/types/media';
interface PlaylistCardProps {
playlist: PlaylistSummary;
}
function formatDuration(totalSeconds: number): string {
if (!totalSeconds) return '0:00';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatCount(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
}
export default function PlaylistCard({ playlist }: PlaylistCardProps) {
const { token } = theme.useToken();
const navigate = useNavigate();
return (
<Card
hoverable
style={{
borderRadius: 12,
overflow: 'hidden',
border: `1px solid ${token.colorBorderSecondary}`,
transition: 'all 0.2s ease',
cursor: 'pointer',
width: '100%',
}}
styles={{ body: { padding: 12 } }}
onClick={() => navigate(`/gallery/curated/${playlist.id}`)}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}}
cover={
<div
style={{
position: 'relative',
paddingTop: '56.25%',
background: '#000',
overflow: 'hidden',
}}
>
{playlist.thumbnailUrl ? (
<img
src={`/media${playlist.thumbnailUrl}`}
alt={playlist.name}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: `linear-gradient(135deg, ${token.colorPrimary}22, ${token.colorPrimary}44)`,
}}
>
<UnorderedListOutlined style={{ fontSize: 48, color: token.colorPrimary }} />
</div>
)}
{/* Playlist overlay badge */}
<div
style={{
position: 'absolute',
bottom: 0,
right: 0,
background: 'rgba(0,0,0,0.85)',
color: '#fff',
padding: '4px 10px',
fontSize: 12,
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 4,
borderTopLeftRadius: 8,
}}
>
<PlayCircleOutlined />
{playlist.videoCount} videos
</div>
</div>
}
>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Typography.Text
strong
style={{
fontSize: 14,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorText,
}}
title={playlist.name}
>
{playlist.name}
</Typography.Text>
<Typography.Text
style={{
fontSize: 12,
color: token.colorTextSecondary,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{playlist.creator.name || playlist.creator.email}
</Typography.Text>
{playlist.description && (
<Typography.Text
style={{
fontSize: 12,
color: token.colorTextTertiary,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical' as const,
overflow: 'hidden',
lineHeight: '1.4',
}}
>
{playlist.description}
</Typography.Text>
)}
<Space size={12} style={{ fontSize: 12, color: token.colorTextSecondary }}>
<Space size={4}>
<ClockCircleOutlined />
<span>{formatDuration(playlist.totalDurationSeconds)}</span>
</Space>
<Space size={4}>
<EyeOutlined />
<span>{formatCount(playlist.viewCount)}</span>
</Space>
</Space>
</Space>
</Card>
);
}

View File

@ -0,0 +1,228 @@
import { useEffect, useRef } from 'react';
import { Typography, Space, theme } from 'antd';
import { PlayCircleOutlined, ClockCircleOutlined } from '@ant-design/icons';
import type { PlaylistVideoItem } from '@/types/media';
const { Text } = Typography;
interface PlaylistSidebarPanelProps {
playlistName: string;
description?: string | null;
videos: PlaylistVideoItem[];
currentVideoId: number | null;
onVideoSelect: (mediaId: number) => void;
}
function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export default function PlaylistSidebarPanel({
playlistName,
description,
videos,
currentVideoId,
onVideoSelect,
}: PlaylistSidebarPanelProps) {
const { token } = theme.useToken();
const currentRef = useRef<HTMLDivElement>(null);
// Auto-scroll to the current playing video
useEffect(() => {
if (currentRef.current) {
currentRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [currentVideoId]);
const currentIndex = videos.findIndex((v) => v.mediaId === currentVideoId);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: token.colorBgContainer,
borderLeft: `1px solid rgba(255,255,255,0.06)`,
}}
>
{/* Header */}
<div
style={{
padding: '16px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
}}
>
<Text strong style={{ fontSize: 15, display: 'block', marginBottom: 4 }}>
{playlistName}
</Text>
{description && (
<Text
type="secondary"
style={{
fontSize: 12,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical' as const,
overflow: 'hidden',
marginBottom: 4,
}}
>
{description}
</Text>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{currentIndex + 1} / {videos.length} videos
</Text>
</div>
{/* Video list */}
<div
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
}}
>
{videos.map((item, index) => {
const isCurrent = item.mediaId === currentVideoId;
const isNext = index === currentIndex + 1;
const title =
item.video.title || item.video.filename.replace(/\.[^/.]+$/, '');
return (
<div
key={item.id}
ref={isCurrent ? currentRef : undefined}
onClick={() => onVideoSelect(item.mediaId)}
style={{
display: 'flex',
gap: 10,
padding: '10px 16px',
cursor: 'pointer',
background: isCurrent
? `${token.colorPrimary}22`
: 'transparent',
borderLeft: isCurrent
? `3px solid ${token.colorPrimary}`
: '3px solid transparent',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!isCurrent) {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
}
}}
onMouseLeave={(e) => {
if (!isCurrent) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{/* Position number */}
<div
style={{
width: 24,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{isCurrent ? (
<PlayCircleOutlined
style={{ color: token.colorPrimary, fontSize: 16 }}
/>
) : (
<Text
type="secondary"
style={{ fontSize: 12, fontWeight: 500 }}
>
{index + 1}
</Text>
)}
</div>
{/* Thumbnail */}
<div
style={{
width: 64,
height: 36,
flexShrink: 0,
borderRadius: 4,
overflow: 'hidden',
background: '#000',
}}
>
{item.video.thumbnailUrl ? (
<img
src={`/media${item.video.thumbnailUrl}`}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#444',
fontSize: 16,
}}
>
<PlayCircleOutlined />
</div>
)}
</div>
{/* Video info */}
<div style={{ flex: 1, minWidth: 0 }}>
{isNext && (
<Text
style={{
fontSize: 10,
color: token.colorPrimary,
textTransform: 'uppercase',
fontWeight: 600,
letterSpacing: '0.5px',
display: 'block',
marginBottom: 2,
}}
>
Up Next
</Text>
)}
<Text
ellipsis
style={{
fontSize: 13,
display: 'block',
fontWeight: isCurrent ? 500 : 400,
}}
>
{title}
</Text>
<Space size={8} style={{ fontSize: 11 }}>
<Text type="secondary">
<ClockCircleOutlined style={{ marginRight: 3 }} />
{formatDuration(item.video.durationSeconds)}
</Text>
</Space>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { theme } from 'antd';
interface Reaction {
id: number;
reactionType: string;
emoji: string;
videoTimestamp: number;
createdAt: string;
}
interface ProgressBarMarkersProps {
videoId: number;
durationSeconds: number;
playerRef?: React.RefObject<HTMLVideoElement>; // Optional, for future enhancements
}
// Color mapping for reaction types
const REACTION_COLORS: Record<string, string> = {
like: '#1890ff', // Blue
love: '#f5222d', // Red
laugh: '#faad14', // Orange
wow: '#722ed1', // Purple
sad: '#13c2c2', // Cyan
angry: '#ff4d4f', // Dark red
};
export default function ProgressBarMarkers({
videoId,
durationSeconds,
playerRef,
}: ProgressBarMarkersProps) {
const { token } = theme.useToken();
const [reactions, setReactions] = useState<Reaction[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchReactions();
}, [videoId]);
const fetchReactions = async () => {
try {
setLoading(true);
// Use relative URL to go through nginx proxy
const response = await fetch(`/media/reactions/${videoId}/chat?limit=500`);
if (!response.ok) {
throw new Error('Failed to fetch reactions');
}
const data = await response.json();
setReactions(data.reactions || []);
} catch (error) {
console.error('Failed to fetch reactions:', error);
} finally {
setLoading(false);
}
};
if (loading || reactions.length === 0 || !durationSeconds) {
return null;
}
// Calculate marker positions as percentages
const markers = reactions.map((reaction) => ({
...reaction,
position: (reaction.videoTimestamp / durationSeconds) * 100,
color: REACTION_COLORS[reaction.reactionType] || token.colorPrimary,
}));
// Group markers that are very close together (within 1% of video duration)
const groupedMarkers: typeof markers = [];
const GROUPING_THRESHOLD = 1; // 1% of video duration
markers.forEach((marker) => {
const existingGroup = groupedMarkers.find(
(m) => Math.abs(m.position - marker.position) < GROUPING_THRESHOLD
);
if (existingGroup) {
// Use the most recent reaction color
if (new Date(marker.createdAt) > new Date(existingGroup.createdAt)) {
existingGroup.color = marker.color;
}
} else {
groupedMarkers.push({ ...marker });
}
});
return (
<div
style={{
position: 'absolute',
bottom: 45, // Position above video controls
left: 0,
right: 0,
height: 8,
pointerEvents: 'none',
zIndex: 5,
}}
>
{groupedMarkers.map((marker, index) => (
<div
key={`${marker.id}-${index}`}
style={{
position: 'absolute',
left: `${marker.position}%`,
top: 0,
width: 4,
height: 8,
background: marker.color,
borderRadius: 2,
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.5)',
transform: 'translateX(-50%)',
transition: 'transform 0.2s',
}}
title={`${marker.emoji} at ${Math.floor(marker.videoTimestamp / 60)}:${String(
marker.videoTimestamp % 60
).padStart(2, '0')}`}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateX(-50%) scale(1.5)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateX(-50%) scale(1)';
}}
/>
))}
</div>
);
}

View File

@ -0,0 +1,348 @@
import { useState, useRef, useEffect } from 'react';
import { Card, Tag, Space, Typography, theme, Modal } from 'antd';
import { PlayCircleOutlined, LikeOutlined, EyeOutlined, CommentOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { hexToRgba } from '@/utils/color';
interface PublicVideoCardProps {
video: {
id: number;
filename: string;
category: string | null;
durationSeconds: number | null;
quality: string | null;
orientation: string | null;
thumbnailPath: string | null;
viewCount: number;
upvoteCount: number;
commentCount: number;
isLocked: boolean;
createdAt: string;
};
}
export default function PublicVideoCard({ video }: PublicVideoCardProps) {
const { token } = theme.useToken();
const navigate = useNavigate();
const { expandVideo } = useExpandedVideo();
// Hover video preview state
const [hovering, setHovering] = useState(false);
const [thumbnailError, setThumbnailError] = useState(false);
const hoverTimeout = useRef<number | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const formatDuration = (seconds: number | null) => {
if (!seconds) return '\u2014';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatCount = (count: number | undefined | null) => {
if (!count && count !== 0) return '0';
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toString();
};
// Extract title from filename (remove extension)
const title = video.filename.replace(/\.[^/.]+$/, '');
// Cleanup hover timeout on unmount
useEffect(() => {
return () => {
if (hoverTimeout.current) {
clearTimeout(hoverTimeout.current);
}
};
}, []);
// Handle mouse enter with debounce to prevent connection saturation
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
// Apply card hover effects
const card = e.currentTarget;
card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
card.style.transform = 'translateY(-2px)';
// Debounce: only load video if user hovers for 200ms
// Prevents connection saturation when quickly scanning the grid
hoverTimeout.current = setTimeout(() => {
setHovering(true);
}, 200);
};
// Handle mouse leave - cancel pending video load
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
// Remove card hover effects
const card = e.currentTarget;
card.style.boxShadow = 'none';
card.style.transform = 'translateY(0)';
// Cancel pending hover timer
if (hoverTimeout.current) {
clearTimeout(hoverTimeout.current);
hoverTimeout.current = null;
}
// Abort any in-flight video load to free the connection slot
const video = videoRef.current;
if (video) {
video.pause();
video.removeAttribute('src');
video.load();
}
setHovering(false);
};
const handleCardClick = () => {
if (video.isLocked) {
// Show login modal for locked content
Modal.confirm({
title: 'Login Required',
content: 'This video is locked. Please log in to watch.',
okText: 'Go to Login',
cancelText: 'Cancel',
onOk: () => {
navigate('/login', { state: { from: `/gallery/watch/${video.id}` } });
},
});
} else {
expandVideo(video.id, video);
}
};
return (
<Card
hoverable
style={{
borderRadius: 12,
overflow: 'hidden',
border: `1px solid ${token.colorBorderSecondary}`,
transition: 'all 0.2s ease',
cursor: 'pointer',
}}
styles={{ body: { padding: 12 } }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleCardClick}
cover={
<div
style={{
position: 'relative',
paddingTop: video.orientation === 'V' ? '177.78%' : '56.25%', // 9:16 or 16:9
background: '#000',
overflow: 'hidden',
}}
>
{/* Thumbnail or Video Preview */}
{hovering && !video.isLocked ? (
<video
ref={videoRef}
src={`/media/public/${video.id}/stream`}
autoPlay
loop
muted
playsInline
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : video.thumbnailPath && !thumbnailError ? (
<img
src={`/media/public/${video.id}/thumbnail`}
alt={title}
onError={() => setThumbnailError(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 48,
color: '#666',
}}
>
{video.orientation === 'V' ? '\uD83D\uDCF1' : '\uD83C\uDFAC'}
</div>
)}
{/* Lock overlay */}
{video.isLocked && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(2px)',
}}
>
<LockOutlined style={{ fontSize: 48, color: token.colorPrimary }} />
</div>
)}
{/* Play button overlay */}
{!video.isLocked && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.3)',
opacity: 0,
transition: 'opacity 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0';
}}
>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: hexToRgba(token.colorPrimary, 0.9),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<PlayCircleOutlined style={{ fontSize: 32, color: '#fff' }} />
</div>
</div>
)}
{/* Duration badge */}
{video.durationSeconds && (
<div
style={{
position: 'absolute',
bottom: 8,
right: 8,
background: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDuration(video.durationSeconds)}
</div>
)}
{/* Quality badge */}
{video.quality && (
<Tag
style={{
position: 'absolute',
top: 8,
left: 8,
margin: 0,
background: token.colorPrimary,
border: 'none',
color: '#fff',
fontWeight: 600,
}}
>
{video.quality}
</Tag>
)}
{/* Category badge */}
{video.category && (
<Tag
style={{
position: 'absolute',
top: 8,
right: 8,
margin: 0,
background: 'rgba(255, 255, 255, 0.15)',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
>
{video.category}
</Tag>
)}
</div>
}
>
{/* Card content */}
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{/* Title */}
<Typography.Text
strong
style={{
fontSize: 14,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorText,
}}
title={title}
>
{title}
</Typography.Text>
{/* Engagement metrics */}
<Space size={16} style={{ fontSize: 12, color: token.colorTextSecondary }}>
<Space size={4}>
<LikeOutlined />
<span>{formatCount(video.upvoteCount)}</span>
</Space>
<Space size={4}>
<EyeOutlined />
<span>{formatCount(video.viewCount)}</span>
</Space>
<Space size={4}>
<CommentOutlined />
<span>{formatCount(video.commentCount)}</span>
</Space>
</Space>
</Space>
</Card>
);
}

View File

@ -0,0 +1,64 @@
import { useState } from 'react';
import { Modal, Select, message } from 'antd';
import { mediaApi } from '@/lib/media-api';
interface PublishModalProps {
open: boolean;
videoIds: number[];
onSuccess: () => void;
onCancel: () => void;
}
export default function PublishModal({ open, videoIds, onSuccess, onCancel }: PublishModalProps) {
const [category, setCategory] = useState<string>('videos');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!category) {
message.error('Please select a category');
return;
}
setLoading(true);
try {
await mediaApi.post('/videos/bulk-publish', {
videoIds,
category,
});
message.success(`Successfully published ${videoIds.length} video(s) to ${category}`);
onSuccess();
setCategory('videos');
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to publish videos');
} finally {
setLoading(false);
}
};
return (
<Modal
title={`Publish ${videoIds.length} Video(s) to Gallery`}
open={open}
onOk={handleSubmit}
onCancel={onCancel}
confirmLoading={loading}
okText="Publish"
>
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', marginBottom: 8 }}>Select Category:</label>
<Select
value={category}
onChange={setCategory}
style={{ width: '100%' }}
options={[
{ value: 'videos', label: 'Videos' },
{ value: 'curated', label: 'Curated' },
{ value: 'compilations', label: 'Compilations' },
{ value: 'playback', label: 'Playback' },
{ value: 'highlights', label: 'Highlights' },
]}
/>
</div>
</Modal>
);
}

View File

@ -0,0 +1,203 @@
import { Modal, Spin, Statistic, Row, Col, Empty, Tag, Button, Alert, Skeleton } from 'antd';
import {
EyeOutlined,
UserOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useEffect, useState } from 'react';
import { mediaApi } from '@/lib/media-api';
import type { VideoAnalytics } from '@/types/media';
interface QuickAnalyticsModalProps {
videoId: number;
videoTitle: string;
open: boolean;
onClose: () => void;
}
export default function QuickAnalyticsModal({
videoId,
videoTitle,
open,
onClose,
}: QuickAnalyticsModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [analytics, setAnalytics] = useState<VideoAnalytics | null>(null);
useEffect(() => {
if (open && videoId) {
fetchAnalytics();
}
}, [open, videoId]);
const fetchAnalytics = async () => {
try {
setLoading(true);
setError(null);
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
setAnalytics(response.data);
} catch (error: any) {
console.error('Failed to fetch analytics:', error);
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
} finally {
setLoading(false);
}
};
const formatWatchTime = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
};
return (
<Modal
title={`Analytics: ${videoTitle}`}
open={open}
onCancel={onClose}
footer={null}
width={800}
aria-label="Video analytics modal"
>
{error ? (
<div style={{ padding: 24 }}>
<Alert
type="error"
message="Failed to Load Analytics"
description={error}
showIcon
action={
<Button
size="small"
type="primary"
onClick={fetchAnalytics}
icon={<ReloadOutlined />}
>
Retry
</Button>
}
/>
</div>
) : loading ? (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
{[1, 2, 3, 4].map((i) => (
<Col span={6} key={i}>
<Skeleton.Button active block style={{ height: 80 }} />
</Col>
))}
</Row>
<Skeleton active paragraph={{ rows: 4 }} />
</div>
) : analytics ? (
<div>
{/* Overview stats */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Statistic
title="Total Views"
value={analytics.overview.totalViews}
prefix={<EyeOutlined />}
/>
</Col>
<Col span={6}>
<Statistic
title="Unique Viewers"
value={analytics.overview.uniqueViewers}
prefix={<UserOutlined />}
/>
</Col>
<Col span={6}>
<Statistic
title="Avg Watch Time"
value={formatWatchTime(Math.round(analytics.overview.averageWatchTime))}
prefix={<ClockCircleOutlined />}
/>
</Col>
<Col span={6}>
<Statistic
title="Completion Rate"
value={analytics.overview.completionRate}
precision={1}
suffix="%"
prefix={<CheckCircleOutlined />}
/>
</Col>
</Row>
{/* Top Referrers */}
<div style={{ marginBottom: 24 }}>
<h4>Top Referrers</h4>
{analytics.topReferrers.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{analytics.topReferrers.slice(0, 5).map((item, index) => (
<Tag key={index} color="blue">
{item.referer} ({item.count})
</Tag>
))}
</div>
) : (
<Empty description="No referrer data" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
{/* Registered Viewers */}
<div>
<h4>Recent Registered Viewers ({analytics.registeredViewers.length})</h4>
{analytics.registeredViewers.length > 0 ? (
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
<table style={{ width: '100%', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f5f5f5' }}>
<th style={{ padding: 8, textAlign: 'left' }}>User</th>
<th style={{ padding: 8, textAlign: 'left' }}>Email</th>
<th style={{ padding: 8, textAlign: 'right' }}>Watch Time</th>
<th style={{ padding: 8, textAlign: 'center' }}>Status</th>
</tr>
</thead>
<tbody>
{analytics.registeredViewers.slice(0, 10).map((viewer) => (
<tr key={viewer.userId} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: 8 }}>{viewer.userName || 'N/A'}</td>
<td style={{ padding: 8, fontSize: 11 }}>{viewer.userEmail}</td>
<td style={{ padding: 8, textAlign: 'right' }}>
{formatWatchTime(viewer.watchTime)}
</td>
<td style={{ padding: 8, textAlign: 'center' }}>
{viewer.completed ? (
<Tag color="success">Completed</Tag>
) : (
<Tag color="default">Partial</Tag>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<Empty description="No registered viewers yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
{/* Total watch time summary */}
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<strong>Total Watch Time:</strong> {formatWatchTime(analytics.overview.totalWatchTime)}
</div>
</div>
) : (
<Empty description="No analytics data available" />
)}
</Modal>
);
}

View File

@ -0,0 +1,148 @@
import { useState } from 'react';
import { Space, Button, message, theme } from 'antd';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color';
interface ReactionButtonsProps {
videoId: number;
currentTime: number;
}
// Standard emoji reactions
const REACTIONS = [
{ emoji: '👍', name: 'like', label: 'Like' },
{ emoji: '❤️', name: 'love', label: 'Love' },
{ emoji: '😂', name: 'laugh', label: 'Laugh' },
{ emoji: '😮', name: 'wow', label: 'Wow' },
{ emoji: '😢', name: 'sad', label: 'Sad' },
{ emoji: '😡', name: 'angry', label: 'Angry' },
];
interface FloatingEmoji {
id: number;
emoji: string;
x: number;
}
export default function ReactionButtons({ videoId, currentTime }: ReactionButtonsProps) {
const { token } = theme.useToken();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [floatingEmojis, setFloatingEmojis] = useState<FloatingEmoji[]>([]);
const [loading, setLoading] = useState(false);
const hoverBg = hexToRgba(token.colorPrimary, 0.1);
const handleReaction = async (reactionType: string, emoji: string) => {
// Check if user is logged in
if (!isAuthenticated) {
message.warning('Please log in to add reactions');
return;
}
try {
setLoading(true);
await mediaPublicApi.post('/reactions', {
mediaId: videoId,
reactionType,
videoTimestamp: Math.floor(currentTime),
});
// Add floating emoji animation
const newEmoji: FloatingEmoji = {
id: Date.now(),
emoji,
x: Math.random() * 80 + 10, // Random x position (10-90%)
};
setFloatingEmojis((prev) => [...prev, newEmoji]);
// Remove emoji after animation completes (2 seconds)
setTimeout(() => {
setFloatingEmojis((prev) => prev.filter((e) => e.id !== newEmoji.id));
}, 2000);
message.success(`${emoji} reaction added!`);
} catch (error: any) {
if (error.response?.status === 401) {
message.error('Please log in to add reactions');
} else {
message.error('Failed to add reaction');
}
console.error('Failed to add reaction:', error);
} finally {
setLoading(false);
}
};
return (
<div style={{ position: 'relative' }}>
{/* Reaction buttons */}
<Space size={12} wrap>
{REACTIONS.map((reaction) => (
<Button
key={reaction.name}
type="text"
size="large"
disabled={loading}
onClick={() => handleReaction(reaction.name, reaction.emoji)}
style={{
fontSize: 24,
padding: '4px 12px',
height: 'auto',
borderRadius: 8,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.2)';
e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.background = 'transparent';
}}
title={reaction.label}
>
{reaction.emoji}
</Button>
))}
</Space>
{/* Floating emojis */}
{floatingEmojis.map((floatingEmoji) => (
<div
key={floatingEmoji.id}
style={{
position: 'fixed',
left: `${floatingEmoji.x}%`,
bottom: '20%',
fontSize: 48,
pointerEvents: 'none',
animation: 'float-up 2s ease-out forwards',
zIndex: 1000,
}}
>
{floatingEmoji.emoji}
</div>
))}
{/* CSS Animation */}
<style>{`
@keyframes float-up {
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
50% {
transform: translateY(-100px) scale(1.2);
opacity: 1;
}
100% {
transform: translateY(-200px) scale(0.8);
opacity: 0;
}
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,250 @@
import { useState, useEffect } from 'react';
import { List, Typography, Space, Spin, theme, Empty } from 'antd';
import { PlayCircleOutlined, EyeOutlined } from '@ant-design/icons';
import { mediaPublicApi } from '@/lib/media-public-api';
const { Text } = Typography;
interface Video {
id: number;
filename: string;
category: string;
durationSeconds: number | null;
quality: string | null;
orientation: string | null;
thumbnailPath: string | null;
viewCount: number;
upvoteCount: number;
commentCount: number;
isLocked: boolean;
createdAt: string;
}
interface RelatedVideosListProps {
currentVideoId: number;
currentCategory?: string;
onVideoSelect: (video: Video) => void;
maxVideos?: number;
}
export default function RelatedVideosList({
currentVideoId,
currentCategory,
onVideoSelect,
maxVideos = 5,
}: RelatedVideosListProps) {
const { token } = theme.useToken();
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchRelatedVideos();
}, [currentVideoId, currentCategory]);
const fetchRelatedVideos = async () => {
try {
setLoading(true);
// Fetch videos from same category or recent videos
const params: any = {
limit: maxVideos + 1, // Get one extra to exclude current video
sort: 'recent',
};
if (currentCategory) {
params.category = currentCategory;
}
const response = await mediaPublicApi.get('/public', { params });
// Filter out current video
const relatedVideos = response.data.videos
.filter((v: Video) => v.id !== currentVideoId)
.slice(0, maxVideos);
setVideos(relatedVideos);
} catch (error) {
console.error('Failed to fetch related videos:', error);
} finally {
setLoading(false);
}
};
const formatDuration = (seconds: number | null) => {
if (!seconds) return '—';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatCount = (count: number) => {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toString();
};
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin size="small" tip="Loading related videos..." />
</div>
);
}
if (videos.length === 0) {
return (
<div style={{ padding: 24 }}>
<Empty
description="No related videos"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
);
}
return (
<div>
<div
style={{
padding: '12px 16px',
borderBottom: `1px solid ${token.colorBorder}`,
background: token.colorBgLayout,
}}
>
<Text strong>Related Videos</Text>
</div>
<List
dataSource={videos}
renderItem={(video) => {
const title = video.filename.replace(/\.[^/.]+$/, '');
return (
<List.Item
key={video.id}
onClick={() => onVideoSelect(video)}
style={{
cursor: 'pointer',
padding: 12,
transition: 'background 0.2s',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = token.colorBgTextHover;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<Space size={12} style={{ width: '100%' }}>
{/* Thumbnail */}
<div
style={{
position: 'relative',
width: 120,
height: video.orientation === 'V' ? 160 : 68,
borderRadius: 8,
overflow: 'hidden',
flexShrink: 0,
background: '#000',
}}
>
{video.thumbnailPath ? (
<img
src={`/api/media/public/${video.id}/thumbnail`}
alt={title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#666',
}}
>
{video.orientation === 'V' ? '📱' : '🎬'}
</div>
)}
{/* Play icon overlay */}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.3)',
}}
>
<PlayCircleOutlined style={{ fontSize: 24, color: '#fff' }} />
</div>
{/* Duration badge */}
{video.durationSeconds && (
<div
style={{
position: 'absolute',
bottom: 4,
right: 4,
background: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
padding: '2px 6px',
borderRadius: 4,
fontSize: 10,
fontWeight: 500,
}}
>
{formatDuration(video.durationSeconds)}
</div>
)}
</div>
{/* Info */}
<div style={{ flex: 1, overflow: 'hidden' }}>
<Text
strong
style={{
display: 'block',
fontSize: 13,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: 4,
}}
title={title}
>
{title}
</Text>
<Space size={8} style={{ fontSize: 11 }}>
{video.quality && (
<Text type="secondary">{video.quality}</Text>
)}
<Space size={4}>
<EyeOutlined />
<Text type="secondary">{formatCount(video.viewCount)}</Text>
</Space>
</Space>
</div>
</Space>
</List.Item>
);
}}
/>
</div>
);
}

View File

@ -0,0 +1,54 @@
import { Tag } from 'antd';
import { ClockCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
interface ScheduleBadgeProps {
scheduledPublishAt?: string | null;
scheduledUnpublishAt?: string | null;
isPublished?: boolean;
style?: React.CSSProperties;
}
export default function ScheduleBadge({
scheduledPublishAt,
scheduledUnpublishAt,
isPublished,
style,
}: ScheduleBadgeProps) {
// Determine which schedule to show (prioritize publish if not yet published)
const showPublish = !isPublished && scheduledPublishAt;
const showUnpublish = isPublished && scheduledUnpublishAt;
if (!showPublish && !showUnpublish) {
return null;
}
const date = showPublish ? scheduledPublishAt : scheduledUnpublishAt;
const action = showPublish ? 'Publish' : 'Unpublish';
const color = showPublish ? 'blue' : 'orange';
const icon = showPublish ? <ClockCircleOutlined /> : <CheckCircleOutlined />;
const relativeTime = dayjs(date).fromNow();
const absoluteTime = dayjs(date).format('MMM D, YYYY HH:mm');
return (
<Tag
color={color}
icon={icon}
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 10,
fontSize: 11,
...style,
}}
title={`${action} scheduled for ${absoluteTime}`}
>
{action} {relativeTime}
</Tag>
);
}

View File

@ -0,0 +1,244 @@
import { Drawer, Calendar, Badge, List, Tag, Button, Space, message, Empty, Alert, Skeleton } from 'antd';
import type { CalendarProps } from 'antd';
import { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import { mediaApi } from '@/lib/media-api';
interface ScheduleEvent {
jobId: string;
videoId: number;
videoTitle: string | null;
action: 'publish' | 'unpublish';
scheduledFor: string;
status: string;
}
interface ScheduleCalendarDrawerProps {
open: boolean;
onClose: () => void;
onRefresh?: () => void;
}
export default function ScheduleCalendarDrawer({
open,
onClose,
onRefresh,
}: ScheduleCalendarDrawerProps) {
const [schedules, setSchedules] = useState<ScheduleEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [selectedDateEvents, setSelectedDateEvents] = useState<ScheduleEvent[]>([]);
useEffect(() => {
if (open) {
fetchSchedules();
}
}, [open]);
useEffect(() => {
// Filter events for selected date
const dayEvents = schedules.filter((event) =>
dayjs(event.scheduledFor).isSame(selectedDate, 'day')
);
setSelectedDateEvents(dayEvents);
}, [selectedDate, schedules]);
const fetchSchedules = async () => {
try {
setLoading(true);
setError(null);
const response = await mediaApi.get('/videos/schedules/upcoming', {
params: { limit: 100 },
});
setSchedules(response.data.schedules || []);
} catch (error: any) {
console.error('Failed to fetch schedules:', error);
setError(error.response?.data?.message || 'Failed to load schedules. Please try again.');
} finally {
setLoading(false);
}
};
const handleCancelSchedule = async (videoId: number, action: 'publish' | 'unpublish') => {
try {
await mediaApi.delete(`/videos/${videoId}/schedule/${action}`);
message.success(`${action} schedule cancelled`);
fetchSchedules();
onRefresh?.();
} catch (error: any) {
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`);
}
};
// Get events for a specific date
const getListData = (value: Dayjs) => {
const dayEvents = schedules.filter((event) =>
dayjs(event.scheduledFor).isSame(value, 'day')
);
return dayEvents.map((event) => ({
type: event.action === 'publish' ? 'success' : 'warning',
content: `${event.videoTitle || `Video ${event.videoId}`} - ${event.action}`,
time: dayjs(event.scheduledFor).format('HH:mm'),
}));
};
// Render cell with badge count (new API)
const cellRender: CalendarProps<Dayjs>['cellRender'] = (current, info) => {
if (info.type !== 'date') return info.originNode;
const listData = getListData(current);
return (
<div style={{ minHeight: 40 }}>
{listData.length > 0 && (
<div style={{ textAlign: 'center' }}>
<Badge
count={listData.length}
style={{
backgroundColor: listData.some((item) => item.type === 'success') ? '#52c41a' : '#faad14',
}}
/>
</div>
)}
</div>
);
};
return (
<Drawer
title={
<Space>
<CalendarOutlined />
Scheduled Publishing Calendar
</Space>
}
open={open}
onClose={onClose}
placement="right"
width={700}
mask={false}
destroyOnClose={false}
styles={{
body: {
padding: 24,
overflowY: 'auto'
}
}}
aria-label="Scheduled publishing calendar"
>
<div>
{error ? (
<div style={{ paddingBottom: 24 }}>
<Alert
type="error"
message="Failed to Load Schedules"
description={error}
showIcon
action={
<Button
size="small"
type="primary"
onClick={fetchSchedules}
icon={<ReloadOutlined />}
>
Retry
</Button>
}
/>
</div>
) : loading ? (
<div>
<Skeleton active paragraph={{ rows: 2 }} />
<Skeleton.Button active block style={{ height: 400, marginTop: 16 }} />
<Skeleton active paragraph={{ rows: 3 }} style={{ marginTop: 16 }} />
</div>
) : (
<>
{/* Stats */}
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<Space size="large">
<span>
<strong>Total Scheduled:</strong> {schedules.length}
</span>
<span>
<strong>Publish:</strong>{' '}
{schedules.filter((s) => s.action === 'publish').length}
</span>
<span>
<strong>Unpublish:</strong>{' '}
{schedules.filter((s) => s.action === 'unpublish').length}
</span>
</Space>
</div>
{/* Calendar */}
<Calendar
cellRender={cellRender}
onSelect={setSelectedDate}
value={selectedDate}
/>
{/* Events for selected date */}
<div style={{ marginTop: 16 }}>
<h4>
Events on {selectedDate.format('YYYY-MM-DD')} ({selectedDateEvents.length})
</h4>
{selectedDateEvents.length > 0 ? (
<List
size="small"
dataSource={selectedDateEvents}
renderItem={(event) => (
<List.Item
actions={[
<Button
key="cancel"
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleCancelSchedule(event.videoId, event.action)}
aria-label={`Cancel ${event.action} schedule for ${event.videoTitle || `Video ${event.videoId}`}`}
>
Cancel
</Button>,
]}
>
<List.Item.Meta
avatar={<ClockCircleOutlined style={{ fontSize: 20 }} />}
title={
<Space>
<span>{event.videoTitle || `Video ${event.videoId}`}</span>
<Tag color={event.action === 'publish' ? 'success' : 'warning'}>
{event.action}
</Tag>
</Space>
}
description={`Scheduled for ${dayjs(event.scheduledFor).format('HH:mm:ss')} (${event.status})`}
/>
</List.Item>
)}
/>
) : (
<Empty description="No events on this date" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
{/* Refresh button */}
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Button
onClick={fetchSchedules}
loading={loading}
icon={<ReloadOutlined />}
aria-label="Refresh calendar"
>
Refresh
</Button>
</div>
</>
)}
</div>
</Drawer>
);
}

View File

@ -0,0 +1,304 @@
import { Modal, DatePicker, Select, Space, Alert, Switch, message } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { mediaApi } from '@/lib/media-api';
import type { Video } from '@/types/media';
dayjs.extend(utc);
dayjs.extend(timezone);
interface SchedulePublishModalProps {
video: Video | null;
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
// Common timezones
const TIMEZONES = [
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
{ value: 'America/New_York', label: 'EST (Eastern Standard Time)' },
{ value: 'America/Chicago', label: 'CST (Central Standard Time)' },
{ value: 'America/Denver', label: 'MST (Mountain Standard Time)' },
{ value: 'America/Los_Angeles', label: 'PST (Pacific Standard Time)' },
{ value: 'America/Toronto', label: 'Toronto (Canada)' },
{ value: 'America/Vancouver', label: 'Vancouver (Canada)' },
{ value: 'Europe/London', label: 'GMT (London)' },
{ value: 'Europe/Paris', label: 'CET (Paris)' },
{ value: 'Asia/Tokyo', label: 'JST (Tokyo)' },
{ value: 'Australia/Sydney', label: 'AEDT (Sydney)' },
];
export default function SchedulePublishModal({
video,
open,
onClose,
onSuccess,
}: SchedulePublishModalProps) {
const [publishNow, setPublishNow] = useState(false);
const [publishAt, setPublishAt] = useState<Dayjs | null>(null);
const [selectedTimezone, setSelectedTimezone] = useState<string>('UTC');
const [unpublishEnabled, setUnpublishEnabled] = useState(false);
const [unpublishAt, setUnpublishAt] = useState<Dayjs | null>(null);
const [loading, setLoading] = useState(false);
// Reset state when modal opens
useEffect(() => {
if (open && video) {
setPublishNow(!video.scheduledPublishAt);
setPublishAt(video.scheduledPublishAt ? dayjs(video.scheduledPublishAt) : dayjs().add(1, 'hour'));
setUnpublishEnabled(!!video.scheduledUnpublishAt);
setUnpublishAt(video.scheduledUnpublishAt ? dayjs(video.scheduledUnpublishAt) : null);
setSelectedTimezone(dayjs.tz.guess() || 'UTC'); // Auto-detect user timezone
}
}, [open, video]);
const handleSchedule = async () => {
if (!video) return;
try {
setLoading(true);
if (publishNow) {
// Publish immediately by updating video
await mediaApi.patch(`/videos/${video.id}`, {
isPublished: true,
});
message.success('Video published successfully');
} else {
if (!publishAt) {
message.error('Please select a publish date/time');
return;
}
// Convert to UTC for backend
const publishDateUTC = publishAt.tz(selectedTimezone).utc().toISOString();
// Schedule publish
await mediaApi.post(`/videos/${video.id}/schedule-publish`, {
publishAt: publishDateUTC,
timezone: selectedTimezone,
});
message.success(`Video scheduled to publish at ${publishAt.tz(selectedTimezone).format('YYYY-MM-DD HH:mm')} ${selectedTimezone}`);
}
// Handle unpublish if enabled
if (unpublishEnabled && unpublishAt) {
const unpublishDateUTC = unpublishAt.tz(selectedTimezone).utc().toISOString();
await mediaApi.post(`/videos/${video.id}/schedule-unpublish`, {
unpublishAt: unpublishDateUTC,
timezone: selectedTimezone,
});
message.success(`Video scheduled to unpublish at ${unpublishAt.tz(selectedTimezone).format('YYYY-MM-DD HH:mm')} ${selectedTimezone}`);
}
onSuccess?.();
onClose();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to schedule video');
} finally {
setLoading(false);
}
};
const handleCancelSchedule = async (action: 'publish' | 'unpublish') => {
if (!video) return;
try {
setLoading(true);
await mediaApi.delete(`/videos/${video.id}/schedule/${action}`);
message.success(`${action} schedule cancelled`);
onSuccess?.();
onClose();
} catch (error: any) {
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`);
} finally {
setLoading(false);
}
};
// Disable dates in the past
const disabledDate = (current: Dayjs) => {
return current && current < dayjs().startOf('day');
};
// Disable times in the past for today
const disabledDateTime = () => {
const now = dayjs();
return {
disabledHours: () => {
if (publishAt?.isSame(now, 'day')) {
return Array.from({ length: now.hour() }, (_, i) => i);
}
return [];
},
disabledMinutes: (selectedHour: number) => {
if (publishAt?.isSame(now, 'day') && selectedHour === now.hour()) {
return Array.from({ length: now.minute() + 1 }, (_, i) => i);
}
return [];
},
};
};
const localTime = publishAt?.tz(selectedTimezone).format('YYYY-MM-DD HH:mm:ss');
const serverTime = publishAt?.utc().format('YYYY-MM-DD HH:mm:ss UTC');
return (
<Modal
title={
<Space>
<ClockCircleOutlined />
Schedule Publishing
</Space>
}
open={open}
onCancel={onClose}
onOk={handleSchedule}
okText={publishNow ? 'Publish Now' : 'Schedule'}
confirmLoading={loading}
width={600}
style={{ top: 20 }}
styles={{
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
}}
aria-label="Schedule video publishing"
>
{video && (
<div>
<div style={{ marginBottom: 16 }}>
<strong>Video:</strong> {video.title || video.filename}
</div>
{/* Publish Now Toggle */}
<div style={{ marginBottom: 16 }}>
<Space>
<Switch
checked={publishNow}
onChange={setPublishNow}
aria-label="Toggle publish now"
/>
<span>Publish immediately</span>
</Space>
</div>
{!publishNow && (
<>
{/* Timezone Selector */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>Timezone:</strong>
</label>
<Select
style={{ width: '100%' }}
value={selectedTimezone}
onChange={setSelectedTimezone}
options={TIMEZONES}
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
</div>
{/* Publish Date/Time */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>Publish Date & Time:</strong>
</label>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm"
value={publishAt}
onChange={setPublishAt}
disabledDate={disabledDate}
disabledTime={disabledDateTime}
style={{ width: '100%' }}
placeholder="Select publish date and time"
/>
</div>
{/* Time Preview */}
{publishAt && (
<Alert
type="info"
message="Time Preview"
description={
<div>
<div><strong>Local Time ({selectedTimezone}):</strong> {localTime}</div>
<div><strong>Server Time:</strong> {serverTime}</div>
</div>
}
style={{ marginBottom: 16 }}
/>
)}
{/* Auto-unpublish Toggle */}
<div style={{ marginBottom: 16 }}>
<Space>
<Switch
checked={unpublishEnabled}
onChange={setUnpublishEnabled}
aria-label="Toggle auto-unpublish"
/>
<span>Auto-unpublish after a period</span>
</Space>
</div>
{/* Unpublish Date/Time */}
{unpublishEnabled && (
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8 }}>
<strong>Unpublish Date & Time:</strong>
</label>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm"
value={unpublishAt}
onChange={setUnpublishAt}
disabledDate={(current) => {
// Unpublish must be after publish
return current && (current < dayjs().startOf('day') || (publishAt && current <= publishAt));
}}
style={{ width: '100%' }}
placeholder="Select unpublish date and time"
/>
</div>
)}
</>
)}
{/* Cancel existing schedules */}
{(video.scheduledPublishAt || video.scheduledUnpublishAt) && (
<div style={{ marginTop: 16 }}>
<Alert
type="warning"
message="Existing Schedules"
description={
<Space direction="vertical" style={{ width: '100%' }}>
{video.scheduledPublishAt && (
<div>
<div>Publish scheduled for: {dayjs(video.scheduledPublishAt).format('YYYY-MM-DD HH:mm UTC')}</div>
<a onClick={() => handleCancelSchedule('publish')}>Cancel publish schedule</a>
</div>
)}
{video.scheduledUnpublishAt && (
<div>
<div>Unpublish scheduled for: {dayjs(video.scheduledUnpublishAt).format('YYYY-MM-DD HH:mm UTC')}</div>
<a onClick={() => handleCancelSchedule('unpublish')}>Cancel unpublish schedule</a>
</div>
)}
</Space>
}
/>
</div>
)}
</div>
)}
</Modal>
);
}

View File

@ -0,0 +1,38 @@
import { LockOutlined } from '@ant-design/icons';
import type { SharedMedia } from '@/types/media';
import VideoCard from './VideoCard';
interface SharedMediaCardProps {
sharedMedia: SharedMedia;
selected: boolean;
onSelect: (id: number) => void;
}
export default function SharedMediaCard({ sharedMedia, selected, onSelect }: SharedMediaCardProps) {
return (
<div style={{ position: 'relative' }}>
<VideoCard video={sharedMedia.video} selected={selected} onSelect={() => onSelect(sharedMedia.id)} />
{/* Lock overlay */}
{sharedMedia.isLocked && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
pointerEvents: 'none',
}}
>
<LockOutlined style={{ fontSize: 48, color: '#fff' }} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,263 @@
import { useState } from 'react';
import {
Modal,
Upload,
Form,
Input,
Progress,
Alert,
Space,
Typography,
List,
Tag,
} from 'antd';
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { mediaApi } from '@/lib/media-api';
const { Dragger } = Upload;
const { Text } = Typography;
interface UploadVideoModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
interface UploadResult {
filename: string;
success: boolean;
error?: string;
}
export default function UploadVideoModal({ open, onClose, onSuccess }: UploadVideoModalProps) {
const [form] = Form.useForm();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [results, setResults] = useState<UploadResult[]>([]);
const [showResults, setShowResults] = useState(false);
const maxFileSize = 10 * 1024 * 1024 * 1024; // 10GB
const handleClose = () => {
if (!uploading) {
form.resetFields();
setFileList([]);
setResults([]);
setShowResults(false);
setUploadProgress(0);
onClose();
}
};
const beforeUpload = (file: File) => {
// Validate file size
if (file.size > maxFileSize) {
return Upload.LIST_IGNORE;
}
// Validate file type
const validTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm', 'video/x-m4v', 'video/x-flv'];
const validExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];
const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0];
if (!validTypes.includes(file.type) && !validExtensions.includes(ext || '')) {
return Upload.LIST_IGNORE;
}
return false; // Prevent auto-upload
};
const handleUpload = async () => {
if (fileList.length === 0) {
return;
}
setUploading(true);
setShowResults(false);
setResults([]);
setUploadProgress(0);
const uploadResults: UploadResult[] = [];
let completed = 0;
// Upload files sequentially
for (const file of fileList) {
try {
const formData = new FormData();
formData.append('file', file.originFileObj as File);
// Add metadata for single file uploads
if (fileList.length === 1) {
const values = form.getFieldsValue();
if (values.title) formData.append('title', values.title);
if (values.producer) formData.append('producer', values.producer);
if (values.creator) formData.append('creator', values.creator);
}
await mediaApi.post('/videos/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const fileProgress = (progressEvent.loaded / progressEvent.total) * 100;
const totalProgress = ((completed + fileProgress / 100) / fileList.length) * 100;
setUploadProgress(Math.round(totalProgress));
}
},
});
uploadResults.push({
filename: file.name,
success: true,
});
} catch (error: any) {
uploadResults.push({
filename: file.name,
success: false,
error: error.response?.data?.message || 'Upload failed',
});
}
completed++;
setUploadProgress(Math.round((completed / fileList.length) * 100));
}
setResults(uploadResults);
setShowResults(true);
setUploading(false);
// Auto-close and refresh if all successful
const allSuccess = uploadResults.every((r) => r.success);
if (allSuccess) {
setTimeout(() => {
onSuccess();
handleClose();
}, 1500);
}
};
const successCount = results.filter((r) => r.success).length;
const failCount = results.length - successCount;
return (
<Modal
title="Upload Videos"
open={open}
onCancel={handleClose}
onOk={handleUpload}
okText={uploading ? 'Uploading...' : 'Upload'}
okButtonProps={{ disabled: fileList.length === 0 || uploading }}
cancelButtonProps={{ disabled: uploading }}
width={700}
destroyOnHidden
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{!showResults && (
<>
<Dragger
multiple
fileList={fileList}
onChange={({ fileList: newFileList }) => setFileList(newFileList)}
beforeUpload={beforeUpload}
accept="video/*,.mp4,.mov,.avi,.mkv,.webm,.m4v,.flv"
disabled={uploading}
showUploadList={{ showRemoveIcon: !uploading }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">Click or drag video files to this area to upload</p>
<p className="ant-upload-hint">
Support for single or bulk upload. Maximum file size: 10GB per file.
<br />
Accepted formats: MP4, MOV, AVI, MKV, WebM, M4V, FLV
</p>
</Dragger>
{fileList.length === 1 && (
<Form form={form} layout="vertical">
<Alert
message="Optional Metadata"
description="Add metadata for this video (optional)"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item label="Title" name="title">
<Input placeholder="Video title (defaults to filename)" />
</Form.Item>
<Form.Item label="Producer" name="producer">
<Input placeholder="Producer name" />
</Form.Item>
<Form.Item label="Creator" name="creator">
<Input placeholder="Creator name" />
</Form.Item>
</Form>
)}
{fileList.length > 1 && (
<Alert
message={`${fileList.length} files selected for batch upload`}
description="Metadata fields are not available for batch uploads. Titles will default to filenames."
type="info"
showIcon
/>
)}
</>
)}
{uploading && (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text>
Uploading {fileList.length} file(s)... This may take a while for large files.
</Text>
<Progress percent={uploadProgress} status="active" />
</Space>
)}
{showResults && (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert
message={`Upload Complete: ${successCount} succeeded, ${failCount} failed`}
type={failCount === 0 ? 'success' : 'warning'}
showIcon
/>
<List
size="small"
bordered
dataSource={results}
renderItem={(result) => (
<List.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text ellipsis style={{ maxWidth: 400 }}>
{result.filename}
</Text>
{result.success ? (
<Tag icon={<CheckCircleOutlined />} color="success">
Success
</Tag>
) : (
<Tag icon={<CloseCircleOutlined />} color="error" title={result.error}>
Failed
</Tag>
)}
</Space>
{result.error && (
<div style={{ marginTop: 4 }}>
<Text type="danger" style={{ fontSize: 12 }}>
{result.error}
</Text>
</div>
)}
</List.Item>
)}
/>
</Space>
)}
</Space>
</Modal>
);
}

View File

@ -0,0 +1,335 @@
import { Button, Dropdown, message, Modal } from 'antd';
import { useEffect } from 'react';
import {
EditOutlined,
PlayCircleOutlined,
BarChartOutlined,
CopyOutlined,
SwapOutlined,
DownloadOutlined,
PictureOutlined,
ReloadOutlined,
DeleteOutlined,
MoreOutlined,
LinkOutlined,
ClockCircleOutlined,
OrderedListOutlined,
} from '@ant-design/icons';
import { useState } from 'react';
import type { Video } from '@/types/media';
import { mediaApi } from '@/lib/media-api';
interface VideoActionsProps {
video: Video;
onEdit?: (video: Video) => void;
onPreview?: (video: Video) => void;
onAnalytics?: (video: Video) => void;
onSchedule?: (video: Video) => void;
onDelete?: (video: Video) => void;
onAddToPlaylist?: (video: Video) => void;
onRefresh?: () => void;
}
export default function VideoActions({
video,
onEdit,
onPreview,
onAnalytics,
onSchedule,
onDelete,
onAddToPlaylist,
onRefresh,
}: VideoActionsProps) {
const [loading, setLoading] = useState(false);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
switch (e.key.toLowerCase()) {
case 'e':
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
onEdit?.(video);
}
break;
case 'p':
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
onPreview?.(video);
}
break;
case 'a':
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
onAnalytics?.(video);
}
break;
case 's':
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
onSchedule?.(video);
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [video, onEdit, onPreview, onAnalytics, onSchedule]);
const handleDuplicate = async () => {
try {
setLoading(true);
await mediaApi.post(`/videos/${video.id}/duplicate`);
message.success('Video duplicated successfully');
onRefresh?.();
} catch (error) {
message.error('Failed to duplicate video');
} finally {
setLoading(false);
}
};
const handleGeneratePreviewLink = async () => {
try {
setLoading(true);
const response = await mediaApi.get(`/videos/${video.id}/preview-link`);
const { previewUrl, expiryHours } = response.data;
// Copy to clipboard
await navigator.clipboard.writeText(previewUrl);
Modal.success({
title: 'Preview Link Generated',
content: (
<div>
<p>Preview link copied to clipboard!</p>
<p style={{ fontSize: 12, color: '#666' }}>Expires in {expiryHours} hours</p>
<div
style={{
marginTop: 12,
padding: 8,
background: '#f5f5f5',
borderRadius: 4,
wordBreak: 'break-all',
fontSize: 12,
}}
>
{previewUrl}
</div>
</div>
),
});
} catch (error) {
message.error('Failed to generate preview link');
} finally {
setLoading(false);
}
};
const handleResetAnalytics = () => {
Modal.confirm({
title: 'Reset Analytics?',
content: 'This will permanently delete all view data, watch time, and analytics for this video. This action cannot be undone.',
okText: 'Reset',
okType: 'danger',
onOk: async () => {
try {
setLoading(true);
await mediaApi.post(`/videos/${video.id}/reset-analytics`);
message.success('Analytics reset successfully');
onRefresh?.();
} catch (error) {
message.error('Failed to reset analytics');
} finally {
setLoading(false);
}
},
});
};
const handleDownload = () => {
// TODO: Implement download functionality
message.info('Download functionality coming soon');
};
const handleGenerateThumbnail = () => {
// TODO: Implement thumbnail generation
message.info('Thumbnail generation coming soon');
};
// Overflow menu items
const menuItems = [
{
key: 'add-to-playlist',
label: 'Add to Playlist',
icon: <OrderedListOutlined />,
onClick: () => onAddToPlaylist?.(video),
},
{
key: 'duplicate',
label: 'Duplicate',
icon: <CopyOutlined />,
onClick: handleDuplicate,
},
{
key: 'preview-link',
label: 'Generate Preview Link',
icon: <LinkOutlined />,
onClick: handleGeneratePreviewLink,
},
{
key: 'divider-1',
type: 'divider' as const,
},
{
key: 'download',
label: 'Download',
icon: <DownloadOutlined />,
onClick: handleDownload,
},
{
key: 'thumbnail',
label: 'Generate Thumbnail',
icon: <PictureOutlined />,
onClick: handleGenerateThumbnail,
},
{
key: 'divider-2',
type: 'divider' as const,
},
{
key: 'reset-analytics',
label: 'Reset Analytics',
icon: <ReloadOutlined />,
onClick: handleResetAnalytics,
danger: true,
},
{
key: 'delete',
label: 'Delete',
icon: <DeleteOutlined />,
onClick: () => onDelete?.(video),
danger: true,
},
];
return (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent)',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
padding: '8px 12px',
transition: 'opacity 0.2s ease',
zIndex: 10,
}}
onClick={(e) => e.stopPropagation()}
role="toolbar"
aria-label="Video actions"
>
{/* Primary actions */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
onEdit?.(video);
}}
title="Edit (E)"
aria-label="Edit video"
style={{ color: '#fff' }}
/>
<Button
type="text"
size="small"
icon={<PlayCircleOutlined />}
onClick={(e) => {
e.stopPropagation();
onPreview?.(video);
}}
title="Preview (P)"
aria-label="Preview video"
style={{ color: '#fff' }}
/>
<Button
type="text"
size="small"
icon={<BarChartOutlined />}
onClick={(e) => {
e.stopPropagation();
onAnalytics?.(video);
}}
title="Analytics (A)"
aria-label="View analytics"
style={{ color: '#fff' }}
/>
<Button
type="text"
size="small"
icon={<ClockCircleOutlined />}
onClick={(e) => {
e.stopPropagation();
onSchedule?.(video);
}}
title="Schedule (S)"
aria-label="Schedule publishing"
style={{ color: '#fff' }}
/>
</div>
{/* Right side: View count + Overflow menu */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{/* View count badge */}
{video.viewCount !== undefined && video.viewCount > 0 && (
<div
style={{
background: 'rgba(0, 0, 0, 0.6)',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<BarChartOutlined style={{ fontSize: 11 }} />
{video.viewCount.toLocaleString()}
</div>
)}
{/* Overflow menu */}
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
placement="topRight"
>
<Button
type="text"
size="small"
icon={<MoreOutlined />}
onClick={(e) => e.stopPropagation()}
loading={loading}
aria-label="More actions menu"
style={{ color: '#fff' }}
/>
</Dropdown>
</div>
</div>
);
}

View File

@ -0,0 +1,270 @@
import { Modal, Tabs, Spin, Statistic, Row, Col, Empty, Card, Table, Alert, Button, Skeleton } from 'antd';
import {
EyeOutlined,
UserOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
BarChartOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useEffect, useState } from 'react';
import { mediaApi } from '@/lib/media-api';
import type { VideoAnalytics } from '@/types/media';
import AnalyticsChart from './AnalyticsChart';
import ViewersTable from './ViewersTable';
interface VideoAnalyticsModalProps {
videoId: number | null;
videoTitle: string;
open: boolean;
onClose: () => void;
}
export default function VideoAnalyticsModal({
videoId,
videoTitle,
open,
onClose,
}: VideoAnalyticsModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [analytics, setAnalytics] = useState<VideoAnalytics | null>(null);
useEffect(() => {
if (open && videoId) {
fetchAnalytics();
}
}, [open, videoId]);
const fetchAnalytics = async () => {
if (!videoId) return;
try {
setLoading(true);
setError(null);
const response = await mediaApi.get(`/videos/${videoId}/analytics`);
setAnalytics(response.data);
} catch (error: any) {
console.error('Failed to fetch analytics:', error);
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
} finally {
setLoading(false);
}
};
const formatWatchTime = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
const tabItems = [
{
key: 'overview',
label: 'Overview',
children: analytics ? (
<div>
{/* Stats Cards */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title="Total Views"
value={analytics.overview.totalViews}
prefix={<EyeOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Unique Viewers"
value={analytics.overview.uniqueViewers}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Avg Watch Time"
value={formatWatchTime(Math.round(analytics.overview.averageWatchTime))}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Completion Rate"
value={analytics.overview.completionRate}
precision={1}
suffix="%"
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
</Row>
{/* Total Watch Time */}
<Card style={{ marginBottom: 24 }}>
<Statistic
title="Total Watch Time"
value={formatWatchTime(analytics.overview.totalWatchTime)}
prefix={<ClockCircleOutlined />}
/>
<p style={{ marginTop: 8, color: '#666', fontSize: 12 }}>
{analytics.overview.totalWatchTime} seconds total
</p>
</Card>
{/* Top Referrers */}
<Card title="Top Referrers">
{analytics.topReferrers.length > 0 ? (
<Table
dataSource={analytics.topReferrers}
columns={[
{
title: 'Source',
dataIndex: 'referer',
key: 'referer',
},
{
title: 'Views',
dataIndex: 'count',
key: 'count',
sorter: (a, b) => a.count - b.count,
defaultSortOrder: 'descend' as const,
},
]}
rowKey="referer"
pagination={false}
size="small"
/>
) : (
<Empty description="No referrer data" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</div>
) : (
<Empty description="No data available" />
),
},
{
key: 'charts',
label: 'Charts',
children: analytics ? (
<div>
{/* Views Over Time */}
<Card title="Views Over Time (Last 30 Days)" style={{ marginBottom: 24 }}>
{analytics.viewsOverTime.length > 0 ? (
<AnalyticsChart
type="area"
data={analytics.viewsOverTime}
dataKey="count"
xKey="date"
color="#1890ff"
height={300}
/>
) : (
<Empty description="No view history" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* Top Referrers Pie Chart */}
<Card title="Traffic Sources Distribution">
{analytics.topReferrers.length > 0 ? (
<AnalyticsChart
type="pie"
data={analytics.topReferrers.map((item) => ({
name: item.referer,
value: item.count,
}))}
dataKey="value"
height={400}
/>
) : (
<Empty description="No referrer data" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</div>
) : (
<Empty description="No data available" />
),
},
{
key: 'viewers',
label: `Viewers (${analytics?.registeredViewers.length || 0})`,
children: analytics ? (
<div>
{analytics.registeredViewers.length > 0 ? (
<ViewersTable viewers={analytics.registeredViewers} />
) : (
<Empty description="No registered viewers yet" />
)}
</div>
) : (
<Empty description="No data available" />
),
},
];
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BarChartOutlined />
<span>Detailed Analytics: {videoTitle}</span>
</div>
}
open={open}
onCancel={onClose}
footer={null}
width={1000}
style={{ top: 20 }}
styles={{
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }
}}
aria-label="Video analytics modal"
>
{error ? (
<div style={{ padding: 24 }}>
<Alert
type="error"
message="Failed to Load Analytics"
description={error}
showIcon
action={
<Button
size="small"
type="primary"
onClick={fetchAnalytics}
icon={<ReloadOutlined />}
aria-label="Retry loading analytics"
>
Retry
</Button>
}
/>
</div>
) : loading ? (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
{[1, 2, 3, 4].map((i) => (
<Col span={6} key={i}>
<Skeleton.Button active block style={{ height: 100 }} />
</Col>
))}
</Row>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
) : (
<Tabs items={tabItems} />
)}
</Modal>
);
}

View File

@ -0,0 +1,302 @@
import { Card, Checkbox, Tag, Spin } from 'antd';
import { ClockCircleOutlined, PlayCircleOutlined, CheckCircleOutlined, ThunderboltFilled } from '@ant-design/icons';
import { useState } from 'react';
import type { Video } from '@/types/media';
import { getAuthCallbacks } from '@/lib/api';
import VideoActions from './VideoActions';
import ScheduleBadge from './ScheduleBadge';
/** Append JWT access token as query param for <img>/<video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoCardProps {
video: Video;
selected: boolean;
onSelect: (id: number) => void;
onClick?: (video: Video) => void;
onEdit?: (video: Video) => void;
onPreview?: (video: Video) => void;
onAnalytics?: (video: Video) => void;
onSchedule?: (video: Video) => void;
onDelete?: (video: Video) => void;
onAddToPlaylist?: (video: Video) => void;
onRefresh?: () => void;
onTogglePublish?: (video: Video) => void;
showActions?: boolean;
}
export default function VideoCard({
video,
selected,
onSelect,
onClick,
onEdit,
onPreview,
onAnalytics,
onSchedule,
onDelete,
onAddToPlaylist,
onRefresh,
onTogglePublish,
showActions = true,
}: VideoCardProps) {
const [thumbnailLoading, setThumbnailLoading] = useState(true);
const [thumbnailError, setThumbnailError] = useState(false);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatFileSize = (bytes: number) => {
const mb = bytes / (1024 * 1024);
if (mb >= 1024) {
return `${(mb / 1024).toFixed(1)} GB`;
}
return `${mb.toFixed(0)} MB`;
};
return (
<Card
hoverable
cover={
<div
style={{
position: 'relative',
paddingTop: video.orientation === 'V' ? '177.78%' : '56.25%',
background: '#1f1f1f',
overflow: 'hidden',
}}
>
{/* Thumbnail image or fallback */}
{video.thumbnailUrl && !thumbnailError ? (
<>
<img
src={getAuthenticatedUrl(video.thumbnailUrl)}
alt={video.title}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
display: thumbnailLoading ? 'none' : 'block',
}}
onLoad={() => setThumbnailLoading(false)}
onError={() => {
setThumbnailError(true);
setThumbnailLoading(false);
}}
/>
{thumbnailLoading && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spin />
</div>
)}
</>
) : (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: 48,
}}
>
{video.orientation === 'V' ? '📱' : '🎬'}
</div>
)}
{/* Schedule badge */}
<ScheduleBadge
scheduledPublishAt={video.scheduledPublishAt}
scheduledUnpublishAt={video.scheduledUnpublishAt}
isPublished={video.isPublished}
/>
{/* Publish toggle pill */}
{onTogglePublish && (
<div
onClick={(e) => {
e.stopPropagation();
onTogglePublish(video);
}}
style={{
position: 'absolute',
top: 8,
right: video.scheduledPublishAt || video.scheduledUnpublishAt ? 120 : 8,
cursor: 'pointer',
background: video.isPublished
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
color: '#fff',
padding: '6px 14px',
borderRadius: 20,
fontSize: 12,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 6,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
transition: 'all 0.2s ease',
zIndex: 11,
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
}}
title={video.isPublished ? 'Click to unpublish' : 'Click to publish'}
role="button"
aria-label={video.isPublished ? 'Unpublish video' : 'Publish video'}
>
{video.isPublished ? (
<>
<CheckCircleOutlined style={{ fontSize: 14 }} />
<span>Published</span>
</>
) : (
<>
<ClockCircleOutlined style={{ fontSize: 14 }} />
<span>Draft</span>
</>
)}
</div>
)}
{/* Action buttons overlay */}
{showActions && (
<VideoActions
video={video}
onEdit={onEdit}
onPreview={onPreview}
onAnalytics={onAnalytics}
onSchedule={onSchedule}
onDelete={onDelete}
onAddToPlaylist={onAddToPlaylist}
onRefresh={onRefresh}
/>
)}
{/* Duration badge */}
<div
style={{
position: 'absolute',
bottom: 8,
right: 8,
background: 'rgba(0, 0, 0, 0.8)',
color: '#fff',
padding: '2px 6px',
borderRadius: 4,
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<ClockCircleOutlined />
{formatDuration(video.duration)}
</div>
{/* Short video badge */}
{video.isShort && (
<div
style={{
position: 'absolute',
bottom: 8,
left: 8,
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<ThunderboltFilled style={{ fontSize: 12 }} />
Short
</div>
)}
{/* Select checkbox */}
<div
style={{
position: 'absolute',
top: 8,
left: 8,
}}
>
<Checkbox
checked={selected}
onChange={(e) => {
e.stopPropagation();
onSelect(video.id);
}}
onClick={(e) => e.stopPropagation()}
aria-label={`Select ${video.title || `video ${video.id}`}`}
/>
</div>
</div>
}
onClick={() => onClick?.(video)}
style={{ cursor: 'pointer' }}
role="article"
aria-label={`Video card: ${video.title || `Video ${video.id}`}`}
>
<Card.Meta
title={
<div style={{ fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{video.title}
</div>
}
description={
<div style={{ fontSize: 12 }}>
<div>{video.width} × {video.height}</div>
<div>{formatFileSize(video.fileSize)}</div>
{video.producer && (
<Tag style={{ marginTop: 4 }} color="blue">
{video.producer}
</Tag>
)}
{video.creator && (
<Tag style={{ marginTop: 4 }} color="green">
{video.creator}
</Tag>
)}
</div>
}
/>
</Card>
);
}

View File

@ -0,0 +1,405 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
Tabs,
Input,
Select,
Row,
Col,
Card,
Pagination,
Empty,
Spin,
Button,
Tag,
message,
} from 'antd';
import {
SearchOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { mediaApi } from '../../lib/media-api';
import { formatDuration } from './VideoPlayer';
const { TabPane } = Tabs;
const { Option } = Select;
export interface Video {
id: number;
title: string;
filename: string;
durationSeconds: number | null;
fileSize: number | null;
width: number | null;
height: number | null;
orientation: string | null;
producer: string | null;
createdAt: string;
thumbnailPath: string | null;
}
export interface VideoPickerModalProps {
open: boolean;
onClose: () => void;
onSelect: (video: Video) => void;
mode?: 'single' | 'multiple';
title?: string;
}
/**
* Video Picker Modal with Library and Upload tabs
* Allows selecting from existing videos or uploading new ones
*/
export const VideoPickerModal: React.FC<VideoPickerModalProps> = ({
open,
onClose,
onSelect,
mode = 'single',
title = 'Select Video',
}) => {
const [activeTab, setActiveTab] = useState('library');
const [loading, setLoading] = useState(false);
const [videos, setVideos] = useState<Video[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(12);
const [search, setSearch] = useState('');
const [orientation, setOrientation] = useState<string>('');
const [producer, setProducer] = useState<string>('');
const [producers, setProducers] = useState<string[]>([]);
const [selectedVideos, setSelectedVideos] = useState<Set<number>>(new Set());
// Upload state (for future implementation)
// const [uploading, setUploading] = useState(false);
// const [uploadProgress, setUploadProgress] = useState(0);
useEffect(() => {
if (open && activeTab === 'library') {
fetchVideos();
fetchProducers();
}
}, [open, activeTab, page, search, orientation, producer]);
const fetchVideos = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((page - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (orientation) params.append('orientation', orientation);
if (producer) params.append('producers', producer);
const response = await mediaApi.get(`/api/videos?${params.toString()}`);
setVideos(response.data.videos || []);
setTotal(response.data.total || 0);
} catch (error) {
console.error('Failed to fetch videos:', error);
message.error('Failed to load videos');
} finally {
setLoading(false);
}
};
const fetchProducers = async () => {
try {
const response = await mediaApi.get('/api/videos/producers');
setProducers(response.data || []);
} catch (error) {
console.error('Failed to fetch producers:', error);
}
};
const handleVideoClick = (video: Video) => {
if (mode === 'single') {
onSelect(video);
onClose();
} else {
// Toggle selection for multiple mode
setSelectedVideos((prev) => {
const newSet = new Set(prev);
if (newSet.has(video.id)) {
newSet.delete(video.id);
} else {
newSet.add(video.id);
}
return newSet;
});
}
};
const handleSelectMultiple = () => {
if (selectedVideos.size === 0) {
message.warning('Please select at least one video');
return;
}
const selected = videos.filter((v) => selectedVideos.has(v.id));
selected.forEach((video) => onSelect(video));
onClose();
};
// const handleUploadComplete = (video: Video) => {
// message.success('Video uploaded successfully!');
// setActiveTab('library');
// setPage(1);
// fetchVideos();
//
// // Auto-select the uploaded video
// setTimeout(() => {
// onSelect(video);
// onClose();
// }, 500);
// };
const handleFilterReset = () => {
setSearch('');
setOrientation('');
setProducer('');
setPage(1);
};
const mediaApiUrl = import.meta.env.VITE_MEDIA_API_URL || 'http://localhost:4100';
return (
<Modal
open={open}
onCancel={onClose}
title={title}
width={900}
footer={
mode === 'multiple' && activeTab === 'library' ? (
<div>
<Button onClick={onClose}>Cancel</Button>
<Button
type="primary"
onClick={handleSelectMultiple}
disabled={selectedVideos.size === 0}
>
Select ({selectedVideos.size})
</Button>
</div>
) : null
}
>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane
tab={
<span>
<PlayCircleOutlined />
Library
</span>
}
key="library"
>
{/* Filters */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col span={12}>
<Input
placeholder="Search videos..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
allowClear
/>
</Col>
<Col span={6}>
<Select
placeholder="Orientation"
value={orientation || undefined}
onChange={(value) => {
setOrientation(value || '');
setPage(1);
}}
style={{ width: '100%' }}
allowClear
>
<Option value="H">Horizontal</Option>
<Option value="V">Vertical</Option>
</Select>
</Col>
<Col span={6}>
<Select
placeholder="Producer"
value={producer || undefined}
onChange={(value) => {
setProducer(value || '');
setPage(1);
}}
style={{ width: '100%' }}
allowClear
showSearch
>
{producers.map((p) => (
<Option key={p} value={p}>
{p}
</Option>
))}
</Select>
</Col>
</Row>
{/* Video Grid */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : videos.length === 0 ? (
<Empty description="No videos found" style={{ padding: 40 }}>
<Button onClick={handleFilterReset}>Reset Filters</Button>
</Empty>
) : (
<>
<Row gutter={[12, 12]}>
{videos.map((video) => {
const isSelected = selectedVideos.has(video.id);
return (
<Col key={video.id} span={6}>
<Card
hoverable
cover={
<div
style={{
position: 'relative',
paddingBottom: '56.25%',
background: '#f0f0f0',
overflow: 'hidden',
}}
>
{video.thumbnailPath ? (
<img
src={`${mediaApiUrl}/api/videos/${video.id}/thumbnail`}
alt={video.title}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<PlayCircleOutlined style={{ fontSize: 32, color: '#999' }} />
</div>
)}
{isSelected && (
<div
style={{
position: 'absolute',
top: 8,
right: 8,
background: '#52c41a',
borderRadius: '50%',
padding: 4,
}}
>
<CheckCircleOutlined style={{ color: '#fff', fontSize: 16 }} />
</div>
)}
{video.durationSeconds && (
<Tag
style={{
position: 'absolute',
bottom: 8,
right: 8,
margin: 0,
}}
>
{formatDuration(video.durationSeconds)}
</Tag>
)}
</div>
}
onClick={() => handleVideoClick(video)}
style={{
border: isSelected ? '2px solid #52c41a' : undefined,
}}
>
<Card.Meta
title={
<div
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 13,
}}
>
{video.title}
</div>
}
description={
<div style={{ fontSize: 11, color: '#999' }}>
{video.orientation === 'H' ? 'Horizontal' : 'Vertical'}
{video.producer && `${video.producer}`}
</div>
}
/>
</Card>
</Col>
);
})}
</Row>
{/* Pagination */}
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={(newPage) => setPage(newPage)}
showSizeChanger={false}
showTotal={(total) => `Total ${total} videos`}
/>
</div>
</>
)}
</TabPane>
<TabPane
tab={
<span>
<UploadOutlined />
Upload
</span>
}
key="upload"
>
<div style={{ padding: '20px 0' }}>
<Empty
description="Upload feature integration coming soon"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<p style={{ color: '#999', marginTop: 16 }}>
For now, please upload videos through the Media Library page,
<br />
then return here to select them.
</p>
<Button type="primary" onClick={() => setActiveTab('library')}>
Go to Library
</Button>
</Empty>
</div>
</TabPane>
</Tabs>
</Modal>
);
};
export default VideoPickerModal;

View File

@ -0,0 +1,290 @@
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
import { Alert, Spin } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
export interface VideoMetadata {
id: number;
title: string;
durationSeconds: number | null;
width: number | null;
height: number | null;
orientation: string | null;
hasAudio: boolean | null;
quality: string | null;
streamUrl: string;
thumbnailUrl: string | null;
createdAt: string;
}
export interface VideoPlayerProps {
videoId: number;
width?: string | number;
height?: string | number;
autoplay?: boolean;
controls?: boolean;
loop?: boolean;
muted?: boolean;
poster?: string;
className?: string;
/** When true, sends auth token for metadata fetch and appends token to stream/thumbnail URLs */
isAdmin?: boolean;
onLoadedMetadata?: (metadata: VideoMetadata) => void;
onError?: (error: Error) => void;
}
export interface VideoPlayerRef {
play: () => void;
pause: () => void;
togglePlay: () => void;
seekForward: (seconds: number) => void;
seekBackward: (seconds: number) => void;
toggleMute: () => void;
toggleFullscreen: () => void;
getVideoElement: () => HTMLVideoElement | null;
}
/**
* Standard HTML5 video player component
* Fetches metadata from Media API and renders video with streaming support
*/
export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
videoId,
width = '100%',
height = 'auto',
autoplay = false,
controls = true,
loop = false,
muted = false,
poster,
className = '',
isAdmin = false,
onLoadedMetadata,
onError,
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Expose control methods via ref
useImperativeHandle(ref, () => ({
play: () => {
videoRef.current?.play();
},
pause: () => {
videoRef.current?.pause();
},
togglePlay: () => {
if (videoRef.current) {
if (videoRef.current.paused) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}
},
seekForward: (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = Math.min(
videoRef.current.currentTime + seconds,
videoRef.current.duration
);
}
},
seekBackward: (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(
videoRef.current.currentTime - seconds,
0
);
}
},
toggleMute: () => {
if (videoRef.current) {
videoRef.current.muted = !videoRef.current.muted;
}
},
toggleFullscreen: () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen?.() ||
(videoRef.current as any).webkitRequestFullscreen?.();
}
}
},
getVideoElement: () => videoRef.current,
}));
useEffect(() => {
fetchMetadata();
}, [videoId]);
const appendToken = (url: string): string => {
if (!isAdmin) return url;
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${accessToken}`;
};
const fetchMetadata = async () => {
setLoading(true);
setError(null);
try {
// Use relative URL to go through nginx proxy
const headers: Record<string, string> = {};
if (isAdmin) {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
}
const response = await fetch(`/media/videos/${videoId}/metadata`, { headers });
if (!response.ok) {
if (response.status === 404) {
throw new Error('Video not found');
}
throw new Error(`Failed to load video: ${response.statusText}`);
}
const data = await response.json();
// For admin, append token to stream/thumbnail URLs so <video>/<img> can access them
if (isAdmin) {
if (data.streamUrl) data.streamUrl = appendToken(data.streamUrl);
if (data.thumbnailUrl) data.thumbnailUrl = appendToken(data.thumbnailUrl);
}
setMetadata(data);
if (onLoadedMetadata) {
onLoadedMetadata(data);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load video';
setError(errorMessage);
if (onError) {
onError(err instanceof Error ? err : new Error(errorMessage));
}
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div
style={{
width,
height: height === 'auto' ? 200 : height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f0f0f0',
borderRadius: 8,
}}
className={className}
>
<Spin size="large" tip="Loading video..." />
</div>
);
}
if (error || !metadata) {
return (
<div style={{ width }} className={className}>
<Alert
message="Video Error"
description={error || 'Failed to load video metadata'}
type="error"
icon={<PlayCircleOutlined />}
showIcon
/>
</div>
);
}
// Use aspect ratio padding trick for responsive sizing
const aspectRatio =
metadata.width && metadata.height
? (metadata.height / metadata.width) * 100
: 56.25; // Default to 16:9
return (
<div
style={{
width,
position: 'relative',
paddingBottom: height === 'auto' ? `${aspectRatio}%` : undefined,
height: height !== 'auto' ? height : undefined,
}}
className={className}
>
<video
ref={videoRef}
src={metadata.streamUrl}
poster={poster || metadata.thumbnailUrl || undefined}
autoPlay={autoplay}
controls={controls}
loop={loop}
muted={muted}
playsInline
style={{
position: height === 'auto' ? 'absolute' : 'relative',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: 8,
background: '#000',
}}
onError={() => {
const videoError = new Error('Video playback failed');
setError('Video playback failed. The file may be corrupted or in an unsupported format.');
if (onError) {
onError(videoError);
}
}}
>
Your browser does not support HTML5 video playback.
<br />
<a href={metadata.streamUrl} download>
Download video
</a>
</video>
</div>
);
});
VideoPlayer.displayName = 'VideoPlayer';
/**
* Format duration from seconds to HH:MM:SS or MM:SS
*/
export function formatDuration(seconds: number | null): string {
if (seconds === null || seconds === undefined) {
return '--:--';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
export default VideoPlayer;

View File

@ -0,0 +1,210 @@
import { Modal } from 'antd';
import { useEffect, useRef, useState } from 'react';
import type { Video } from '@/types/media';
import { mediaApi } from '@/lib/media-api';
import { getAuthCallbacks } from '@/lib/api';
/** Append JWT access token as query param for <video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoViewerModalProps {
video: Video | null;
open: boolean;
onClose: () => void;
}
export default function VideoViewerModal({ video, open, onClose }: VideoViewerModalProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [viewId, setViewId] = useState<number | null>(null);
const heartbeatInterval = useRef<NodeJS.Timeout | null>(null);
const lastWatchTime = useRef<number>(0);
useEffect(() => {
if (open && video) {
// Record view when video opens
recordView();
}
return () => {
// Cleanup on unmount
cleanup();
};
}, [open, video]);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement || !video) return;
const handlePlay = () => {
recordEvent('play', videoElement.currentTime);
startHeartbeat();
};
const handlePause = () => {
recordEvent('pause', videoElement.currentTime);
stopHeartbeat();
updateWatchTime();
};
const handleSeeked = () => {
recordEvent('seek', videoElement.currentTime);
};
const handleEnded = () => {
recordEvent('complete', videoElement.currentTime);
stopHeartbeat();
updateWatchTime();
};
// Add event listeners
videoElement.addEventListener('play', handlePlay);
videoElement.addEventListener('pause', handlePause);
videoElement.addEventListener('seeked', handleSeeked);
videoElement.addEventListener('ended', handleEnded);
return () => {
// Cleanup event listeners
videoElement.removeEventListener('play', handlePlay);
videoElement.removeEventListener('pause', handlePause);
videoElement.removeEventListener('seeked', handleSeeked);
videoElement.removeEventListener('ended', handleEnded);
};
}, [video, viewId]);
const recordView = async () => {
if (!video) return;
try {
const response = await mediaApi.post('/track/view', {
videoId: video.id,
referer: document.referrer || undefined,
});
setViewId(response.data.viewId);
} catch (error) {
console.error('Failed to record view:', error);
}
};
const recordEvent = async (eventType: 'play' | 'pause' | 'seek' | 'complete', timestamp: number) => {
if (!video) return;
try {
await mediaApi.post('/track/event', {
videoId: video.id,
viewId: viewId || undefined,
eventType,
timestamp,
});
} catch (error) {
console.error('Failed to record event:', error);
}
};
const updateWatchTime = async () => {
if (!viewId || !videoRef.current) return;
const currentTime = Math.floor(videoRef.current.currentTime);
if (currentTime === lastWatchTime.current) return;
lastWatchTime.current = currentTime;
try {
// Use sendBeacon for reliable tracking even on page close
const data = JSON.stringify({
viewId,
watchTimeSeconds: currentTime,
});
// Use relative URL via window.location.origin to go through nginx proxy
const sent = navigator.sendBeacon(
`${window.location.origin}/media/track/heartbeat`,
new Blob([data], { type: 'application/json' })
);
if (!sent) {
// Fallback to regular API call
await mediaApi.post('/track/heartbeat', {
viewId,
watchTimeSeconds: currentTime,
});
}
} catch (error) {
console.error('Failed to update watch time:', error);
}
};
const startHeartbeat = () => {
stopHeartbeat(); // Clear any existing interval
heartbeatInterval.current = setInterval(() => {
updateWatchTime();
}, 10000); // Every 10 seconds
};
const stopHeartbeat = () => {
if (heartbeatInterval.current) {
clearInterval(heartbeatInterval.current);
heartbeatInterval.current = null;
}
};
const cleanup = () => {
stopHeartbeat();
updateWatchTime(); // Final update before closing
};
if (!video) return null;
return (
<Modal
title={video.title}
open={open}
onCancel={() => {
cleanup();
onClose();
}}
footer={null}
width={video.orientation === 'V' ? 600 : 1200}
centered
destroyOnHidden
>
<video
ref={videoRef}
src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)}
controls
autoPlay
style={{
width: '100%',
maxHeight: '70vh',
background: '#000',
}}
/>
<div style={{ marginTop: 16, fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>
<div><strong>Duration:</strong> {formatDuration(video.duration)}</div>
<div><strong>Resolution:</strong> {video.width} × {video.height}</div>
<div><strong>Size:</strong> {formatFileSize(video.fileSize)}</div>
{video.producer && <div><strong>Producer:</strong> {video.producer}</div>}
{video.creator && <div><strong>Creator:</strong> {video.creator}</div>}
</div>
</Modal>
);
}
function formatDuration(seconds: number) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatFileSize(bytes: number) {
const mb = bytes / (1024 * 1024);
if (mb >= 1024) {
return `${(mb / 1024).toFixed(1)} GB`;
}
return `${mb.toFixed(0)} MB`;
}

View File

@ -0,0 +1,89 @@
import { Table, Tag } from 'antd';
import { CheckCircleOutlined, ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { VideoAnalytics } from '@/types/media';
interface ViewersTableProps {
viewers: VideoAnalytics['registeredViewers'];
loading?: boolean;
}
export default function ViewersTable({ viewers, loading }: ViewersTableProps) {
const formatWatchTime = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
};
const columns = [
{
title: 'User',
dataIndex: 'userName',
key: 'userName',
render: (name: string | null, record: any) => name || <i style={{ color: '#999' }}>No name</i>,
},
{
title: 'Email',
dataIndex: 'userEmail',
key: 'userEmail',
ellipsis: true,
},
{
title: 'Watch Time',
dataIndex: 'watchTime',
key: 'watchTime',
render: (seconds: number) => formatWatchTime(seconds),
sorter: (a: any, b: any) => a.watchTime - b.watchTime,
},
{
title: 'Completion',
dataIndex: 'completed',
key: 'completed',
render: (completed: boolean) =>
completed ? (
<Tag icon={<CheckCircleOutlined />} color="success">
Completed
</Tag>
) : (
<Tag icon={<ClockCircleOutlined />} color="default">
Partial
</Tag>
),
filters: [
{ text: 'Completed', value: true },
{ text: 'Partial', value: false },
],
onFilter: (value: any, record: any) => record.completed === value,
},
{
title: 'Viewed At',
dataIndex: 'viewedAt',
key: 'viewedAt',
render: (date: string) => dayjs(date).format('MMM D, YYYY HH:mm'),
sorter: (a: any, b: any) => dayjs(a.viewedAt).unix() - dayjs(b.viewedAt).unix(),
defaultSortOrder: 'descend' as const,
},
];
return (
<Table
dataSource={viewers}
columns={columns}
rowKey="userId"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total} viewers`,
}}
size="small"
/>
);
}

View File

@ -0,0 +1,49 @@
import { Grid } from 'antd';
import { useChatBar } from './ChatBarContext';
import MiniChatWindow from './MiniChatWindow';
import MinimizedChat from './MinimizedChat';
const { useBreakpoint } = Grid;
export default function ChatBar() {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { windows, closeChat, toggleExpanded } = useChatBar();
if (windows.length === 0) return null;
return (
<div
style={{
position: 'fixed',
bottom: isMobile ? 56 : 0, // Above mobile bottom nav
right: 16,
display: 'flex',
gap: 8,
alignItems: 'flex-end',
zIndex: 1000,
pointerEvents: 'none', // Allow clicks to pass through gaps
}}
>
{windows.map((w) =>
w.isExpanded ? (
<div key={w.videoId} style={{ pointerEvents: 'auto' }}>
<MiniChatWindow
window={w}
onToggle={() => toggleExpanded(w.videoId)}
onClose={() => closeChat(w.videoId)}
/>
</div>
) : (
<div key={w.videoId} style={{ pointerEvents: 'auto' }}>
<MinimizedChat
window={w}
onExpand={() => toggleExpanded(w.videoId)}
onClose={() => closeChat(w.videoId)}
/>
</div>
)
)}
</div>
);
}

View File

@ -0,0 +1,127 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { Grid } from 'antd';
const { useBreakpoint } = Grid;
export interface ChatWindow {
videoId: number;
videoTitle: string;
thumbnailPath?: string | null;
isExpanded: boolean;
unreadCount: number;
}
interface ChatBarContextValue {
windows: ChatWindow[];
openChat: (videoId: number, videoTitle: string, thumbnailPath?: string | null) => void;
closeChat: (videoId: number) => void;
toggleExpanded: (videoId: number) => void;
minimizeAll: () => void;
clearUnread: (videoId: number) => void;
incrementUnread: (videoId: number) => void;
maxWindows: number;
}
const ChatBarContext = createContext<ChatBarContextValue | undefined>(undefined);
export function useChatBar() {
const context = useContext(ChatBarContext);
if (!context) {
throw new Error('useChatBar must be used within ChatBarProvider');
}
return context;
}
export function ChatBarProvider({ children }: { children: ReactNode }) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const maxWindows = isMobile ? 1 : 3;
const [windows, setWindows] = useState<ChatWindow[]>([]);
const openChat = useCallback(
(videoId: number, videoTitle: string, thumbnailPath?: string | null) => {
setWindows((prev) => {
// Already open? Just expand it
const existing = prev.find((w) => w.videoId === videoId);
if (existing) {
return prev.map((w) =>
w.videoId === videoId ? { ...w, isExpanded: true, unreadCount: 0 } : w
);
}
const newWindow: ChatWindow = {
videoId,
videoTitle,
thumbnailPath,
isExpanded: true,
unreadCount: 0,
};
// If at max, close the oldest minimized window (or oldest)
if (prev.length >= maxWindows) {
const minimized = prev.filter((w) => !w.isExpanded);
if (minimized.length > 0) {
// Remove oldest minimized
const toRemove = minimized[0]!;
return [...prev.filter((w) => w.videoId !== toRemove.videoId), newWindow];
}
// Remove first window
return [...prev.slice(1), newWindow];
}
return [...prev, newWindow];
});
},
[maxWindows]
);
const closeChat = useCallback((videoId: number) => {
setWindows((prev) => prev.filter((w) => w.videoId !== videoId));
}, []);
const toggleExpanded = useCallback((videoId: number) => {
setWindows((prev) =>
prev.map((w) =>
w.videoId === videoId ? { ...w, isExpanded: !w.isExpanded, unreadCount: 0 } : w
)
);
}, []);
const minimizeAll = useCallback(() => {
setWindows((prev) => prev.map((w) => ({ ...w, isExpanded: false })));
}, []);
const clearUnread = useCallback((videoId: number) => {
setWindows((prev) =>
prev.map((w) => (w.videoId === videoId ? { ...w, unreadCount: 0 } : w))
);
}, []);
const incrementUnread = useCallback((videoId: number) => {
setWindows((prev) =>
prev.map((w) =>
w.videoId === videoId && !w.isExpanded
? { ...w, unreadCount: w.unreadCount + 1 }
: w
)
);
}, []);
return (
<ChatBarContext.Provider
value={{
windows,
openChat,
closeChat,
toggleExpanded,
minimizeAll,
clearUnread,
incrementUnread,
maxWindows,
}}
>
{children}
</ChatBarContext.Provider>
);
}

View File

@ -0,0 +1,93 @@
import { Typography, theme } from 'antd';
import {
CloseOutlined,
MinusOutlined,
ExpandOutlined,
} from '@ant-design/icons';
import type { ChatWindow } from './ChatBarContext';
import MiniLiveChat from './MiniLiveChat';
const { Text } = Typography;
interface MiniChatWindowProps {
window: ChatWindow;
onToggle: () => void;
onClose: () => void;
}
export default function MiniChatWindow({
window: chatWindow,
onToggle,
onClose,
}: MiniChatWindowProps) {
const { token } = theme.useToken();
return (
<div
style={{
width: 320,
height: chatWindow.isExpanded ? 400 : 40,
background: token.colorBgContainer,
borderRadius: '12px 12px 0 0',
border: `1px solid ${token.colorBorder}`,
borderBottom: 'none',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
transition: 'height 0.2s ease',
boxShadow: '0 -4px 20px rgba(0,0,0,0.3)',
}}
>
{/* Title bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
background: token.colorBgElevated,
borderBottom: chatWindow.isExpanded ? `1px solid ${token.colorBorder}` : 'none',
cursor: 'pointer',
userSelect: 'none',
minHeight: 40,
}}
onClick={onToggle}
>
<Text
ellipsis
strong
style={{ flex: 1, fontSize: 13 }}
>
{chatWindow.videoTitle}
</Text>
<div
style={{ display: 'flex', gap: 8 }}
onClick={(e) => e.stopPropagation()}
>
{chatWindow.isExpanded ? (
<MinusOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={onToggle}
/>
) : (
<ExpandOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={onToggle}
/>
)}
<CloseOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={onClose}
/>
</div>
</div>
{/* Chat content */}
{chatWindow.isExpanded && (
<div style={{ flex: 1, overflow: 'hidden' }}>
<MiniLiveChat videoId={chatWindow.videoId} isExpanded={chatWindow.isExpanded} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,229 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Input, Button, Typography, Tag, Spin, theme } from 'antd';
import { SendOutlined, UserOutlined } from '@ant-design/icons';
import { useMediaAuth } from '@/contexts/MediaAuthContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
const { Text } = Typography;
const { TextArea } = Input;
interface Comment {
id: number;
content: string;
createdAt: string;
user: { id: string; name: string } | null;
}
interface MiniLiveChatProps {
videoId: number;
isExpanded: boolean;
}
export default function MiniLiveChat({ videoId, isExpanded }: MiniLiveChatProps) {
const { token } = theme.useToken();
const { isAuthenticated, isApproved } = useMediaAuth();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [input, setInput] = useState('');
const [submitting, setSubmitting] = useState(false);
const [sseConnected, setSSEConnected] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
// Auto-scroll to bottom
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, []);
// Fetch initial comments
useEffect(() => {
if (!isExpanded) return;
const fetchComments = async () => {
setLoading(true);
try {
const res = await fetch(`/media/public/${videoId}/comments?limit=50`);
if (res.ok) {
const data = await res.json();
setComments(data.comments || []);
setTimeout(scrollToBottom, 100);
}
} catch {
// Silent
} finally {
setLoading(false);
}
};
fetchComments();
// Mark as read
if (isAuthenticated) {
mediaApi.post(`/media/chat/threads/${videoId}/read`).catch(() => {});
}
}, [videoId, isExpanded, scrollToBottom, isAuthenticated]);
// SSE connection — only when expanded
useEffect(() => {
if (!isExpanded) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
setSSEConnected(false);
}
return;
}
const es = new EventSource(`/media/public/${videoId}/chat-stream`);
es.onopen = () => setSSEConnected(true);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'connected') return;
if (data.type === 'new_comment') {
setComments((prev) => {
if (prev.some((c) => c.id === data.comment.id)) return prev;
const updated = [...prev, data.comment];
return updated.slice(-50);
});
setTimeout(scrollToBottom, 100);
}
} catch {
// Ignore
}
};
es.onerror = () => setSSEConnected(false);
eventSourceRef.current = es;
return () => {
es.close();
eventSourceRef.current = null;
};
}, [videoId, isExpanded, scrollToBottom]);
// Submit comment
const handleSubmit = async () => {
if (!input.trim() || submitting || !isAuthenticated) return;
setSubmitting(true);
try {
await mediaPublicApi.post(`/public/${videoId}/comments`, {
content: input.trim(),
});
setInput('');
} catch {
// Silent
} finally {
setSubmitting(false);
}
};
// Format time
const formatTime = (iso: string) => {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header */}
<div
style={{
padding: '6px 10px',
borderBottom: `1px solid ${token.colorBorder}`,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Text strong style={{ fontSize: 12 }}>Chat</Text>
{sseConnected && <Tag color="success" style={{ fontSize: 10, lineHeight: '14px', padding: '0 4px' }}>Live</Tag>}
</div>
{/* Messages */}
<div
ref={scrollRef}
style={{
flex: 1,
overflowY: 'auto',
padding: 4,
}}
>
{loading && (
<div style={{ textAlign: 'center', padding: 20 }}>
<Spin size="small" />
</div>
)}
{!loading && comments.length === 0 && (
<div style={{ textAlign: 'center', padding: 20 }}>
<Text type="secondary" style={{ fontSize: 11 }}>No messages yet</Text>
</div>
)}
{comments.map((c) => (
<div key={c.id} style={{ padding: '4px 6px', fontSize: 12 }}>
<span style={{ display: 'flex', gap: 4, alignItems: 'baseline' }}>
<UserOutlined style={{ fontSize: 10, color: token.colorPrimary }} />
<Text strong style={{ fontSize: 11 }}>{c.user?.name || 'Anon'}</Text>
<Text type="secondary" style={{ fontSize: 10 }}>{formatTime(c.createdAt)}</Text>
</span>
<div style={{ paddingLeft: 14, fontSize: 12, wordBreak: 'break-word' }}>
{c.content}
</div>
</div>
))}
</div>
{/* Input */}
{isAuthenticated && isApproved && (
<div style={{ padding: 6, borderTop: `1px solid ${token.colorBorder}` }}>
<div style={{ display: 'flex', gap: 4 }}>
<TextArea
value={input}
onChange={(e) => setInput(e.target.value)}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder="Message..."
maxLength={1000}
autoSize={{ minRows: 1, maxRows: 2 }}
disabled={submitting}
style={{ fontSize: 12 }}
/>
<Button
type="primary"
size="small"
icon={<SendOutlined />}
onClick={handleSubmit}
loading={submitting}
disabled={!input.trim()}
/>
</div>
</div>
)}
{!isAuthenticated && (
<div style={{ padding: '8px', textAlign: 'center', borderTop: `1px solid ${token.colorBorder}` }}>
<Text type="secondary" style={{ fontSize: 11 }}>Sign in to chat</Text>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { Badge, Typography, theme } from 'antd';
import { CloseOutlined, MessageOutlined } from '@ant-design/icons';
import type { ChatWindow } from './ChatBarContext';
const { Text } = Typography;
interface MinimizedChatProps {
window: ChatWindow;
onExpand: () => void;
onClose: () => void;
}
export default function MinimizedChat({ window: chatWindow, onExpand, onClose }: MinimizedChatProps) {
const { token } = theme.useToken();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
background: token.colorBgElevated,
borderRadius: '12px 12px 0 0',
border: `1px solid ${token.colorBorder}`,
borderBottom: 'none',
cursor: 'pointer',
minWidth: 180,
maxWidth: 240,
}}
onClick={onExpand}
>
<Badge count={chatWindow.unreadCount} size="small" offset={[-2, 2]}>
<MessageOutlined style={{ fontSize: 16, color: token.colorPrimary }} />
</Badge>
<Text
ellipsis
style={{
flex: 1,
fontSize: 13,
fontWeight: chatWindow.unreadCount > 0 ? 600 : 400,
}}
>
{chatWindow.videoTitle}
</Text>
<CloseOutlined
style={{ fontSize: 12, color: token.colorTextSecondary }}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
/>
</div>
);
}

View File

@ -0,0 +1,59 @@
import { Card, Table, Tag } from 'antd';
import dayjs from 'dayjs';
import type { AlertInfo } from '@/types/api';
interface AlertsTableProps {
alerts: AlertInfo[];
loading: boolean;
}
export function AlertsTable({ alerts, loading }: AlertsTableProps) {
const columns = [
{
title: 'Severity',
dataIndex: 'severity',
key: 'severity',
width: 100,
render: (severity: string) => {
const colorMap: Record<string, string> = {
critical: 'red',
warning: 'orange',
info: 'blue',
};
return <Tag color={colorMap[severity] || 'default'}>{severity.toUpperCase()}</Tag>;
},
},
{
title: 'Alert',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: 'Summary',
dataIndex: 'summary',
key: 'summary',
ellipsis: true,
},
{
title: 'Started',
dataIndex: 'startsAt',
key: 'startsAt',
width: 180,
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
},
];
return (
<Card title={`Active Alerts (${alerts.length})`} style={{ marginBottom: 16 }}>
<Table
dataSource={alerts}
columns={columns}
loading={loading}
rowKey="id"
pagination={false}
size="small"
/>
</Card>
);
}

View File

@ -0,0 +1,55 @@
import { Component, ReactNode } from 'react';
import { Alert, Button } from 'antd';
interface Props {
children: ReactNode;
serviceName: string;
}
interface State {
hasError: boolean;
error?: Error;
}
/**
* Error boundary for iframe components
* Prevents iframe loading failures from crashing the entire page
*/
export class IframeErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Iframe loading error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<Alert
message={`Failed to load ${this.props.serviceName}`}
description={this.state.error?.message || 'Unknown error occurred'}
type="error"
showIcon
action={
<Button
size="small"
onClick={() => window.location.reload()}
>
Reload Page
</Button>
}
style={{ marginBottom: 16 }}
/>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,81 @@
import { Card, Row, Col, Statistic } from 'antd';
import type { MetricsSummary } from '@/types/api';
interface MetricsGridProps {
metrics: MetricsSummary | null;
loading: boolean;
}
export function MetricsGrid({ metrics, loading }: MetricsGridProps) {
if (!metrics) return null;
const formatUptime = (seconds: number) => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
return (
<Card title="Key Metrics" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Statistic
title="API Uptime"
value={formatUptime(metrics.apiUptime.value)}
loading={loading}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Statistic
title="Email Queue"
value={metrics.emailQueueSize.value}
suffix="jobs"
loading={loading}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Statistic
title="Request Rate"
value={metrics.requestRate.value.toFixed(2)}
suffix="req/s"
loading={loading}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Statistic
title="Email Error Rate"
value={metrics.emailErrorRate.value.toFixed(2)}
suffix="%"
loading={loading}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Statistic
title="Active Sessions"
value={metrics.activeSessions.value}
suffix="sessions"
loading={loading}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Statistic
title="Active Canvass"
value={metrics.activeCanvassSessions.value}
suffix="sessions"
loading={loading}
/>
</Col>
<Col xs={24} sm={12} lg={6}>
<Statistic
title="Redis Health"
value={metrics.redisHealth.value === 1 ? 'Up' : 'Down'}
loading={loading}
/>
</Col>
</Row>
</Card>
);
}

View File

@ -0,0 +1,35 @@
import { Card, Badge, Button } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
interface ServiceStatusCardProps {
name: string;
online: boolean;
url: string;
icon: React.ReactNode;
}
export function ServiceStatusCard({ name, online, url, icon }: ServiceStatusCardProps) {
return (
<Card size="small">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 24 }}>{icon}</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{name}</div>
<Badge
status={online ? 'success' : 'error'}
text={online ? 'Online' : 'Offline'}
/>
</div>
<Button
type="link"
href={url}
target="_blank"
disabled={!online}
icon={online ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
>
{online ? 'Open' : 'Offline'}
</Button>
</div>
</Card>
);
}

View File

@ -0,0 +1,82 @@
import { Modal, Radio, Space, Typography } from 'antd';
import { useState } from 'react';
import type { EditMode } from '@/types/api';
import dayjs from 'dayjs';
const { Text } = Typography;
interface Props {
open: boolean;
onCancel: () => void;
onConfirm: (editMode: EditMode) => void;
shiftDate: string;
shiftsCount: number;
}
export default function EditModeModal({
open,
onCancel,
onConfirm,
shiftDate,
shiftsCount,
}: Props) {
const [mode, setMode] = useState<'THIS' | 'FUTURE' | 'ALL'>('THIS');
const handleOk = () => {
onConfirm({
mode,
fromDate: mode === 'FUTURE' ? shiftDate : undefined,
});
};
return (
<Modal
open={open}
onCancel={onCancel}
title="Edit Shift Series"
okText="Continue"
onOk={handleOk}
width={500}
>
<Text>
This shift is part of a repeating series. What would you like to edit?
</Text>
<Radio.Group
value={mode}
onChange={(e) => setMode(e.target.value)}
style={{ marginTop: 16, width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value="THIS">
<strong>Only this occurrence</strong>
<div style={{ marginLeft: 24, color: '#666' }}>
<Text type="secondary">
Remove this shift from the series. Future shifts remain unchanged.
</Text>
</div>
</Radio>
<Radio value="FUTURE">
<strong>This and all future occurrences</strong>
<div style={{ marginLeft: 24, color: '#666' }}>
<Text type="secondary">
Update this shift and all shifts after {dayjs(shiftDate).format('MMM D, YYYY')}.
Past shifts remain unchanged.
</Text>
</div>
</Radio>
<Radio value="ALL">
<strong>All occurrences in the series</strong>
<div style={{ marginLeft: 24, color: '#666' }}>
<Text type="secondary">
Update all {shiftsCount} shifts in the series.
</Text>
</div>
</Radio>
</Space>
</Radio.Group>
</Modal>
);
}

View File

@ -0,0 +1,73 @@
import { Calendar, Badge, Tag, Spin } from 'antd';
import type { CalendarProps } from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import type { Shift } from '@/types/api';
interface Props {
loading: boolean;
calendarData: Record<string, { count: number; shifts: Shift[] }>;
onSelectDate: (date: Dayjs) => void;
onSelectShift: (shift: Shift) => void;
}
export default function ShiftsCalendar({
loading,
calendarData,
onSelectDate,
onSelectShift,
}: Props) {
const cellRender: CalendarProps<Dayjs>['cellRender'] = (current, info) => {
if (info.type !== 'date') return info.originNode;
const dateKey = current.format('YYYY-MM-DD');
const dayData = calendarData[dateKey];
if (!dayData) return null;
return (
<div>
<Badge count={dayData.count} style={{ backgroundColor: '#1890ff' }} />
<div style={{ marginTop: 8 }}>
{dayData.shifts.slice(0, 3).map((shift) => (
<Tag
key={shift.id}
color={shift.status === 'OPEN' ? 'blue' : shift.status === 'FULL' ? 'green' : 'red'}
style={{
marginBottom: 4,
cursor: 'pointer',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
onClick={(e) => {
e.stopPropagation();
onSelectShift(shift);
}}
>
{shift.startTime} {shift.title}
</Tag>
))}
{dayData.count > 3 && (
<Tag style={{ display: 'block' }}>+{dayData.count - 3} more</Tag>
)}
</div>
</div>
);
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
);
}
return (
<Calendar
cellRender={cellRender}
onSelect={onSelectDate}
/>
);
}

View File

@ -0,0 +1,81 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
export interface VideoData {
id: number;
filename: string;
category: string | null;
durationSeconds: number | null;
quality: string | null;
orientation: string | null;
thumbnailPath: string | null;
viewCount: number;
upvoteCount: number;
commentCount: number;
isLocked: boolean;
createdAt: string;
}
interface ExpandedVideoState {
videoId: number | null;
video: VideoData | null;
}
interface ExpandedVideoContextValue {
state: ExpandedVideoState;
expandVideo: (id: number, video: VideoData) => void;
collapseVideo: () => void;
}
const ExpandedVideoContext = createContext<ExpandedVideoContextValue | undefined>(undefined);
export function useExpandedVideo() {
const context = useContext(ExpandedVideoContext);
if (!context) {
throw new Error('useExpandedVideo must be used within ExpandedVideoProvider');
}
return context;
}
interface ExpandedVideoProviderProps {
children: ReactNode;
}
export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps) {
const navigate = useNavigate();
const [state, setState] = useState<ExpandedVideoState>({
videoId: null,
video: null,
});
const expandVideo = useCallback((id: number, video: VideoData) => {
setState({ videoId: id, video });
// Update URL with ?expanded=id (read current params at call time)
const newParams = new URLSearchParams(window.location.search);
newParams.set('expanded', id.toString());
navigate({ search: newParams.toString() }, { replace: true });
}, [navigate]);
const collapseVideo = useCallback(() => {
setState({ videoId: null, video: null });
// Remove URL param (read current params at call time)
const newParams = new URLSearchParams(window.location.search);
newParams.delete('expanded');
navigate({ search: newParams.toString() }, { replace: true });
}, [navigate]);
const value: ExpandedVideoContextValue = {
state,
expandVideo,
collapseVideo,
};
return (
<ExpandedVideoContext.Provider value={value}>
{children}
</ExpandedVideoContext.Provider>
);
}

View File

@ -0,0 +1,66 @@
import { createContext, useContext, ReactNode } from 'react';
import { useAuthStore } from '@/stores/auth.store';
import { isAdmin } from '@/utils/roles';
interface MediaAuthState {
isAuthenticated: boolean;
isApproved: boolean; // True if NOT a USER or TEMP role
user: {
id: string;
email: string;
role: string;
} | null;
token: string | null;
}
interface MediaAuthContextValue extends MediaAuthState {
checkAuth: () => void;
}
const MediaAuthContext = createContext<MediaAuthContextValue | undefined>(undefined);
export function useMediaAuth() {
const context = useContext(MediaAuthContext);
if (!context) {
throw new Error('useMediaAuth must be used within MediaAuthProvider');
}
return context;
}
interface MediaAuthProviderProps {
children: ReactNode;
}
export function MediaAuthProvider({ children }: MediaAuthProviderProps) {
// Read auth state directly from the Zustand auth store (single source of truth)
const authUser = useAuthStore((s) => s.user);
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const hydrate = useAuthStore((s) => s.hydrate);
// Approved means user has an admin role (admins can chat)
const isApproved = isAuthenticated && !!authUser && isAdmin(authUser);
const user = authUser
? { id: authUser.id, email: authUser.email, role: authUser.role }
: null;
const checkAuth = () => {
// Re-hydrate from persisted storage
hydrate();
};
const value: MediaAuthContextValue = {
isAuthenticated,
isApproved,
user,
token: accessToken,
checkAuth,
};
return (
<MediaAuthContext.Provider value={value}>
{children}
</MediaAuthContext.Provider>
);
}

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