Tonne of debugging - getting ready for the production builds
This commit is contained in:
parent
a77306fac2
commit
7895ce683e
180
.claude/agents/foss-compliance-reviewer.md
Normal file
180
.claude/agents/foss-compliance-reviewer.md
Normal 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.
|
||||||
818
CLAUDE.md
818
CLAUDE.md
@ -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`.
|
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
|
### 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
|
- **React Admin GUI** — Vite + Ant Design + Zustand, port 3000
|
||||||
- **Nginx reverse proxy** — subdomain routing (`*.cmlite.org`)
|
- **Nginx reverse proxy** — subdomain routing (`*.cmlite.org`)
|
||||||
- **NocoDB v2** — read-only data browser on port 8091
|
- **NocoDB v2** — read-only data browser on port 8091
|
||||||
- **JWT auth** — access tokens (15min) + refresh tokens (7 days, stored in DB)
|
- **Redis** — caching, rate limiting, BullMQ backend, geocoding queue (authenticated)
|
||||||
- **BullMQ** — async email job queue, **Listmonk** for newsletters
|
- **Monitoring Stack** (Docker profile: `monitoring`) — Prometheus, Grafana, Alertmanager, cAdvisor, exporters
|
||||||
- **Redis** — caching, rate limiting, BullMQ backend
|
|
||||||
|
|
||||||
### 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/
|
changemaker.lite/
|
||||||
├── api/ # Unified Express.js API (TypeScript)
|
├── api/ # Dual API servers (Express + Fastify)
|
||||||
│ ├── prisma/ # Schema, migrations, seed
|
│ ├── 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/
|
│ └── src/
|
||||||
│ ├── config/ # env.ts, database.ts, redis.ts
|
│ ├── server.ts # Express API entry point (port 4000)
|
||||||
│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac
|
│ ├── 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/
|
│ ├── modules/
|
||||||
│ │ ├── auth/ # auth.service, auth.routes, auth.schemas
|
│ │ ├── auth/ # JWT login, register, refresh, logout
|
||||||
│ │ ├── users/ # users.service, users.routes, users.schemas
|
│ │ ├── users/ # User CRUD + pagination + search
|
||||||
│ │ ├── influence/ # campaigns, representatives, responses, postal-codes
|
│ │ ├── settings/ # Site settings singleton
|
||||||
│ │ └── map/ # locations, shifts, cuts
|
│ │ ├── 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)
|
│ ├── types/ # express.d.ts (Request augmentation)
|
||||||
│ └── utils/ # logger.ts (Winston), metrics.ts (prom-client)
|
│ └── utils/ # logger (Winston), metrics (prom-client), spatial
|
||||||
|
│
|
||||||
├── admin/ # React Admin (Vite + Ant Design + Zustand)
|
├── admin/ # React Admin (Vite + Ant Design + Zustand)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── components/ # ProtectedRoute, AppLayout
|
│ ├── App.tsx # Main router + route definitions
|
||||||
│ ├── pages/ # LoginPage, DashboardPage, UsersPage
|
│ ├── components/
|
||||||
│ ├── stores/ # auth.store.ts (Zustand)
|
│ │ ├── AppLayout.tsx # Admin sidebar layout
|
||||||
│ ├── lib/ # api.ts (axios instance + interceptors)
|
│ │ ├── PublicLayout.tsx # Public dark theme layout
|
||||||
│ └── types/ # api.ts (TypeScript interfaces)
|
│ │ ├── VolunteerLayout.tsx # Volunteer portal layout
|
||||||
├── nginx/ # Reverse proxy config
|
│ │ ├── MediaPublicLayout.tsx # Public media gallery layout
|
||||||
├── public-web/ # Public landing pages
|
│ │ ├── GrapesJSEditor.tsx # Landing page editor wrapper (forwardRef, Ctrl+S)
|
||||||
├── docker-compose.yml # V2 orchestration
|
│ │ ├── map/ # Leaflet map components + controls + drawing modes
|
||||||
├── docker-compose.v1.yml # V1 backup for reference
|
│ │ ├── 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
|
└── V2_PLAN.md # Full 14-phase roadmap
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Files
|
---
|
||||||
|
|
||||||
| File | Purpose |
|
## Quick Start Guide
|
||||||
|------|---------|
|
|
||||||
| `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
|
### Initial Setup (First Time)
|
||||||
|
|
||||||
- JWT-based: access tokens (15min) + refresh tokens (7 days, stored in DB)
|
1. **Clone repository and checkout v2 branch:**
|
||||||
- Login → verify bcrypt hash → generate token pair → return tokens + user
|
```bash
|
||||||
- Refresh → validate refresh token → rotate (invalidate old, issue new) → return new pair
|
git clone <repo-url> changemaker.lite
|
||||||
- Roles: `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `USER`, `TEMP`
|
cd changemaker.lite
|
||||||
- RBAC middleware: `requireRole(...roles)`, `requireNonTemp`
|
git checkout v2
|
||||||
|
```
|
||||||
|
|
||||||
### Nginx Routing
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
| Subdomain | Target |
|
3. **Start core services:**
|
||||||
|-----------|--------|
|
```bash
|
||||||
| `app.cmlite.org` | Admin React app (port 3000) |
|
docker compose up -d v2-postgres redis api admin
|
||||||
| `api.cmlite.org` | Express API (port 4000) |
|
```
|
||||||
| `data.cmlite.org` | NocoDB read-only (port 8091) |
|
|
||||||
| `docs.cmlite.org` | MkDocs (port 4001) |
|
4. **Run database migrations:**
|
||||||
| `cmlite.org` | Public landing pages |
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## V2 Development Commands
|
## Development Commands
|
||||||
|
|
||||||
|
The user likes to use Docker - recereating services as if in production.
|
||||||
|
|
||||||
### API Development
|
### API Development
|
||||||
```bash
|
```bash
|
||||||
cd api && npm run dev # Dev server with tsx watch (auto-reload)
|
cd api && npm run dev # Express dev server (port 4000)
|
||||||
cd api && npx tsc --noEmit # Type-check without emitting
|
cd api && npm run dev:media # Fastify media dev server (port 4100)
|
||||||
cd api && npx prisma migrate dev # Run/create migrations
|
cd api && npx tsc --noEmit # Type-check
|
||||||
cd api && npx prisma studio # Browse database in browser
|
cd api && npx prisma migrate dev # Run/create Prisma migrations
|
||||||
cd api && npx prisma generate # Regenerate Prisma client
|
cd api && npx prisma studio # Browse database
|
||||||
|
cd api && npx drizzle-kit push # Push Drizzle schema changes (media)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Admin GUI Development
|
### Admin Development
|
||||||
```bash
|
```bash
|
||||||
cd admin && npm run dev # Vite dev server (port 3000)
|
cd admin && npm run dev # Vite dev server (port 3000)
|
||||||
cd admin && npx tsc --noEmit # Type-check without emitting
|
cd admin && npx tsc --noEmit # Type-check
|
||||||
cd admin && npm run build # Production build (tsc + vite)
|
cd admin && npm run build # Production build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker (V2 Services)
|
### Docker Operations
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d v2-postgres redis api # Start API + dependencies
|
# Start services
|
||||||
docker compose up -d admin # Start admin GUI
|
docker compose up -d v2-postgres redis api admin
|
||||||
docker compose up -d # Start all v2 services
|
docker compose up -d media-api
|
||||||
docker compose logs -f api # Tail API logs
|
docker compose --profile monitoring up -d
|
||||||
docker compose exec api npx prisma migrate dev # Run migrations in container
|
|
||||||
docker compose down # Stop all services
|
# 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
|
```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
|
cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Port Reference (V2)
|
## Core Modules Reference
|
||||||
|
|
||||||
| Port | Service |
|
### Auth & Users
|
||||||
|------|---------|
|
|
||||||
| 3000 | Admin GUI (Vite dev / React) |
|
**Files:**
|
||||||
| 3001 | Grafana |
|
- `api/src/modules/auth/` — JWT login, register, refresh, logout
|
||||||
| 3010 | Homepage |
|
- `api/src/modules/users/` — User CRUD + pagination + search
|
||||||
| 3030 | Gitea |
|
- `api/src/middleware/auth.ts` — JWT verification + RBAC
|
||||||
| 4000 | V2 API (Express.js) |
|
- `admin/src/stores/auth.store.ts` — Zustand auth state + token persistence
|
||||||
| 4001 | MkDocs (built static) |
|
- `admin/src/lib/api.ts` — Axios with 401 refresh interceptor
|
||||||
| 5432 | Listmonk PostgreSQL |
|
|
||||||
| 5433 | V2 PostgreSQL (localhost) |
|
**Features:** JWT access/refresh tokens, bcrypt passwords (12+ chars), role-based access control, user enumeration prevention, rate limiting
|
||||||
| 5678 | n8n |
|
|
||||||
| 6379 | Redis |
|
### Influence Module (Advocacy Campaigns)
|
||||||
| 8025 | MailHog Web UI |
|
|
||||||
| 8080 | cAdvisor |
|
**Files:**
|
||||||
| 8089 | Mini QR |
|
- `api/src/modules/influence/campaigns/` — Campaign CRUD + public routes
|
||||||
| 8091 | NocoDB v2 (read-only) |
|
- `api/src/modules/influence/representatives/` — Represent API client + cache
|
||||||
| 8888 | Code Server |
|
- `api/src/modules/influence/responses/` — Response wall + moderation + upvoting
|
||||||
| 9001 | Listmonk |
|
- `api/src/services/email-queue.service.ts` — BullMQ queue + worker
|
||||||
| 9090 | Prometheus |
|
- `admin/src/pages/CampaignsPage.tsx` — Campaign management
|
||||||
| 9093 | Alertmanager |
|
- `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 Reference (Legacy)
|
||||||
|
|
||||||
V1 code is preserved in `influence/` and `map/` directories and backed up in `docker-compose.v1.yml`.
|
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
|
||||||
### 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
|
|
||||||
- `map/README.md` — Features, config, setup instructions
|
- `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
|
## Key Configuration Files
|
||||||
|
|
||||||
| File | Purpose |
|
### Infrastructure
|
||||||
|------|---------|
|
- `docker-compose.yml` — V2 orchestration (20+ services, monitoring profile)
|
||||||
| `docker-compose.yml` | V2 orchestration (all services) |
|
- `.env` / `.env.example` — Environment variables (100+ vars)
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## 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
BIN
NARguide.pdf
Normal file
Binary file not shown.
181
PANGOLIN_NGINX_FIX_SUMMARY.md
Normal file
181
PANGOLIN_NGINX_FIX_SUMMARY.md
Normal 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
200
PRODUCTION_403_FIX.md
Normal 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
BIN
RNAguide.pdf
Normal file
Binary file not shown.
15
V2_PLAN.md
15
V2_PLAN.md
@ -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)
|
- [ ] API integration tests (Jest/Vitest)
|
||||||
- [ ] Admin E2E tests
|
- [ ] Admin E2E tests
|
||||||
- [ ] Performance optimization
|
- [ ] Performance optimization
|
||||||
- [ ] Security audit
|
- [ ] Security audit (auth-security-reviewer for media features)
|
||||||
- [ ] Documentation updates
|
- [ ] UI design review (ui-design-critic for media components)
|
||||||
|
|
||||||
### PHASE 1: Extras
|
### PHASE 1: Extras
|
||||||
- [ ] Add apache answers
|
- [ ] Add apache answers
|
||||||
|
|||||||
419
admin/package-lock.json
generated
419
admin/package-lock.json
generated
@ -11,9 +11,11 @@
|
|||||||
"@ant-design/icons": "^5.6.0",
|
"@ant-design/icons": "^5.6.0",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
"antd": "^5.23.0",
|
"antd": "^5.23.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"grapesjs": "^0.22.14",
|
"grapesjs": "^0.22.14",
|
||||||
"grapesjs-blocks-basic": "^1.0.2",
|
"grapesjs-blocks-basic": "^1.0.2",
|
||||||
"grapesjs-component-countdown": "^1.0.2",
|
"grapesjs-component-countdown": "^1.0.2",
|
||||||
@ -26,11 +28,14 @@
|
|||||||
"grapesjs-tabs": "^1.0.6",
|
"grapesjs-tabs": "^1.0.6",
|
||||||
"grapesjs-touch": "^0.1.1",
|
"grapesjs-touch": "^0.1.1",
|
||||||
"grapesjs-typed": "^2.0.1",
|
"grapesjs-typed": "^2.0.1",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-leaflet-cluster": "^4.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
@ -901,7 +906,6 @@
|
|||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/loader": "^1.5.0"
|
"@monaco-editor/loader": "^1.5.0"
|
||||||
},
|
},
|
||||||
@ -1059,6 +1063,40 @@
|
|||||||
"react-dom": "^19.0.0"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@ -1390,6 +1428,16 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@ -1440,6 +1488,69 @@
|
|||||||
"@types/underscore": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -1502,14 +1613,18 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/underscore": {
|
"node_modules/@types/underscore": {
|
||||||
"version": "1.13.0",
|
"version": "1.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz",
|
||||||
"integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA=="
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
"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": {
|
"node_modules/codemirror": {
|
||||||
"version": "5.63.0",
|
"version": "5.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"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": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"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": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@ -1794,10 +2032,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.2.7",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||||
"peer": true,
|
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@ -1862,6 +2099,11 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@ -1912,6 +2154,11 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
|
||||||
"integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA=="
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@ -2200,12 +2464,29 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/leaflet": {
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@ -2264,6 +2545,15 @@
|
|||||||
"marked": "14.0.0"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@ -2960,6 +3250,43 @@
|
|||||||
"react-dom": "^19.0.0"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@ -3005,6 +3332,50 @@
|
|||||||
"react-dom": ">=18"
|
"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": {
|
"node_modules/resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
@ -3113,6 +3484,11 @@
|
|||||||
"node": ">=12.22"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@ -3182,6 +3558,35 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
|
|||||||
@ -12,9 +12,11 @@
|
|||||||
"@ant-design/icons": "^5.6.0",
|
"@ant-design/icons": "^5.6.0",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
"antd": "^5.23.0",
|
"antd": "^5.23.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"grapesjs": "^0.22.14",
|
"grapesjs": "^0.22.14",
|
||||||
"grapesjs-blocks-basic": "^1.0.2",
|
"grapesjs-blocks-basic": "^1.0.2",
|
||||||
"grapesjs-component-countdown": "^1.0.2",
|
"grapesjs-component-countdown": "^1.0.2",
|
||||||
@ -27,11 +29,14 @@
|
|||||||
"grapesjs-tabs": "^1.0.6",
|
"grapesjs-tabs": "^1.0.6",
|
||||||
"grapesjs-touch": "^0.1.1",
|
"grapesjs-touch": "^0.1.1",
|
||||||
"grapesjs-typed": "^2.0.1",
|
"grapesjs-typed": "^2.0.1",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-leaflet-cluster": "^4.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,14 +8,17 @@ import FeatureGate from '@/components/FeatureGate';
|
|||||||
import AppLayout from '@/components/AppLayout';
|
import AppLayout from '@/components/AppLayout';
|
||||||
import PublicLayout from '@/components/PublicLayout';
|
import PublicLayout from '@/components/PublicLayout';
|
||||||
import VolunteerLayout from '@/components/VolunteerLayout';
|
import VolunteerLayout from '@/components/VolunteerLayout';
|
||||||
|
import MediaPublicLayout from '@/components/MediaPublicLayout';
|
||||||
import LoginPage from '@/pages/LoginPage';
|
import LoginPage from '@/pages/LoginPage';
|
||||||
import DashboardPage from '@/pages/DashboardPage';
|
import DashboardPage from '@/pages/DashboardPage';
|
||||||
import UsersPage from '@/pages/UsersPage';
|
import UsersPage from '@/pages/UsersPage';
|
||||||
import CampaignsPage from '@/pages/CampaignsPage';
|
import CampaignsPage from '@/pages/CampaignsPage';
|
||||||
import RepresentativesPage from '@/pages/RepresentativesPage';
|
import RepresentativesPage from '@/pages/RepresentativesPage';
|
||||||
import EmailQueuePage from '@/pages/EmailQueuePage';
|
import EmailQueuePage from '@/pages/EmailQueuePage';
|
||||||
|
import EmailTemplatesPage from '@/pages/EmailTemplatesPage';
|
||||||
import ResponsesPage from '@/pages/ResponsesPage';
|
import ResponsesPage from '@/pages/ResponsesPage';
|
||||||
import LocationsPage from '@/pages/LocationsPage';
|
import LocationsPage from '@/pages/LocationsPage';
|
||||||
|
import DataQualityDashboardPage from '@/pages/DataQualityDashboardPage';
|
||||||
import CutsPage from '@/pages/CutsPage';
|
import CutsPage from '@/pages/CutsPage';
|
||||||
import ShiftsPage from '@/pages/ShiftsPage';
|
import ShiftsPage from '@/pages/ShiftsPage';
|
||||||
import MapSettingsPage from '@/pages/MapSettingsPage';
|
import MapSettingsPage from '@/pages/MapSettingsPage';
|
||||||
@ -23,7 +26,6 @@ import CutExportPage from '@/pages/CutExportPage';
|
|||||||
import CanvassDashboardPage from '@/pages/CanvassDashboardPage';
|
import CanvassDashboardPage from '@/pages/CanvassDashboardPage';
|
||||||
import ListmonkPage from '@/pages/ListmonkPage';
|
import ListmonkPage from '@/pages/ListmonkPage';
|
||||||
import LandingPagesPage from '@/pages/LandingPagesPage';
|
import LandingPagesPage from '@/pages/LandingPagesPage';
|
||||||
import PageEditorPage from '@/pages/PageEditorPage';
|
|
||||||
import DocsPage from '@/pages/DocsPage';
|
import DocsPage from '@/pages/DocsPage';
|
||||||
import MkDocsSettingsPage from '@/pages/MkDocsSettingsPage';
|
import MkDocsSettingsPage from '@/pages/MkDocsSettingsPage';
|
||||||
import CodeEditorPage from '@/pages/CodeEditorPage';
|
import CodeEditorPage from '@/pages/CodeEditorPage';
|
||||||
@ -31,14 +33,22 @@ import NocoDBPage from '@/pages/NocoDBPage';
|
|||||||
import N8nPage from '@/pages/N8nPage';
|
import N8nPage from '@/pages/N8nPage';
|
||||||
import GiteaPage from '@/pages/GiteaPage';
|
import GiteaPage from '@/pages/GiteaPage';
|
||||||
import MailHogPage from '@/pages/MailHogPage';
|
import MailHogPage from '@/pages/MailHogPage';
|
||||||
|
import MiniQRPage from '@/pages/MiniQRPage';
|
||||||
|
import ExcalidrawPage from '@/pages/ExcalidrawPage';
|
||||||
import SettingsPage from '@/pages/SettingsPage';
|
import SettingsPage from '@/pages/SettingsPage';
|
||||||
import PangolinPage from '@/pages/PangolinPage';
|
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 PublicLandingPage from '@/pages/public/LandingPage';
|
import PublicLandingPage from '@/pages/public/LandingPage';
|
||||||
import CampaignsListPage from '@/pages/public/CampaignsListPage';
|
import CampaignsListPage from '@/pages/public/CampaignsListPage';
|
||||||
import CampaignPage from '@/pages/public/CampaignPage';
|
import CampaignPage from '@/pages/public/CampaignPage';
|
||||||
import ResponseWallPage from '@/pages/public/ResponseWallPage';
|
import ResponseWallPage from '@/pages/public/ResponseWallPage';
|
||||||
import MapPage from '@/pages/public/MapPage';
|
import MapPage from '@/pages/public/MapPage';
|
||||||
import PublicShiftsPage from '@/pages/public/ShiftsPage';
|
import PublicShiftsPage from '@/pages/public/ShiftsPage';
|
||||||
|
import MediaGalleryPage from '@/pages/public/MediaGalleryPage';
|
||||||
|
import MediaViewerPage from '@/pages/public/MediaViewerPage';
|
||||||
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
|
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
|
||||||
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
|
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
|
||||||
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
|
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
|
||||||
@ -126,6 +136,15 @@ export default function App() {
|
|||||||
<Route path="/map" element={<FeatureGate feature="enableMap"><MapPage /></FeatureGate>} />
|
<Route path="/map" element={<FeatureGate feature="enableMap"><MapPage /></FeatureGate>} />
|
||||||
<Route path="/p/:slug" element={<FeatureGate feature="enableLandingPages"><PublicLandingPage /></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=":category" element={<MediaGalleryPage />} />
|
||||||
|
</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 */}
|
{/* Volunteer map — full-screen, default landing page */}
|
||||||
<Route
|
<Route
|
||||||
path="/volunteer"
|
path="/volunteer"
|
||||||
@ -156,14 +175,6 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
|
||||||
path="/app/pages/:id/edit"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
|
||||||
<PageEditorPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/app"
|
path="/app"
|
||||||
element={
|
element={
|
||||||
@ -205,6 +216,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="email-templates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<EmailTemplatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="responses"
|
path="responses"
|
||||||
element={
|
element={
|
||||||
@ -285,6 +304,22 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="services/miniqr"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||||
|
<MiniQRPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="services/excalidraw"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||||
|
<ExcalidrawPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings"
|
path="settings"
|
||||||
element={
|
element={
|
||||||
@ -301,6 +336,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="observability"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||||
|
<ObservabilityPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="map"
|
path="map"
|
||||||
element={
|
element={
|
||||||
@ -309,6 +352,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="map/data-quality"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<DataQualityDashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="map/shifts"
|
path="map/shifts"
|
||||||
element={
|
element={
|
||||||
@ -349,6 +400,30 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</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>
|
</Route>
|
||||||
<Route path="*" element={<RoleAwareRedirect />} />
|
<Route path="*" element={<RoleAwareRedirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||||
import { Layout, Menu, Dropdown, Button, Typography, Drawer, Grid, theme } from 'antd';
|
import { Layout, Menu, Dropdown, Button, Typography, Drawer, Grid, theme } from 'antd';
|
||||||
import {
|
import {
|
||||||
@ -26,25 +26,27 @@ import {
|
|||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
BranchesOutlined,
|
BranchesOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
EditOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.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 { Header, Sider, Content } = Layout;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
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'] {
|
function buildMenuItems(settings: import('@/types/api').SiteSettings | null): MenuProps['items'] {
|
||||||
const items: MenuProps['items'] = [
|
const items: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
@ -70,9 +72,13 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
|
|
||||||
if (settings?.enableNewsletter !== false) {
|
if (settings?.enableNewsletter !== false) {
|
||||||
items.push({
|
items.push({
|
||||||
key: '/app/listmonk',
|
key: 'broadcast-submenu',
|
||||||
icon: <NotificationOutlined />,
|
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 +103,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
icon: <EnvironmentOutlined />,
|
icon: <EnvironmentOutlined />,
|
||||||
label: 'Map',
|
label: 'Map',
|
||||||
children: [
|
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/shifts', icon: <CalendarOutlined />, label: 'Shifts' },
|
||||||
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Cuts' },
|
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Cuts' },
|
||||||
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
||||||
@ -106,6 +113,18 @@ 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/jobs', icon: <HistoryOutlined />, label: 'Processing Jobs' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
key: 'services-submenu',
|
key: 'services-submenu',
|
||||||
icon: <CloudServerOutlined />,
|
icon: <CloudServerOutlined />,
|
||||||
@ -115,7 +134,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
|
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
|
||||||
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
|
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
|
||||||
{ key: '/app/services/mailhog', icon: <MailOutlined />, label: 'MailHog' },
|
{ 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/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
|
||||||
|
{ key: '/app/observability', icon: <LineChartOutlined />, label: 'Observability' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -230,7 +252,6 @@ export default function AppLayout() {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
defaultOpenKeys={['influence-submenu', 'map-submenu', 'web-submenu', 'services-submenu']}
|
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
/>
|
/>
|
||||||
@ -304,6 +325,22 @@ export default function AppLayout() {
|
|||||||
>
|
>
|
||||||
{!isMobile && 'Canvass'}
|
{!isMobile && 'Canvass'}
|
||||||
</Button>
|
</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">
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
<Button type="text" icon={<UserOutlined />}>
|
<Button type="text" icon={<UserOutlined />}>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useSettingsStore } from '@/stores/settings.store';
|
|||||||
import type { SiteSettings } from '@/types/api';
|
import type { SiteSettings } from '@/types/api';
|
||||||
|
|
||||||
interface FeatureGateProps {
|
interface FeatureGateProps {
|
||||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter'>;
|
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures'>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
<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>
|
</form>
|
||||||
</section>`;
|
</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:
|
default:
|
||||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||||
}
|
}
|
||||||
|
|||||||
104
admin/src/components/MediaPublicLayout.tsx
Normal file
104
admin/src/components/MediaPublicLayout.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
|
export default function MediaPublicLayout() {
|
||||||
|
// Purple theme tokens matching media-manager aesthetic
|
||||||
|
const colorPrimary = '#9333ea'; // purple-600
|
||||||
|
const colorBgBase = '#0d0d12'; // nearly black
|
||||||
|
const colorBgContainer = '#18181b'; // zinc-900
|
||||||
|
const colorBgElevated = '#27272a'; // zinc-800
|
||||||
|
|
||||||
|
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 | Changemaker Lite';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
colorBorder: 'rgba(147, 51, 234, 0.2)', // purple border
|
||||||
|
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: 12,
|
||||||
|
colorLink: '#a855f7', // purple-500
|
||||||
|
colorLinkHover: '#c084fc', // purple-400
|
||||||
|
colorText: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
colorTextSecondary: 'rgba(255, 255, 255, 0.65)',
|
||||||
|
colorTextTertiary: 'rgba(255, 255, 255, 0.45)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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: isMobile ? 56 : 0, // Space for mobile bottom nav
|
||||||
|
transition: 'margin-left 0.3s ease',
|
||||||
|
background: colorBgBase,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: isMobile ? '16px 12px' : '24px 32px',
|
||||||
|
maxWidth: 1400, // Wider for video grid
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Mobile: Show bottom nav, Desktop: Hide */}
|
||||||
|
<MediaBottomNav />
|
||||||
|
</Layout>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ConfigProvider, Layout, Typography, theme } from 'antd';
|
import { ConfigProvider, Layout, Typography, theme, Space } from 'antd';
|
||||||
import { Outlet, Link } from 'react-router-dom';
|
import { Outlet, Link } from 'react-router-dom';
|
||||||
|
import { PlayCircleOutlined } from '@ant-design/icons';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
const { Header, Content, Footer } = Layout;
|
const { Header, Content, Footer } = Layout;
|
||||||
@ -53,12 +54,13 @@ export default function PublicLayout() {
|
|||||||
background: headerGradient,
|
background: headerGradient,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'space-between',
|
||||||
padding: '0 24px',
|
padding: '0 24px',
|
||||||
height: 56,
|
height: 56,
|
||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Left: Logo */}
|
||||||
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 10 }}>
|
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<img
|
<img
|
||||||
@ -71,6 +73,31 @@ export default function PublicLayout() {
|
|||||||
{orgName}
|
{orgName}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Right: Navigation */}
|
||||||
|
<Space size={24}>
|
||||||
|
<Link
|
||||||
|
to="/gallery"
|
||||||
|
style={{
|
||||||
|
color: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = '#fff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
<span>Media Gallery</span>
|
||||||
|
</Link>
|
||||||
|
</Space>
|
||||||
</Header>
|
</Header>
|
||||||
<Content
|
<Content
|
||||||
style={{
|
style={{
|
||||||
@ -94,7 +121,11 @@ export default function PublicLayout() {
|
|||||||
<div>{footerText}</div>
|
<div>{footerText}</div>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
||||||
Return to Main Page
|
Campaigns
|
||||||
|
</Link>
|
||||||
|
{' • '}
|
||||||
|
<Link to="/gallery" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
||||||
|
Media Gallery
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</Footer>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
NodeIndexOutlined,
|
NodeIndexOutlined,
|
||||||
|
MenuOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@ -16,9 +17,11 @@ const NAV_ITEMS = [
|
|||||||
|
|
||||||
interface VolunteerFooterNavProps {
|
interface VolunteerFooterNavProps {
|
||||||
style?: React.CSSProperties;
|
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 navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@ -47,6 +50,29 @@ export default function VolunteerFooterNav({ style }: VolunteerFooterNavProps) {
|
|||||||
...style,
|
...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 }) => {
|
{NAV_ITEMS.map(({ key, icon: Icon, label }) => {
|
||||||
const isActive = activeKey === key;
|
const isActive = activeKey === key;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -15,6 +15,7 @@ interface AddLocationDrawerProps {
|
|||||||
userRole: UserRole;
|
userRole: UserRole;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
shiftId?: string;
|
shiftId?: string;
|
||||||
|
zIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcomeKeys: VisitOutcome[] = [
|
const outcomeKeys: VisitOutcome[] = [
|
||||||
@ -35,12 +36,13 @@ export default function AddLocationDrawer({
|
|||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
userRole,
|
userRole,
|
||||||
sessionId,
|
sessionId: _sessionId,
|
||||||
shiftId,
|
shiftId: _shiftId,
|
||||||
|
zIndex = 1000,
|
||||||
}: AddLocationDrawerProps) {
|
}: AddLocationDrawerProps) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const { addLocation, recordVisit, reverseGeocode } = useCanvassStore();
|
const { addLocation, reverseGeocode } = useCanvassStore();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [geocoding, setGeocoding] = useState(false);
|
const [geocoding, setGeocoding] = useState(false);
|
||||||
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
|
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
|
||||||
@ -99,24 +101,16 @@ export default function AddLocationDrawer({
|
|||||||
if (showDetailFields && notes) locationData.notes = notes;
|
if (showDetailFields && notes) locationData.notes = notes;
|
||||||
|
|
||||||
// Create location
|
// Create location
|
||||||
const newLoc = await addLocation(locationData);
|
await addLocation(locationData);
|
||||||
|
|
||||||
// Track event point for location added
|
// Track event point for location added
|
||||||
useTrackingStore.getState().addEventPoint(lat, lng, 'LOCATION_ADDED');
|
useTrackingStore.getState().addEventPoint(lat, lng, 'LOCATION_ADDED');
|
||||||
|
|
||||||
// Record visit on the new location
|
// TODO: Record visit on the new address
|
||||||
await recordVisit({
|
// Need to get addressId from created location (returned from addLocation above)
|
||||||
locationId: newLoc.id,
|
// For now, just add the location - visit can be recorded separately
|
||||||
outcome,
|
|
||||||
supportLevel,
|
|
||||||
signRequested,
|
|
||||||
signSize,
|
|
||||||
notes: notes || undefined,
|
|
||||||
sessionId,
|
|
||||||
shiftId,
|
|
||||||
});
|
|
||||||
|
|
||||||
message.success('Location added & visit recorded');
|
message.success('Location added successfully');
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to add location');
|
message.error('Failed to add location');
|
||||||
@ -131,6 +125,7 @@ export default function AddLocationDrawer({
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
height="auto"
|
height="auto"
|
||||||
|
zIndex={zIndex}
|
||||||
forceRender
|
forceRender
|
||||||
styles={{
|
styles={{
|
||||||
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },
|
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },
|
||||||
|
|||||||
@ -1,20 +1,23 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Input, Button, App } from 'antd';
|
import { Input, Button, App, Grid } from 'antd';
|
||||||
import type { InputRef } from 'antd';
|
import type { InputRef } from 'antd';
|
||||||
import { SearchOutlined, CloseOutlined } from '@ant-design/icons';
|
import { SearchOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
import { useCanvassStore } from '@/stores/canvass.store';
|
import { useCanvassStore } from '@/stores/canvass.store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFlyTo: (lat: number, lng: number) => void;
|
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 [expanded, setExpanded] = useState(false);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const inputRef = useRef<InputRef>(null);
|
const inputRef = useRef<InputRef>(null);
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const { geocodeSearch } = useCanvassStore();
|
const { geocodeSearch } = useCanvassStore();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
@ -37,10 +40,7 @@ export default function AddressSearchOverlay({ onFlyTo }: Props) {
|
|||||||
onClick={() => setExpanded(true)}
|
onClick={() => setExpanded(true)}
|
||||||
title="Search address"
|
title="Search address"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
...style,
|
||||||
top: 12,
|
|
||||||
left: 60,
|
|
||||||
zIndex: 1000,
|
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@ -63,10 +63,7 @@ export default function AddressSearchOverlay({ onFlyTo }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
...style,
|
||||||
top: 12,
|
|
||||||
left: 60,
|
|
||||||
zIndex: 1000,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
background: 'rgba(0,0,0,0.75)',
|
background: 'rgba(0,0,0,0.75)',
|
||||||
@ -83,7 +80,13 @@ export default function AddressSearchOverlay({ onFlyTo }: Props) {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onPressEnter={handleSearch}
|
onPressEnter={handleSearch}
|
||||||
size="small"
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
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 type { Map as LeafletMap } from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { LiveVolunteer } from '@/types/tracking';
|
import type { LiveVolunteer } from '@/types/tracking';
|
||||||
import type { PublicCut, MapSettings } from '@/types/api';
|
import type { PublicCut, MapSettings } from '@/types/api';
|
||||||
import CutOverlays from '@/components/map/CutOverlays';
|
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';
|
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_CENTER: [number, number] = [45.42, -75.69];
|
||||||
const DEFAULT_ZOOM = 13;
|
const DEFAULT_ZOOM = 13;
|
||||||
const POLL_INTERVAL = 15000;
|
const POLL_INTERVAL = 15000;
|
||||||
@ -16,10 +19,13 @@ const POLL_INTERVAL = 15000;
|
|||||||
interface AdminLiveMapProps {
|
interface AdminLiveMapProps {
|
||||||
cuts: PublicCut[];
|
cuts: PublicCut[];
|
||||||
mapSettings: MapSettings | null;
|
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 [volunteers, setVolunteers] = useState<LiveVolunteer[]>([]);
|
||||||
|
const [tileKey, setTileKey] = useState(getPersistedTileLayer);
|
||||||
const mapRef = useRef<LeafletMap | null>(null);
|
const mapRef = useRef<LeafletMap | null>(null);
|
||||||
|
|
||||||
const fetchLive = useCallback(async () => {
|
const fetchLive = useCallback(async () => {
|
||||||
@ -42,8 +48,6 @@ export default function AdminLiveMap({ cuts, mapSettings }: AdminLiveMapProps) {
|
|||||||
: DEFAULT_CENTER;
|
: DEFAULT_CENTER;
|
||||||
const zoom = mapSettings?.zoom ?? DEFAULT_ZOOM;
|
const zoom = mapSettings?.zoom ?? DEFAULT_ZOOM;
|
||||||
|
|
||||||
const visibleCutIds = new Set(cuts.map((c) => c.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
@ -52,9 +56,22 @@ export default function AdminLiveMap({ cuts, mapSettings }: AdminLiveMapProps) {
|
|||||||
zoomControl={true}
|
zoomControl={true}
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
||||||
attribution='© <a href="https://carto.com">CARTO</a>'
|
|
||||||
url={DARK_TILE}
|
<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} />
|
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
|
||||||
|
|||||||
418
admin/src/components/canvass/BottomControlPanel.tsx
Normal file
418
admin/src/components/canvass/BottomControlPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Button, Badge } from 'antd';
|
import { Button, Badge } from 'antd';
|
||||||
import {
|
import {
|
||||||
AimOutlined,
|
AimOutlined,
|
||||||
@ -9,6 +10,51 @@ import {
|
|||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
} from '@ant-design/icons';
|
} 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 {
|
interface CanvassBottomToolbarProps {
|
||||||
visitedCount: number;
|
visitedCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
@ -47,7 +93,8 @@ export default function CanvassBottomToolbar({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: bottomOffset,
|
bottom: `max(${bottomOffset}px, calc(${bottomOffset}px + env(safe-area-inset-bottom)))`,
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
@ -60,59 +107,53 @@ export default function CanvassBottomToolbar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{onMenuOpen && (
|
{onMenuOpen && (
|
||||||
<Button
|
<ToolbarButton
|
||||||
type="default"
|
type="default"
|
||||||
icon={<MenuOutlined />}
|
icon={<MenuOutlined />}
|
||||||
onClick={onMenuOpen}
|
onClick={onMenuOpen}
|
||||||
size="middle"
|
label="Open menu"
|
||||||
aria-label="Open menu"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sessionActive && (
|
{sessionActive && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<ToolbarButton
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<ArrowRightOutlined />}
|
icon={<ArrowRightOutlined />}
|
||||||
onClick={onNextDoor}
|
onClick={onNextDoor}
|
||||||
size="middle"
|
label="Next door"
|
||||||
aria-label="Next door"
|
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</ToolbarButton>
|
||||||
<Button
|
<ToolbarButton
|
||||||
type={routeVisible ? 'primary' : 'default'}
|
type={routeVisible ? 'primary' : 'default'}
|
||||||
icon={<NodeIndexOutlined />}
|
icon={<NodeIndexOutlined />}
|
||||||
onClick={onToggleRoute}
|
onClick={onToggleRoute}
|
||||||
size="middle"
|
|
||||||
ghost={routeVisible}
|
ghost={routeVisible}
|
||||||
aria-label="Toggle walking route"
|
label="Toggle walking route"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
<ToolbarButton
|
||||||
type={gpsFollowing ? 'primary' : 'default'}
|
type={gpsFollowing ? 'primary' : 'default'}
|
||||||
icon={<AimOutlined />}
|
icon={<AimOutlined />}
|
||||||
onClick={onToggleGps}
|
onClick={onToggleGps}
|
||||||
size="middle"
|
|
||||||
ghost={gpsFollowing}
|
ghost={gpsFollowing}
|
||||||
aria-label="Toggle GPS following"
|
label="Toggle GPS following"
|
||||||
/>
|
/>
|
||||||
{onToggleFullscreen && (
|
{onToggleFullscreen && (
|
||||||
<Button
|
<ToolbarButton
|
||||||
type={fullscreen ? 'primary' : 'default'}
|
type={fullscreen ? 'primary' : 'default'}
|
||||||
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||||
onClick={onToggleFullscreen}
|
onClick={onToggleFullscreen}
|
||||||
size="middle"
|
label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onAddAtCenter && (
|
{onAddAtCenter && (
|
||||||
<Button
|
<ToolbarButton
|
||||||
type="default"
|
type="default"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={onAddAtCenter}
|
onClick={onAddAtCenter}
|
||||||
size="middle"
|
label="Add location at crosshair"
|
||||||
aria-label="Add location at crosshair"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sessionActive && (
|
{sessionActive && (
|
||||||
|
|||||||
@ -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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -21,6 +25,7 @@ export default function CanvassLegend() {
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
maxWidth: 180,
|
maxWidth: 180,
|
||||||
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Icon type indicators */}
|
{/* Icon type indicators */}
|
||||||
|
|||||||
284
admin/src/components/canvass/CanvassMarkerGroup.tsx
Normal file
284
admin/src/components/canvass/CanvassMarkerGroup.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import React, { useMemo } 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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
<>
|
||||||
|
<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
|
||||||
|
</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 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Already sorted in groupAddressesByLocation helper */}
|
||||||
|
{addresses.map((addr, i) => (
|
||||||
|
<button
|
||||||
|
key={addr.id}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
all: 'unset',
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
marginBottom: i < addresses.length - 1 ? 8 : 0,
|
||||||
|
paddingBottom: i < addresses.length - 1 ? 8 : 0,
|
||||||
|
borderBottom: i < addresses.length - 1 ? '1px solid #eee' : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: addr.id === selectedAddressId ? 'rgba(52, 152, 219, 0.1)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => onAddressClick(addr.id)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddressClick(addr.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`Unit ${addr.unitNumber || 'main'}, ${
|
||||||
|
addr.firstName ? `${addr.firstName} ${addr.lastName}, ` : ''
|
||||||
|
}${addr.lastVisit ? VISIT_OUTCOME_LABELS[addr.lastVisit.outcome] : 'not visited'}`}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{addr.unitNumber && (
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: '#555' }}>
|
||||||
|
Unit {addr.unitNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{addr.firstName && (
|
||||||
|
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
|
||||||
|
{addr.firstName} {addr.lastName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: 8, textAlign: 'right' }}>
|
||||||
|
{addr.lastVisit ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 11, marginBottom: 2 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: getMarkerColor(addr),
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{VISIT_OUTCOME_LABELS[addr.lastVisit.outcome]}
|
||||||
|
</div>
|
||||||
|
{addr.lastVisit.visitorName && (
|
||||||
|
<div style={{ fontSize: 10, color: '#999' }}>
|
||||||
|
by {addr.lastVisit.visitorName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11, color: '#999' }}>Not visited</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{addr.notes && (
|
||||||
|
<div style={{ fontSize: 10, color: '#888', marginTop: 4, fontStyle: 'italic' }}>
|
||||||
|
Note: {addr.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -3,12 +3,12 @@ import { Drawer, Form, Input, Select, Switch, Button, message } from 'antd';
|
|||||||
import type { CanvassLocation } from '@/types/canvass';
|
import type { CanvassLocation } from '@/types/canvass';
|
||||||
import type { SupportLevel } from '@/types/api';
|
import type { SupportLevel } from '@/types/api';
|
||||||
import { SUPPORT_LEVEL_LABELS } from '@/types/api';
|
import { SUPPORT_LEVEL_LABELS } from '@/types/api';
|
||||||
import { useCanvassStore } from '@/stores/canvass.store';
|
|
||||||
|
|
||||||
interface LocationEditDrawerProps {
|
interface LocationEditDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
location: CanvassLocation | null;
|
location: CanvassLocation | null;
|
||||||
|
zIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportLevelOptions = (['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'] as SupportLevel[]).map(
|
const supportLevelOptions = (['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'] as SupportLevel[]).map(
|
||||||
@ -19,9 +19,10 @@ export default function LocationEditDrawer({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
location,
|
location,
|
||||||
|
zIndex = 1000,
|
||||||
}: LocationEditDrawerProps) {
|
}: LocationEditDrawerProps) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { updateLocationFields } = useCanvassStore();
|
// TODO: Update to work with Address model instead of deprecated CanvassLocation
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location && open) {
|
if (location && open) {
|
||||||
@ -40,20 +41,16 @@ export default function LocationEditDrawer({
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!location) return;
|
if (!location) return;
|
||||||
try {
|
message.warning('Location editing temporarily disabled - needs Address model update');
|
||||||
const values = await form.validateFields();
|
|
||||||
await updateLocationFields(location.id, values);
|
|
||||||
message.success('Location updated');
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
// TODO: Implement address update API call
|
||||||
message.error('Failed to update location');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
open={open}
|
open={open}
|
||||||
|
zIndex={zIndex}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
height="auto"
|
height="auto"
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@ -1,17 +1,23 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Input, Space, Typography, message } from 'antd';
|
import { Button, Input, Space, Typography, message, Alert, Dropdown, Modal, Row, Col, Grid } from 'antd';
|
||||||
import type { VisitOutcome, RecordVisitPayload, CanvassLocation } from '@/types/canvass';
|
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 type { SupportLevel, UserRole } from '@/types/api';
|
||||||
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
|
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
|
||||||
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
||||||
|
import { sanitizeHtml } from '@/utils/sanitize';
|
||||||
|
|
||||||
interface VisitRecordingFormProps {
|
interface VisitRecordingFormProps {
|
||||||
location: CanvassLocation;
|
address: CanvassAddress;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
shiftId?: string;
|
shiftId?: string;
|
||||||
onRecord: (payload: RecordVisitPayload) => Promise<void>;
|
onRecord: (payload: RecordVisitPayload) => Promise<void>;
|
||||||
|
onBulkRecord?: (payload: BulkRecordVisitPayload) => Promise<void>;
|
||||||
|
onNextUnit?: () => void;
|
||||||
recording: boolean;
|
recording: boolean;
|
||||||
userRole?: UserRole;
|
userRole?: UserRole;
|
||||||
|
isMultiUnit?: boolean;
|
||||||
|
unvisitedCountInBuilding?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcomeKeys: VisitOutcome[] = [
|
const outcomeKeys: VisitOutcome[] = [
|
||||||
@ -27,18 +33,25 @@ const outcomeKeys: VisitOutcome[] = [
|
|||||||
const supportLevelKeys: SupportLevel[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'];
|
const supportLevelKeys: SupportLevel[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'];
|
||||||
|
|
||||||
export default function VisitRecordingForm({
|
export default function VisitRecordingForm({
|
||||||
location,
|
address,
|
||||||
sessionId,
|
sessionId,
|
||||||
shiftId,
|
shiftId,
|
||||||
onRecord,
|
onRecord,
|
||||||
|
onBulkRecord,
|
||||||
|
onNextUnit,
|
||||||
recording,
|
recording,
|
||||||
userRole,
|
userRole,
|
||||||
|
isMultiUnit = false,
|
||||||
|
unvisitedCountInBuilding = 0,
|
||||||
}: VisitRecordingFormProps) {
|
}: VisitRecordingFormProps) {
|
||||||
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
|
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
|
||||||
const [supportLevel, setSupportLevel] = useState<SupportLevel | undefined>(undefined);
|
const [supportLevel, setSupportLevel] = useState<SupportLevel | undefined>(undefined);
|
||||||
const [signRequested, setSignRequested] = useState(false);
|
const [signRequested, setSignRequested] = useState(false);
|
||||||
const [signSize, setSignSize] = useState<string | undefined>(undefined);
|
const [signSize, setSignSize] = useState<string | undefined>(undefined);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
const isNarrow = !screens.sm;
|
||||||
|
|
||||||
const showDetailFields = userRole !== 'TEMP';
|
const showDetailFields = userRole !== 'TEMP';
|
||||||
|
|
||||||
@ -49,7 +62,7 @@ export default function VisitRecordingForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await onRecord({
|
await onRecord({
|
||||||
locationId: location.id,
|
addressId: address.id, // Changed from locationId
|
||||||
outcome,
|
outcome,
|
||||||
supportLevel,
|
supportLevel,
|
||||||
signRequested,
|
signRequested,
|
||||||
@ -59,6 +72,11 @@ export default function VisitRecordingForm({
|
|||||||
shiftId,
|
shiftId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-advance to next unit if multi-unit
|
||||||
|
if (isMultiUnit && unvisitedCountInBuilding > 1 && onNextUnit) {
|
||||||
|
onNextUnit();
|
||||||
|
}
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
setSupportLevel(undefined);
|
setSupportLevel(undefined);
|
||||||
@ -67,60 +85,137 @@ export default function VisitRecordingForm({
|
|||||||
setNotes('');
|
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 (
|
return (
|
||||||
<div style={{ padding: '0 4px' }}>
|
<div style={{ padding: '0 4px' }}>
|
||||||
<Typography.Text strong style={{ fontSize: 15, display: 'block', marginBottom: 8 }}>
|
{address.firstName && (
|
||||||
{location.address || 'Unknown Address'}
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
{location.unitNumber && ` #${location.unitNumber}`}
|
{address.firstName} {address.lastName}
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
{location.firstName && (
|
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
|
||||||
{location.firstName} {location.lastName}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
{/* Building notes for multi-unit */}
|
||||||
Outcome
|
{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>
|
</Typography.Text>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
<Row gutter={[6, 6]} style={{ marginBottom: 12 }}>
|
||||||
{outcomeKeys.map((key) => {
|
{outcomeKeys.map((key) => {
|
||||||
const color = VISIT_OUTCOME_COLORS[key];
|
const color = VISIT_OUTCOME_COLORS[key];
|
||||||
const selected = outcome === key;
|
const selected = outcome === key;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Col
|
||||||
key={key}
|
key={key}
|
||||||
size="middle"
|
xs={isNarrow ? 12 : 8}
|
||||||
|
sm={8}
|
||||||
|
md={6}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
type={selected ? 'primary' : 'default'}
|
type={selected ? 'primary' : 'default'}
|
||||||
style={{
|
style={{
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
background: selected ? color : 'transparent',
|
background: selected ? color : 'transparent',
|
||||||
color: selected ? '#fff' : color,
|
color: selected ? '#fff' : color,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
fontWeight: key === 'SPOKE_WITH' ? 600 : 400,
|
||||||
}}
|
}}
|
||||||
onClick={() => setOutcome(key)}
|
onClick={() => setOutcome(key)}
|
||||||
>
|
>
|
||||||
{VISIT_OUTCOME_LABELS[key]}
|
{VISIT_OUTCOME_LABELS[key]}
|
||||||
</Button>
|
</Button>
|
||||||
|
</Col>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Row>
|
||||||
|
|
||||||
{showDetailFields && outcome === 'SPOKE_WITH' && (
|
{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
|
Support Level
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Space style={{ marginBottom: 12 }}>
|
<Row gutter={[8, 8]} justify="space-between" style={{ marginBottom: 12 }}>
|
||||||
{supportLevelKeys.map((key) => (
|
{supportLevelKeys.map((key) => (
|
||||||
|
<Col key={key} xs={6} sm={6}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
|
||||||
shape="circle"
|
shape="circle"
|
||||||
size="large"
|
size="large"
|
||||||
type={supportLevel === key ? 'primary' : 'default'}
|
type={supportLevel === key ? 'primary' : 'default'}
|
||||||
style={{
|
style={{
|
||||||
width: 44,
|
width: 48,
|
||||||
height: 44,
|
height: 48,
|
||||||
background: supportLevel === key ? SUPPORT_LEVEL_COLORS[key] : undefined,
|
background: supportLevel === key ? SUPPORT_LEVEL_COLORS[key] : undefined,
|
||||||
borderColor: SUPPORT_LEVEL_COLORS[key],
|
borderColor: SUPPORT_LEVEL_COLORS[key],
|
||||||
color: supportLevel === key ? '#fff' : SUPPORT_LEVEL_COLORS[key],
|
color: supportLevel === key ? '#fff' : SUPPORT_LEVEL_COLORS[key],
|
||||||
@ -130,18 +225,26 @@ export default function VisitRecordingForm({
|
|||||||
>
|
>
|
||||||
{key.replace('LEVEL_', '')}
|
{key.replace('LEVEL_', '')}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
<div style={{ fontSize: 11, marginTop: 4, color: 'rgba(255,255,255,0.6)' }}>
|
||||||
</Space>
|
{SUPPORT_LEVEL_LABELS[key]}
|
||||||
<div style={{ marginBottom: 4 }}>
|
|
||||||
{supportLevel && (
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{SUPPORT_LEVEL_LABELS[supportLevel]}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
<Typography.Text
|
||||||
Sign
|
strong
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign Request
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Space style={{ marginBottom: 12 }}>
|
<Space style={{ marginBottom: 12 }}>
|
||||||
<Button
|
<Button
|
||||||
@ -170,8 +273,18 @@ export default function VisitRecordingForm({
|
|||||||
|
|
||||||
{showDetailFields && (
|
{showDetailFields && (
|
||||||
<>
|
<>
|
||||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
<Typography.Text
|
||||||
Notes
|
strong
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Notes (Optional)
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
value={notes}
|
value={notes}
|
||||||
@ -183,6 +296,7 @@ export default function VisitRecordingForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Space style={{ width: '100%' }} direction="vertical" size="small">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
@ -193,6 +307,52 @@ export default function VisitRecordingForm({
|
|||||||
>
|
>
|
||||||
Record Visit
|
Record Visit
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 {
|
import {
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
AimOutlined,
|
AimOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CloseOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import SessionTimer from './SessionTimer';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import type { MyAssignment, MyCanvassStats } from '@/types/canvass';
|
import type { MyAssignment, MyCanvassStats } from '@/types/canvass';
|
||||||
@ -15,21 +19,35 @@ import type { PublicCut } from '@/types/api';
|
|||||||
interface VolunteerMapDrawerProps {
|
interface VolunteerMapDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
drawerBodyRef?: React.RefObject<HTMLDivElement>;
|
||||||
cuts: PublicCut[];
|
cuts: PublicCut[];
|
||||||
onStartSession: (cutId: string, shiftId?: string) => void;
|
onStartSession: (cutId: string, shiftId?: string) => void;
|
||||||
|
sessionActive?: boolean;
|
||||||
|
sessionCutName?: string;
|
||||||
|
sessionStartedAt?: string;
|
||||||
|
onEndSession?: () => void;
|
||||||
|
endingSession?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VolunteerMapDrawer({
|
export default function VolunteerMapDrawer({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
drawerBodyRef,
|
||||||
cuts,
|
cuts,
|
||||||
onStartSession,
|
onStartSession,
|
||||||
|
sessionActive = false,
|
||||||
|
sessionCutName,
|
||||||
|
sessionStartedAt,
|
||||||
|
onEndSession,
|
||||||
|
endingSession = false,
|
||||||
}: VolunteerMapDrawerProps) {
|
}: VolunteerMapDrawerProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const [stats, setStats] = useState<MyCanvassStats | null>(null);
|
const [stats, setStats] = useState<MyCanvassStats | null>(null);
|
||||||
const [assignments, setAssignments] = useState<MyAssignment[]>([]);
|
const [assignments, setAssignments] = useState<MyAssignment[]>([]);
|
||||||
const [freeCutId, setFreeCutId] = useState<string | null>(null);
|
const [freeCutId, setFreeCutId] = useState<string | null>(null);
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@ -45,15 +63,93 @@ export default function VolunteerMapDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
placement="left"
|
placement="bottom"
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width={300}
|
height="auto"
|
||||||
|
closable={false}
|
||||||
|
mask={false}
|
||||||
|
maskClosable={false}
|
||||||
|
zIndex={1150}
|
||||||
styles={{
|
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' },
|
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 }}>
|
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 4 }}>
|
||||||
{user?.name || user?.email || 'Volunteer'}
|
{user?.name || user?.email || 'Volunteer'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@ -71,8 +167,8 @@ export default function VolunteerMapDrawer({
|
|||||||
|
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
{/* Assignments */}
|
{/* Assignments (hidden when session active) */}
|
||||||
{assignments.length > 0 && (
|
{!sessionActive && assignments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||||
My Assignments
|
My Assignments
|
||||||
@ -111,7 +207,9 @@ export default function VolunteerMapDrawer({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Free session — pick a cut */}
|
{/* Free session — pick a cut (hidden when session active) */}
|
||||||
|
{!sessionActive && (
|
||||||
|
<>
|
||||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||||
Start Session (Any Cut)
|
Start Session (Any Cut)
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@ -133,6 +231,8 @@ export default function VolunteerMapDrawer({
|
|||||||
Go
|
Go
|
||||||
</Button>
|
</Button>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Navigation links */}
|
{/* Navigation links */}
|
||||||
<Button
|
<Button
|
||||||
@ -158,6 +258,7 @@ export default function VolunteerMapDrawer({
|
|||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,8 @@ export default function VolunteerSessionBar({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 60,
|
bottom: `max(60px, calc(56px + 4px + env(safe-area-inset-bottom)))`,
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
150
admin/src/components/canvass/canvassClusterUtils.ts
Normal file
150
admin/src/components/canvass/canvassClusterUtils.ts
Normal 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
|
||||||
|
};
|
||||||
481
admin/src/components/email-templates/EmailTemplateEditor.tsx
Normal file
481
admin/src/components/email-templates/EmailTemplateEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
470
admin/src/components/email-templates/LAYOUT_RECOMMENDATION.md
Normal file
470
admin/src/components/email-templates/LAYOUT_RECOMMENDATION.md
Normal 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
|
||||||
247
admin/src/components/email-templates/TestEmailModal.tsx
Normal file
247
admin/src/components/email-templates/TestEmailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
admin/src/components/email-templates/VariablesPanel.tsx
Normal file
187
admin/src/components/email-templates/VariablesPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
admin/src/components/email-templates/VersionHistoryDrawer.tsx
Normal file
156
admin/src/components/email-templates/VersionHistoryDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
admin/src/components/email-templates/VideoVariableEditor.tsx
Normal file
178
admin/src/components/email-templates/VideoVariableEditor.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
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, App } from 'antd';
|
||||||
import { Button, Switch, Space, Typography, message, Spin, Tag, Grid, Result, theme } from 'antd';
|
|
||||||
import { ArrowLeftOutlined, SaveOutlined, EyeOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, SaveOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -10,9 +9,12 @@ import type { LandingPage, PageBlock } from '@/types/api';
|
|||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function PageEditorPage() {
|
interface LandingPageEditorProps {
|
||||||
const { id } = useParams<{ id: string }>();
|
pageId: string;
|
||||||
const navigate = useNavigate();
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LandingPageEditor({ pageId, onClose }: LandingPageEditorProps) {
|
||||||
const [page, setPage] = useState<LandingPage | null>(null);
|
const [page, setPage] = useState<LandingPage | null>(null);
|
||||||
const [blocks, setBlocks] = useState<PageBlock[]>([]);
|
const [blocks, setBlocks] = useState<PageBlock[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -22,6 +24,7 @@ export default function PageEditorPage() {
|
|||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
const { modal } = App.useApp();
|
||||||
|
|
||||||
const isCodeMode = page?.editorMode === 'CODE';
|
const isCodeMode = page?.editorMode === 'CODE';
|
||||||
|
|
||||||
@ -30,12 +33,12 @@ export default function PageEditorPage() {
|
|||||||
try {
|
try {
|
||||||
if (isCodeMode) {
|
if (isCodeMode) {
|
||||||
// CODE mode only needs page data
|
// 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);
|
setPage(pageRes.data);
|
||||||
setCodeContent(pageRes.data.htmlOutput || '');
|
setCodeContent(pageRes.data.htmlOutput || '');
|
||||||
} else {
|
} else {
|
||||||
const [pageRes, blocksRes] = await Promise.all([
|
const [pageRes, blocksRes] = await Promise.all([
|
||||||
api.get<LandingPage>(`/pages/${id}`),
|
api.get<LandingPage>(`/pages/${pageId}`),
|
||||||
api.get<PageBlock[]>('/page-blocks'),
|
api.get<PageBlock[]>('/page-blocks'),
|
||||||
]);
|
]);
|
||||||
setPage(pageRes.data);
|
setPage(pageRes.data);
|
||||||
@ -44,13 +47,13 @@ export default function PageEditorPage() {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to load page');
|
message.error('Failed to load page');
|
||||||
navigate('/app/pages');
|
onClose();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
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 }) => {
|
const handleSaveVisual = useCallback(async (data: { projectData: Record<string, unknown>; html: string; css: string }) => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
@ -112,9 +115,25 @@ export default function PageEditorPage() {
|
|||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [isCodeMode, handleSaveCode]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
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" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -124,13 +143,13 @@ export default function PageEditorPage() {
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
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
|
<Result
|
||||||
status="info"
|
status="info"
|
||||||
title="Desktop Required"
|
title="Desktop Required"
|
||||||
subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}
|
subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={() => navigate('/app/pages')}>
|
<Button type="primary" onClick={onClose}>
|
||||||
Back to Pages
|
Back to Pages
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@ -140,7 +159,7 @@ export default function PageEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Toolbar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -158,8 +177,8 @@ export default function PageEditorPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={() => navigate('/app/pages')}
|
onClick={handleClose}
|
||||||
aria-label="Back to pages list"
|
aria-label="Close editor"
|
||||||
style={{ color: '#fff' }}
|
style={{ color: '#fff' }}
|
||||||
/>
|
/>
|
||||||
<Text strong style={{ color: '#fff', fontSize: 16 }}>{page.title}</Text>
|
<Text strong style={{ color: '#fff', fontSize: 16 }}>{page.title}</Text>
|
||||||
@ -2,12 +2,23 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|||||||
import { Spin, Checkbox, Button, Typography, message } from 'antd';
|
import { Spin, Checkbox, Button, Typography, message } from 'antd';
|
||||||
import { EditOutlined, DragOutlined } from '@ant-design/icons';
|
import { EditOutlined, DragOutlined } from '@ant-design/icons';
|
||||||
import { MapContainer, CircleMarker, Popup, Marker, useMap, useMapEvents } from 'react-leaflet';
|
import { MapContainer, CircleMarker, Popup, Marker, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
import type { Map as LeafletMap } from 'leaflet';
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
import L 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 { api } from '@/lib/api';
|
||||||
import type { Location, MapSettings, SupportLevel, Cut } from '@/types/api';
|
import type { Location, MapSettings, SupportLevel, Cut } from '@/types/api';
|
||||||
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
||||||
import { groupLocations, getMarkerColor } from './mapUtils';
|
import { groupLocations, getMarkerColor } from './mapUtils';
|
||||||
|
import { createLocationIcon } from './mapIcons';
|
||||||
import MapLegend from './MapLegend';
|
import MapLegend from './MapLegend';
|
||||||
import MapControls from './MapControls';
|
import MapControls from './MapControls';
|
||||||
import AddLocationMode from './AddLocationMode';
|
import AddLocationMode from './AddLocationMode';
|
||||||
@ -38,6 +49,32 @@ const homeIcon = L.divIcon({
|
|||||||
className: '',
|
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 {
|
interface Props {
|
||||||
locations: Location[];
|
locations: Location[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@ -76,19 +113,28 @@ function FullscreenInvalidator() {
|
|||||||
return null;
|
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({
|
const map = useMapEvents({
|
||||||
moveend: () => {
|
moveend: () => {
|
||||||
// Only trigger if not animating to prevent Leaflet state corruption
|
// Only trigger if not animating to prevent Leaflet state corruption
|
||||||
if (!map._animatingZoom && !map._moving) {
|
if (!map._animatingZoom && !map._moving) {
|
||||||
onMove?.(map);
|
setMapInstance(map); // Trigger debounced callback
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
zoomend: () => {
|
zoomend: () => {
|
||||||
|
setCurrentZoom(map.getZoom()); // Track current zoom
|
||||||
// Wait a tick for Leaflet to finish internal zoom state updates
|
// Wait a tick for Leaflet to finish internal zoom state updates
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!map._animatingZoom && !map._moving) {
|
if (!map._animatingZoom && !map._moving) {
|
||||||
onMove?.(map);
|
setMapInstance(map);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
@ -104,6 +150,22 @@ function FlyToPosition({ position }: { position: [number, number] }) {
|
|||||||
return null;
|
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({
|
export default function AdminMapView({
|
||||||
locations,
|
locations,
|
||||||
loading,
|
loading,
|
||||||
@ -127,6 +189,11 @@ export default function AdminMapView({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const autoRefreshRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
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
|
// Cuts state
|
||||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||||
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
|
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(() => {});
|
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
|
// Fetch cuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<{ cuts: Cut[] }>('/map/cuts', { params: { limit: 100 } })
|
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(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
return groups.filter((g) =>
|
return groups.filter((g) =>
|
||||||
g.locations.some((loc) => {
|
g.location.addresses.some((addr) => {
|
||||||
const level = loc.supportLevel || 'NONE';
|
const level = addr.supportLevel || 'NONE';
|
||||||
return visibleLevels.has(level);
|
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})` : ''} · </>}
|
||||||
|
{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})` : ''} · </>}
|
||||||
|
{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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
|
||||||
@ -277,7 +555,7 @@ export default function AdminMapView({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
id="admin-map-container"
|
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 */}
|
{/* Support level filter overlay */}
|
||||||
<div
|
<div
|
||||||
@ -286,13 +564,14 @@ export default function AdminMapView({
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 10,
|
top: 10,
|
||||||
left: 10,
|
left: 70,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
background: 'rgba(26, 16, 37, 0.92)',
|
background: 'rgba(26, 16, 37, 0.92)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
border: '1px solid rgba(255,255,255,0.12)',
|
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 }}>
|
<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 {
|
.admin-map .leaflet-popup-content {
|
||||||
margin: 10px 14px;
|
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>
|
`}</style>
|
||||||
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
@ -376,7 +688,12 @@ export default function AdminMapView({
|
|||||||
>
|
>
|
||||||
<InvalidateSizeOnVisible visible={visible} />
|
<InvalidateSizeOnVisible visible={visible} />
|
||||||
<FullscreenInvalidator />
|
<FullscreenInvalidator />
|
||||||
<MapEventsHandler onMove={onMapMove} />
|
<CenterOnSettings settings={settings} />
|
||||||
|
<MapEventsHandler
|
||||||
|
onMove={onMapMove}
|
||||||
|
setMapInstance={setMapInstance}
|
||||||
|
setCurrentZoom={setCurrentZoom}
|
||||||
|
/>
|
||||||
{flyTo && <FlyToPosition position={flyTo} />}
|
{flyTo && <FlyToPosition position={flyTo} />}
|
||||||
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
||||||
|
|
||||||
@ -416,104 +733,13 @@ export default function AdminMapView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location markers */}
|
{/* Location markers with clustering */}
|
||||||
{filteredGroups.map((group, idx) => {
|
<MarkerClusterGroup
|
||||||
const color = getMarkerColor(group.dominantLevel);
|
key={currentZoom >= 18 ? 'unclustered' : 'clustered'}
|
||||||
const radius = group.isMultiUnit ? 10 : 7;
|
{...clusterConfig}
|
||||||
|
|
||||||
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>
|
{markers}
|
||||||
<div style={{ minWidth: 200, maxWidth: 280 }}>
|
</MarkerClusterGroup>
|
||||||
{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})` : ''} · </>}
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
<MapLegend variant="admin" />
|
<MapLegend variant="admin" />
|
||||||
@ -521,7 +747,7 @@ export default function AdminMapView({
|
|||||||
<TileLayerToggle
|
<TileLayerToggle
|
||||||
activeKey={tileKey}
|
activeKey={tileKey}
|
||||||
onChange={(key) => { setTileKey(key); persistTileLayer(key); }}
|
onChange={(key) => { setTileKey(key); persistTileLayer(key); }}
|
||||||
position="bottom-right"
|
position="bottom-left"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cut overlay controls */}
|
{/* Cut overlay controls */}
|
||||||
@ -531,6 +757,7 @@ export default function AdminMapView({
|
|||||||
visibleCutIds={visibleCutIds}
|
visibleCutIds={visibleCutIds}
|
||||||
onToggleCut={toggleCut}
|
onToggleCut={toggleCut}
|
||||||
variant="admin"
|
variant="admin"
|
||||||
|
style={{ top: 180, left: 10, bottom: 'auto' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,6 +24,22 @@ function InvalidateOnMount() {
|
|||||||
return null;
|
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) {
|
export default function CutEditorMap({ cuts, onFinishDraw }: Props) {
|
||||||
const [settings, setSettings] = useState<MapSettings | null>(null);
|
const [settings, setSettings] = useState<MapSettings | null>(null);
|
||||||
const [drawing, setDrawing] = useState(false);
|
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));
|
const allCutIds = new Set(cuts.map((c) => c.id));
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Drawing toolbar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -99,6 +115,7 @@ export default function CutEditorMap({ cuts, onFinishDraw }: Props) {
|
|||||||
className="cut-editor-map"
|
className="cut-editor-map"
|
||||||
>
|
>
|
||||||
<InvalidateOnMount />
|
<InvalidateOnMount />
|
||||||
|
<CenterOnSettings settings={settings} />
|
||||||
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
||||||
<CutOverlays cuts={cuts} visibleCutIds={allCutIds} />
|
<CutOverlays cuts={cuts} visibleCutIds={allCutIds} />
|
||||||
<CutDrawingMode
|
<CutDrawingMode
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
||||||
import type { SupportLevel } from '@/types/api';
|
import type { SupportLevel } from '@/types/api';
|
||||||
import { NO_LEVEL_COLOR } from './mapUtils';
|
import { NO_LEVEL_COLOR } from './mapUtils';
|
||||||
|
import { houseSvg, apartmentSvg } from './mapIcons';
|
||||||
|
|
||||||
const entries: { level: SupportLevel; label: string; color: string }[] = [
|
const entries: { level: SupportLevel; label: string; color: string }[] = [
|
||||||
{ level: 'LEVEL_1', label: SUPPORT_LEVEL_LABELS.LEVEL_1, color: SUPPORT_LEVEL_COLORS.LEVEL_1 },
|
{ 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)',
|
backdropFilter: 'blur(8px)',
|
||||||
border: '1px solid rgba(255,255,255,0.12)',
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
minWidth: 140,
|
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 }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: '#fff', marginBottom: 6 }}>
|
||||||
Support Level
|
Support Level
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ interface Props {
|
|||||||
activeKey: string;
|
activeKey: string;
|
||||||
onChange: (key: string) => void;
|
onChange: (key: string) => void;
|
||||||
position?: 'bottom-left' | 'bottom-right';
|
position?: 'bottom-left' | 'bottom-right';
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const icons: Record<string, React.ReactNode> = {
|
const icons: Record<string, React.ReactNode> = {
|
||||||
@ -13,10 +14,10 @@ const icons: Record<string, React.ReactNode> = {
|
|||||||
satellite: <GlobalOutlined style={{ transform: 'rotate(45deg)' }} />,
|
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'
|
const posStyle = position === 'bottom-left'
|
||||||
? { left: 10, bottom: 80 }
|
? { left: 10, bottom: 16 }
|
||||||
: { right: 10, bottom: 80 };
|
: { right: 10, bottom: 140 }; // Increased from 80 to 140 for legend clearance
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -27,6 +28,7 @@ export default function TileLayerToggle({ activeKey, onChange, position = 'botto
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{TILE_LAYERS.map((layer) => (
|
{TILE_LAYERS.map((layer) => (
|
||||||
|
|||||||
68
admin/src/components/map/mapIcons.ts
Normal file
68
admin/src/components/map/mapIcons.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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';
|
import { SUPPORT_LEVEL_COLORS } from '@/types/api';
|
||||||
|
|
||||||
export const NO_LEVEL_COLOR = '#3498db';
|
export const NO_LEVEL_COLOR = '#3498db';
|
||||||
@ -8,35 +8,21 @@ export function getMarkerColor(level: SupportLevel | null): string {
|
|||||||
return SUPPORT_LEVEL_COLORS[level] ?? NO_LEVEL_COLOR;
|
return SUPPORT_LEVEL_COLORS[level] ?? NO_LEVEL_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocationGroup {
|
// Location with addresses for map display
|
||||||
latitude: number;
|
export interface GroupableLocation extends Location {
|
||||||
longitude: number;
|
addresses: Address[];
|
||||||
locations: Location[];
|
|
||||||
isMultiUnit: boolean;
|
|
||||||
dominantLevel: SupportLevel | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupLocations(locations: Location[]): LocationGroup[] {
|
// Get the dominant support level from all addresses in a location
|
||||||
const groups = new Map<string, Location[]>();
|
function getDominantSupportLevel(addresses: Address[]): SupportLevel | null {
|
||||||
|
if (addresses.length === 0) return null;
|
||||||
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(groups.entries()).map(([key, locs]) => {
|
|
||||||
const [lat, lng] = key.split(',');
|
|
||||||
const levelCounts: Record<string, number> = {};
|
const levelCounts: Record<string, number> = {};
|
||||||
for (const loc of locs) {
|
for (const addr of addresses) {
|
||||||
const level = loc.supportLevel || 'NONE';
|
const level = addr.supportLevel || 'NONE';
|
||||||
levelCounts[level] = (levelCounts[level] || 0) + 1;
|
levelCounts[level] = (levelCounts[level] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dominant: SupportLevel | null = null;
|
let dominant: SupportLevel | null = null;
|
||||||
let maxCount = 0;
|
let maxCount = 0;
|
||||||
for (const [level, count] of Object.entries(levelCounts)) {
|
for (const [level, count] of Object.entries(levelCounts)) {
|
||||||
@ -46,12 +32,33 @@ export function groupLocations(locations: Location[]): LocationGroup[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return dominant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationGroup {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
location: GroupableLocation;
|
||||||
|
isMultiUnit: boolean;
|
||||||
|
dominantLevel: SupportLevel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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!;
|
||||||
|
|
||||||
|
// Defensive: ensure addresses array exists
|
||||||
|
const addresses = Array.isArray(loc.addresses) ? loc.addresses : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
latitude: parseFloat(lat!),
|
latitude: lat,
|
||||||
longitude: parseFloat(lng!),
|
longitude: lng,
|
||||||
locations: locs,
|
location: loc,
|
||||||
isMultiUnit: locs.length > 1,
|
isMultiUnit: addresses.length > 1,
|
||||||
dominantLevel: dominant,
|
dominantLevel: getDominantSupportLevel(addresses),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
288
admin/src/components/media/AdvancedVideoPlayer.tsx
Normal file
288
admin/src/components/media/AdvancedVideoPlayer.tsx
Normal 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;
|
||||||
116
admin/src/components/media/AnalyticsChart.tsx
Normal file
116
admin/src/components/media/AnalyticsChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
admin/src/components/media/BulkActionsBar.tsx
Normal file
52
admin/src/components/media/BulkActionsBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
admin/src/components/media/CommentSection.tsx
Normal file
226
admin/src/components/media/CommentSection.tsx
Normal 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 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
|
||||||
|
const accessToken = localStorage.getItem('accessToken');
|
||||||
|
if (!accessToken) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
admin/src/components/media/DeleteConfirmModal.tsx
Normal file
58
admin/src/components/media/DeleteConfirmModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
admin/src/components/media/EditVideoModal.tsx
Normal file
155
admin/src/components/media/EditVideoModal.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { Drawer, Form, Input, Select, 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 || '',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.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 || '',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.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;
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
admin/src/components/media/ExpandedVideoCard.tsx
Normal file
342
admin/src/components/media/ExpandedVideoCard.tsx
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import { Button, Space, Tag, Grid, theme } from 'antd';
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
LikeOutlined,
|
||||||
|
LikeFilled,
|
||||||
|
EyeOutlined,
|
||||||
|
CommentOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useExpandedVideo, type VideoData } from '@/contexts/ExpandedVideoContext';
|
||||||
|
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
|
||||||
|
import VideoPlayer, { VideoPlayerRef } from './VideoPlayer';
|
||||||
|
import LiveChat from './LiveChat';
|
||||||
|
import ProgressBarMarkers from './ProgressBarMarkers';
|
||||||
|
import ReactionButtons from './ReactionButtons';
|
||||||
|
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 [videoHeight, setVideoHeight] = useState<number>(0);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [isExpanding, setIsExpanding] = useState(true);
|
||||||
|
|
||||||
|
// Read sidebar collapse state for full-width calculation
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('media_sidebar_collapsed');
|
||||||
|
return saved ? JSON.parse(saved) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorage = () => {
|
||||||
|
const saved = localStorage.getItem('media_sidebar_collapsed');
|
||||||
|
if (saved !== null) setSidebarCollapsed(JSON.parse(saved));
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', handleStorage);
|
||||||
|
const interval = setInterval(handleStorage, 200);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handleStorage);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sidebarWidth = isMobile ? 0 : sidebarCollapsed ? 64 : 256;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Break out of parent container (maxWidth + padding) to use full content area
|
||||||
|
// Use viewport width minus sidebar to go truly edge-to-edge
|
||||||
|
const fullWidth = `calc(100vw - ${sidebarWidth}px)`;
|
||||||
|
|
||||||
|
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 + maxWidth to fill full content area
|
||||||
|
width: fullWidth,
|
||||||
|
position: 'relative',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
marginLeft: `calc(-50% - ${sidebarWidth / 2}px + 50%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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',
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
570
admin/src/components/media/LiveChat.tsx
Normal file
570
admin/src/components/media/LiveChat.tsx
Normal file
@ -0,0 +1,570 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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}/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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch timeline and setup SSE when component opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchInitialTimeline();
|
||||||
|
setupSSE();
|
||||||
|
}
|
||||||
|
}, [isOpen, videoId, setupSSE]);
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
admin/src/components/media/MediaBottomNav.tsx
Normal file
108
admin/src/components/media/MediaBottomNav.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import {
|
||||||
|
HomeOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaBottomNav() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Navigation items (shortened labels for mobile)
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
|
||||||
|
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
|
||||||
|
{ key: 'videos', label: 'Vids', icon: <VideoCameraOutlined />, path: '/gallery/videos' },
|
||||||
|
{
|
||||||
|
key: 'compilations',
|
||||||
|
label: 'Comps',
|
||||||
|
icon: <AppstoreOutlined />,
|
||||||
|
path: '/gallery/compilations',
|
||||||
|
},
|
||||||
|
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
|
||||||
|
{ key: 'playback', label: 'Play', icon: <PlayCircleOutlined />, path: '/gallery/playback' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Determine active nav item from current path
|
||||||
|
const getActiveKey = () => {
|
||||||
|
const path = location.pathname;
|
||||||
|
if (path === '/gallery') return 'all';
|
||||||
|
const match = navItems.find((item) => path.startsWith(item.path));
|
||||||
|
return match ? match.key : 'all';
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeKey = getActiveKey();
|
||||||
|
|
||||||
|
const handleNavigate = (path: string) => {
|
||||||
|
navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="md:hidden" // Hide on desktop (>= 768px), show on mobile (< 768px)
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 56,
|
||||||
|
background: '#18181b', // zinc-900
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
padding: '0 4px',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = activeKey === item.key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => handleNavigate(item.path)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
padding: '6px 4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: isActive ? '#9333ea' : 'rgba(255,255,255,0.65)',
|
||||||
|
transition: 'color 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'inherit',
|
||||||
|
fontWeight: isActive ? 500 : 400,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
653
admin/src/components/media/MediaSidebar.tsx
Normal file
653
admin/src/components/media/MediaSidebar.tsx
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Typography, Space, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
HomeOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
LoginOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionState {
|
||||||
|
content: boolean;
|
||||||
|
activity: boolean;
|
||||||
|
online: boolean;
|
||||||
|
account: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaSidebar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// 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 tokens exist before attempting to hydrate
|
||||||
|
const accessToken = localStorage.getItem('access_token');
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
if (accessToken || refreshToken) {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock data for activity feed (currently empty)
|
||||||
|
const recentVideos: any[] = [];
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
// 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: 'compilations',
|
||||||
|
label: 'Compilations',
|
||||||
|
icon: <AppstoreOutlined />,
|
||||||
|
path: '/gallery/compilations',
|
||||||
|
},
|
||||||
|
{ 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
|
||||||
|
const getActiveKey = () => {
|
||||||
|
const path = location.pathname;
|
||||||
|
if (path === '/gallery') return 'all';
|
||||||
|
const match = navItems.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: '#18181b', // zinc-900
|
||||||
|
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: '#9333ea',
|
||||||
|
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: '#9333ea',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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 ? '#9333ea' : '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 = 'rgba(147, 51, 234, 0.1)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ACTIVITY
|
||||||
|
</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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recentVideos.length === 0 ? (
|
||||||
|
<div style={{ padding: '12px 16px' }}>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'rgba(255,255,255,0.35)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No recent activity
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentVideos.slice(0, 10).map((video, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'rgba(255,255,255,0.65)',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 4 }}>{video.title}</div>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'rgba(255,255,255,0.35)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{video.timestamp}
|
||||||
|
</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: '#9333ea' }} />
|
||||||
|
<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: 'rgba(147, 51, 234, 0.05)',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<UserOutlined style={{ color: '#9333ea' }} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* My Stats */}
|
||||||
|
<Tooltip title={collapsed ? 'My Stats' : ''} placement="right">
|
||||||
|
<div
|
||||||
|
onClick={() => navigate('/app')}
|
||||||
|
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 = 'rgba(147, 51, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BarChartOutlined style={{ fontSize: 18 }} />
|
||||||
|
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>My Stats</Text>}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<Tooltip title={collapsed ? 'Settings' : ''} placement="right">
|
||||||
|
<div
|
||||||
|
onClick={() => navigate('/app/settings')}
|
||||||
|
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 = 'rgba(147, 51, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingOutlined style={{ fontSize: 18 }} />
|
||||||
|
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>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 = 'rgba(147, 51, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
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: '#9333ea',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
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 = 'rgba(147, 51, 234, 0.1)';
|
||||||
|
e.currentTarget.style.color = '#9333ea';
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
admin/src/components/media/ProgressBarMarkers.tsx
Normal file
131
admin/src/components/media/ProgressBarMarkers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
admin/src/components/media/PublicVideoCard.tsx
Normal file
345
admin/src/components/media/PublicVideoCard.tsx
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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 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 ? (
|
||||||
|
<img
|
||||||
|
src={`/media/public/${video.id}/thumbnail`}
|
||||||
|
alt={title}
|
||||||
|
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: `rgba(147, 51, 234, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
admin/src/components/media/PublishModal.tsx
Normal file
64
admin/src/components/media/PublishModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
admin/src/components/media/QuickAnalyticsModal.tsx
Normal file
203
admin/src/components/media/QuickAnalyticsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
admin/src/components/media/ReactionButtons.tsx
Normal file
144
admin/src/components/media/ReactionButtons.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Space, Button, message } from 'antd';
|
||||||
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
|
|
||||||
|
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 [floatingEmojis, setFloatingEmojis] = useState<FloatingEmoji[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleReaction = async (reactionType: string, emoji: string) => {
|
||||||
|
// Check if user is logged in
|
||||||
|
const accessToken = localStorage.getItem('accessToken');
|
||||||
|
if (!accessToken) {
|
||||||
|
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 = 'rgba(147, 51, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
admin/src/components/media/RelatedVideosList.tsx
Normal file
250
admin/src/components/media/RelatedVideosList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
admin/src/components/media/ScheduleBadge.tsx
Normal file
54
admin/src/components/media/ScheduleBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
admin/src/components/media/ScheduleCalendarDrawer.tsx
Normal file
244
admin/src/components/media/ScheduleCalendarDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
admin/src/components/media/SchedulePublishModal.tsx
Normal file
304
admin/src/components/media/SchedulePublishModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
admin/src/components/media/SharedMediaCard.tsx
Normal file
38
admin/src/components/media/SharedMediaCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
admin/src/components/media/UploadVideoModal.tsx
Normal file
263
admin/src/components/media/UploadVideoModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
admin/src/components/media/VideoActions.tsx
Normal file
326
admin/src/components/media/VideoActions.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import { Button, Dropdown, message, Modal } from 'antd';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
} 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;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoActions({
|
||||||
|
video,
|
||||||
|
onEdit,
|
||||||
|
onPreview,
|
||||||
|
onAnalytics,
|
||||||
|
onSchedule,
|
||||||
|
onDelete,
|
||||||
|
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: '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
admin/src/components/media/VideoAnalyticsModal.tsx
Normal file
270
admin/src/components/media/VideoAnalyticsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
admin/src/components/media/VideoCard.tsx
Normal file
266
admin/src/components/media/VideoCard.tsx
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import { Card, Checkbox, Tag, Spin } from 'antd';
|
||||||
|
import { ClockCircleOutlined, PlayCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { Video } from '@/types/media';
|
||||||
|
import VideoActions from './VideoActions';
|
||||||
|
import ScheduleBadge from './ScheduleBadge';
|
||||||
|
|
||||||
|
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;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onTogglePublish?: (video: Video) => void;
|
||||||
|
showActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoCard({
|
||||||
|
video,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onClick,
|
||||||
|
onEdit,
|
||||||
|
onPreview,
|
||||||
|
onAnalytics,
|
||||||
|
onSchedule,
|
||||||
|
onDelete,
|
||||||
|
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={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}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
admin/src/components/media/VideoPickerModal.tsx
Normal file
405
admin/src/components/media/VideoPickerModal.tsx
Normal 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;
|
||||||
260
admin/src/components/media/VideoPlayer.tsx
Normal file
260
admin/src/components/media/VideoPlayer.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||||
|
import { Alert, Spin } from 'antd';
|
||||||
|
import { PlayCircleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 = '',
|
||||||
|
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 fetchMetadata = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use relative URL to go through nginx proxy
|
||||||
|
const response = await fetch(`/media/videos/${videoId}/metadata`);
|
||||||
|
|
||||||
|
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();
|
||||||
|
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%',
|
||||||
|
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;
|
||||||
200
admin/src/components/media/VideoViewerModal.tsx
Normal file
200
admin/src/components/media/VideoViewerModal.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { Modal } from 'antd';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { Video } from '@/types/media';
|
||||||
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
|
||||||
|
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={`/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`;
|
||||||
|
}
|
||||||
89
admin/src/components/media/ViewersTable.tsx
Normal file
89
admin/src/components/media/ViewersTable.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
admin/src/components/observability/AlertsTable.tsx
Normal file
59
admin/src/components/observability/AlertsTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
admin/src/components/observability/IframeErrorBoundary.tsx
Normal file
55
admin/src/components/observability/IframeErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
admin/src/components/observability/MetricsGrid.tsx
Normal file
81
admin/src/components/observability/MetricsGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
admin/src/components/observability/ServiceStatusCard.tsx
Normal file
35
admin/src/components/observability/ServiceStatusCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
admin/src/components/shifts/EditModeModal.tsx
Normal file
82
admin/src/components/shifts/EditModeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
admin/src/components/shifts/ShiftsCalendar.tsx
Normal file
73
admin/src/components/shifts/ShiftsCalendar.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
admin/src/contexts/ExpandedVideoContext.tsx
Normal file
82
admin/src/contexts/ExpandedVideoContext.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } 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 [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
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
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set('expanded', id.toString());
|
||||||
|
navigate({ search: newParams.toString() }, { replace: true });
|
||||||
|
}, [navigate, searchParams]);
|
||||||
|
|
||||||
|
const collapseVideo = useCallback(() => {
|
||||||
|
setState({ videoId: null, video: null });
|
||||||
|
|
||||||
|
// Remove URL param
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.delete('expanded');
|
||||||
|
navigate({ search: newParams.toString() }, { replace: true });
|
||||||
|
}, [navigate, searchParams]);
|
||||||
|
|
||||||
|
const value: ExpandedVideoContextValue = {
|
||||||
|
state,
|
||||||
|
expandVideo,
|
||||||
|
collapseVideo,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpandedVideoContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ExpandedVideoContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
admin/src/contexts/MediaAuthContext.tsx
Normal file
123
admin/src/contexts/MediaAuthContext.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaAuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isApproved: boolean; // True if NOT a USER or TEMP role
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaAuthContextValue extends MediaAuthState {
|
||||||
|
checkAuth: () => void; // Re-check auth state (e.g., after login)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const [authState, setAuthState] = useState<MediaAuthState>({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isApproved: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkAuth = () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isApproved: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode JWT to extract user info
|
||||||
|
const decoded = jwtDecode<JwtPayload>(token);
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
if (decoded.exp < now) {
|
||||||
|
// Token expired
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isApproved: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approved means NOT a USER or TEMP role
|
||||||
|
const isApproved = !['USER', 'TEMP'].includes(decoded.role);
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isApproved,
|
||||||
|
user: {
|
||||||
|
id: decoded.id,
|
||||||
|
email: decoded.email,
|
||||||
|
role: decoded.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decode JWT:', error);
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
setAuthState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isApproved: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check auth on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
// Listen for storage events (e.g., login in another tab)
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === 'accessToken') {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: MediaAuthContextValue = {
|
||||||
|
...authState,
|
||||||
|
checkAuth,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MediaAuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</MediaAuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
admin/src/hooks/useDebounce.ts
Normal file
17
admin/src/hooks/useDebounce.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
95
admin/src/hooks/useKeyboardShortcuts.ts
Normal file
95
admin/src/hooks/useKeyboardShortcuts.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, RefObject } from 'react';
|
||||||
|
|
||||||
|
export interface VideoPlayerRef {
|
||||||
|
togglePlay: () => void;
|
||||||
|
seekForward: (seconds: number) => void;
|
||||||
|
seekBackward: (seconds: number) => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
toggleFullscreen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseKeyboardShortcutsOptions {
|
||||||
|
playerRef: RefObject<VideoPlayerRef>;
|
||||||
|
onClose?: () => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for video player keyboard shortcuts
|
||||||
|
*
|
||||||
|
* Shortcuts:
|
||||||
|
* - Space: Toggle play/pause
|
||||||
|
* - Left Arrow: Seek backward 5 seconds
|
||||||
|
* - Right Arrow: Seek forward 5 seconds
|
||||||
|
* - M: Toggle mute
|
||||||
|
* - F: Toggle fullscreen
|
||||||
|
* - Escape: Close video (if onClose provided)
|
||||||
|
*/
|
||||||
|
export function useKeyboardShortcuts({
|
||||||
|
playerRef,
|
||||||
|
onClose,
|
||||||
|
enabled = true,
|
||||||
|
}: UseKeyboardShortcutsOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Don't trigger shortcuts if user is typing in an input
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
// Allow ESC even in inputs
|
||||||
|
if (e.key !== 'Escape') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = playerRef.current;
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
onClose?.();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
player.togglePlay();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
player.seekBackward(5);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
player.seekForward(5);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'm':
|
||||||
|
case 'M':
|
||||||
|
e.preventDefault();
|
||||||
|
player.toggleMute();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
e.preventDefault();
|
||||||
|
player.toggleFullscreen();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Do nothing for other keys
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [enabled, playerRef, onClose]);
|
||||||
|
}
|
||||||
23
admin/src/hooks/useLocalStorage.ts
Normal file
23
admin/src/hooks/useLocalStorage.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : initialValue;
|
||||||
|
} catch (error) {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setValue = (value: T) => {
|
||||||
|
try {
|
||||||
|
setStoredValue(value);
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save to localStorage', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [storedValue, setValue];
|
||||||
|
}
|
||||||
@ -21,6 +21,11 @@ export function registerAuthCallbacks(callbacks: {
|
|||||||
onAuthFailure = callbacks.onAuthFailure;
|
onAuthFailure = callbacks.onAuthFailure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to get current callbacks (for use in other API clients)
|
||||||
|
export function getAuthCallbacks() {
|
||||||
|
return { getTokens, onTokenRefresh, onAuthFailure };
|
||||||
|
}
|
||||||
|
|
||||||
// Request interceptor: attach access token
|
// Request interceptor: attach access token
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const { accessToken } = getTokens();
|
const { accessToken } = getTokens();
|
||||||
|
|||||||
83
admin/src/lib/media-api.ts
Normal file
83
admin/src/lib/media-api.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { getAuthCallbacks } from './api';
|
||||||
|
import type { AuthResponse } from '@/types/api';
|
||||||
|
|
||||||
|
// Base URL: /media (Vite proxy → Fastify 4100)
|
||||||
|
export const mediaApi = axios.create({
|
||||||
|
baseURL: '/media',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor: attach Bearer token from auth store
|
||||||
|
mediaApi.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const callbacks = getAuthCallbacks();
|
||||||
|
const { accessToken } = callbacks.getTokens();
|
||||||
|
if (accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor: handle 401 with token refresh
|
||||||
|
let mediaRefreshPromise: Promise<AuthResponse> | null = null;
|
||||||
|
|
||||||
|
mediaApi.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
const errorCode = error.response?.data?.error?.code;
|
||||||
|
|
||||||
|
// Handle 401 errors with proper error code checking
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
(errorCode === 'INVALID_TOKEN' || errorCode === 'AUTH_REQUIRED') &&
|
||||||
|
!originalRequest._retry
|
||||||
|
) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
const callbacks = getAuthCallbacks();
|
||||||
|
const { refreshToken } = callbacks.getTokens();
|
||||||
|
|
||||||
|
// No refresh token available - fail immediately
|
||||||
|
if (!refreshToken) {
|
||||||
|
callbacks.onAuthFailure();
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use shared refresh promise to prevent concurrent refreshes
|
||||||
|
if (!mediaRefreshPromise) {
|
||||||
|
// Import main API client dynamically to avoid circular dependency
|
||||||
|
const { api } = await import('./api');
|
||||||
|
|
||||||
|
mediaRefreshPromise = api
|
||||||
|
.post<AuthResponse>('/auth/refresh', { refreshToken })
|
||||||
|
.then((res) => res.data)
|
||||||
|
.finally(() => {
|
||||||
|
mediaRefreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await mediaRefreshPromise;
|
||||||
|
|
||||||
|
// Update tokens in auth store via callback
|
||||||
|
callbacks.onTokenRefresh(data.accessToken, data.refreshToken);
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||||
|
return mediaApi(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh failed - clear auth state and trigger redirect
|
||||||
|
callbacks.onAuthFailure();
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
52
admin/src/lib/media-public-api.ts
Normal file
52
admin/src/lib/media-public-api.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { getAuthCallbacks } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Media API Client
|
||||||
|
* For unauthenticated or optionally authenticated public endpoints
|
||||||
|
* No 401 refresh interceptor since these are public routes
|
||||||
|
*/
|
||||||
|
export const mediaPublicApi = axios.create({
|
||||||
|
baseURL: '/media',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate or retrieve session ID for anonymous tracking
|
||||||
|
* Stored in localStorage for upvote/comment tracking
|
||||||
|
*/
|
||||||
|
export function getOrCreateSessionId(): string {
|
||||||
|
const STORAGE_KEY = 'media_session_id';
|
||||||
|
let sessionId = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
// Generate UUID v4 using crypto API
|
||||||
|
sessionId = crypto.randomUUID();
|
||||||
|
localStorage.setItem(STORAGE_KEY, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach session ID to request headers
|
||||||
|
* Used for upvote/comment tracking without auth
|
||||||
|
*/
|
||||||
|
mediaPublicApi.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const sessionId = getOrCreateSessionId();
|
||||||
|
config.headers['X-Session-ID'] = sessionId;
|
||||||
|
|
||||||
|
// Optionally attach Bearer token if user is logged in
|
||||||
|
const callbacks = getAuthCallbacks();
|
||||||
|
const { accessToken } = callbacks.getTokens();
|
||||||
|
if (accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
@ -44,9 +44,10 @@ export default function CanvassDashboardPage() {
|
|||||||
const [mapVisible, setMapVisible] = useState(true);
|
const [mapVisible, setMapVisible] = useState(true);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [_historyRoute, setHistoryRoute] = useState<SessionRoute | null>(null);
|
const [_historyRoute, setHistoryRoute] = useState<SessionRoute | null>(null);
|
||||||
|
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'Canvassing' });
|
setPageHeader({ title: 'Canvassing', fullBleed: true });
|
||||||
return () => setPageHeader(null);
|
return () => setPageHeader(null);
|
||||||
}, [setPageHeader]);
|
}, [setPageHeader]);
|
||||||
|
|
||||||
@ -72,12 +73,31 @@ export default function CanvassDashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [message]);
|
}, [message]);
|
||||||
|
|
||||||
|
const toggleCut = useCallback((id: string) => {
|
||||||
|
setVisibleCutIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
const interval = setInterval(loadData, 30000);
|
const interval = setInterval(loadData, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
// Initialize visible cuts when cuts data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (cuts.length > 0) {
|
||||||
|
setVisibleCutIds(new Set(cuts.map((c) => c.id)));
|
||||||
|
}
|
||||||
|
}, [cuts]);
|
||||||
|
|
||||||
const activityColumns: ColumnsType<CanvassVisit> = [
|
const activityColumns: ColumnsType<CanvassVisit> = [
|
||||||
{
|
{
|
||||||
title: 'Volunteer',
|
title: 'Volunteer',
|
||||||
@ -90,7 +110,7 @@ export default function CanvassDashboardPage() {
|
|||||||
key: 'address',
|
key: 'address',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (_: unknown, r: CanvassVisit) =>
|
render: (_: unknown, r: CanvassVisit) =>
|
||||||
`${r.location?.address ?? '?'}${r.location?.unitNumber ? ` #${r.location.unitNumber}` : ''}`,
|
`${r.address?.location?.address ?? '?'}${r.address?.unitNumber ? ` #${r.address.unitNumber}` : ''}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Outcome',
|
title: 'Outcome',
|
||||||
@ -167,7 +187,7 @@ export default function CanvassDashboardPage() {
|
|||||||
bounds: c.bounds,
|
bounds: c.bounds,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mapHeight = isMobile ? 350 : isDesktop ? 'calc(100vh - 180px)' : 500;
|
const mapHeight = isMobile ? 350 : isDesktop ? 'calc(100vh - 120px)' : 500;
|
||||||
|
|
||||||
const mapCard = (
|
const mapCard = (
|
||||||
<Card
|
<Card
|
||||||
@ -200,7 +220,12 @@ export default function CanvassDashboardPage() {
|
|||||||
>
|
>
|
||||||
{mapVisible && (
|
{mapVisible && (
|
||||||
<div style={{ height: mapHeight }}>
|
<div style={{ height: mapHeight }}>
|
||||||
<AdminLiveMap cuts={publicCuts} mapSettings={mapSettings} />
|
<AdminLiveMap
|
||||||
|
cuts={publicCuts}
|
||||||
|
mapSettings={mapSettings}
|
||||||
|
visibleCutIds={visibleCutIds}
|
||||||
|
onToggleCut={toggleCut}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!mapVisible && (
|
{!mapVisible && (
|
||||||
@ -215,7 +240,7 @@ export default function CanvassDashboardPage() {
|
|||||||
<div
|
<div
|
||||||
style={
|
style={
|
||||||
isDesktop
|
isDesktop
|
||||||
? { position: 'sticky', top: 0, maxHeight: 'calc(100vh - 140px)', overflowY: 'auto', paddingRight: 4 }
|
? { position: 'sticky', top: 12, maxHeight: 'calc(100vh - 100px)', overflowY: 'auto', paddingRight: 4 }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -284,7 +309,7 @@ export default function CanvassDashboardPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ padding: isMobile ? 8 : 12 }}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
// Mobile: stack vertically
|
// Mobile: stack vertically
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import { useOutletContext } from 'react-router-dom';
|
|||||||
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
||||||
import { ReloadOutlined, LinkOutlined, CodeOutlined } from '@ant-design/icons';
|
import { ReloadOutlined, LinkOutlined, CodeOutlined } from '@ant-design/icons';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { buildServiceUrl } from '@/lib/service-url';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
import type { DocsStatus, DocsConfig } from '@/types/api';
|
import type { DocsStatus, ServicesConfig } from '@/types/api';
|
||||||
|
|
||||||
export default function CodeEditorPage() {
|
export default function CodeEditorPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
@ -12,17 +13,17 @@ export default function CodeEditorPage() {
|
|||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
const [online, setOnline] = useState<boolean | null>(null);
|
const [online, setOnline] = useState<boolean | null>(null);
|
||||||
const [codeServerPort, setCodeServerPort] = useState<number | null>(null);
|
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [statusRes, configRes] = await Promise.all([
|
const [statusRes, configRes] = await Promise.all([
|
||||||
api.get<DocsStatus>('/docs/status'),
|
api.get<DocsStatus>('/docs/status'),
|
||||||
api.get<DocsConfig>('/docs/config'),
|
api.get<ServicesConfig>('/services/config'),
|
||||||
]);
|
]);
|
||||||
setOnline(statusRes.data.codeServer.online);
|
setOnline(statusRes.data.codeServer.online);
|
||||||
setCodeServerPort(configRes.data.codeServerPort);
|
setConfig(configRes.data);
|
||||||
} catch {
|
} catch {
|
||||||
setOnline(false);
|
setOnline(false);
|
||||||
} finally {
|
} finally {
|
||||||
@ -34,8 +35,8 @@ export default function CodeEditorPage() {
|
|||||||
fetchStatus();
|
fetchStatus();
|
||||||
}, [fetchStatus]);
|
}, [fetchStatus]);
|
||||||
|
|
||||||
const codeServerUrl = codeServerPort
|
const codeServerUrl = config
|
||||||
? `//${window.location.hostname}:${codeServerPort}`
|
? buildServiceUrl(config.codeServerSubdomain, config.domain, config.codeServerPort)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
import type { Cut, Location, CutStatistics, SupportLevel } from '@/types/api';
|
import type { Cut, Location, Address, CutStatistics, SupportLevel } from '@/types/api';
|
||||||
import {
|
import {
|
||||||
SUPPORT_LEVEL_LABELS,
|
SUPPORT_LEVEL_LABELS,
|
||||||
SUPPORT_LEVEL_COLORS,
|
SUPPORT_LEVEL_COLORS,
|
||||||
@ -28,11 +28,16 @@ import {
|
|||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// Combined type for table display - Address with parent Location info
|
||||||
|
interface AddressWithLocation extends Address {
|
||||||
|
locationAddress: string; // Building street address
|
||||||
|
}
|
||||||
|
|
||||||
export default function CutExportPage() {
|
export default function CutExportPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [cut, setCut] = useState<Cut | null>(null);
|
const [cut, setCut] = useState<Cut | null>(null);
|
||||||
const [locations, setLocations] = useState<Location[]>([]);
|
const [addresses, setAddresses] = useState<AddressWithLocation[]>([]);
|
||||||
const [stats, setStats] = useState<CutStatistics | null>(null);
|
const [stats, setStats] = useState<CutStatistics | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@ -46,7 +51,21 @@ export default function CutExportPage() {
|
|||||||
api.get<CutStatistics>(`/map/cuts/${id}/statistics`),
|
api.get<CutStatistics>(`/map/cuts/${id}/statistics`),
|
||||||
]);
|
]);
|
||||||
setCut(cutRes.data);
|
setCut(cutRes.data);
|
||||||
setLocations(locsRes.data);
|
|
||||||
|
// Flatten locations with their addresses
|
||||||
|
const flatAddresses: AddressWithLocation[] = [];
|
||||||
|
for (const loc of locsRes.data) {
|
||||||
|
if (loc.addresses && loc.addresses.length > 0) {
|
||||||
|
for (const addr of loc.addresses) {
|
||||||
|
flatAddresses.push({
|
||||||
|
...addr,
|
||||||
|
locationAddress: loc.address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAddresses(flatAddresses);
|
||||||
|
|
||||||
setStats(statsRes.data);
|
setStats(statsRes.data);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to load cut data');
|
message.error('Failed to load cut data');
|
||||||
@ -95,21 +114,21 @@ export default function CutExportPage() {
|
|||||||
|
|
||||||
const now = dayjs().format('YYYY-MM-DD HH:mm');
|
const now = dayjs().format('YYYY-MM-DD HH:mm');
|
||||||
|
|
||||||
const withEmail = locations.filter((l) => l.email).length;
|
const withEmail = addresses.filter((a) => a.email).length;
|
||||||
const withPhone = locations.filter((l) => l.phone).length;
|
const withPhone = addresses.filter((a) => a.phone).length;
|
||||||
|
|
||||||
const columns: ColumnsType<Location> = [
|
const columns: ColumnsType<AddressWithLocation> = [
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
render: (_: unknown, record: Location) =>
|
render: (_: unknown, record: AddressWithLocation) =>
|
||||||
[record.firstName, record.lastName].filter(Boolean).join(' ') || '--',
|
[record.firstName, record.lastName].filter(Boolean).join(' ') || '--',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Address',
|
title: 'Address',
|
||||||
key: 'address',
|
key: 'address',
|
||||||
render: (_: unknown, record: Location) =>
|
render: (_: unknown, record: AddressWithLocation) =>
|
||||||
[record.address, record.unitNumber && `#${record.unitNumber}`].filter(Boolean).join(' ') || '--',
|
[record.locationAddress, record.unitNumber && `#${record.unitNumber}`].filter(Boolean).join(' ') || '--',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Support',
|
title: 'Support',
|
||||||
@ -141,7 +160,7 @@ export default function CutExportPage() {
|
|||||||
title: 'Sign',
|
title: 'Sign',
|
||||||
key: 'sign',
|
key: 'sign',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_: unknown, record: Location) =>
|
render: (_: unknown, record: AddressWithLocation) =>
|
||||||
record.sign
|
record.sign
|
||||||
? `Yes${record.signSize ? ` (${record.signSize})` : ''}`
|
? `Yes${record.signSize ? ` (${record.signSize})` : ''}`
|
||||||
: 'No',
|
: 'No',
|
||||||
@ -278,10 +297,10 @@ export default function CutExportPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location table */}
|
{/* Address table */}
|
||||||
<Table<Location>
|
<Table<AddressWithLocation>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={locations}
|
dataSource={addresses}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@ -426,9 +426,13 @@ export default function CutsPage() {
|
|||||||
), [activeTab]);
|
), [activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'Cuts', actions: headerActions });
|
setPageHeader({
|
||||||
|
title: 'Cuts',
|
||||||
|
actions: headerActions,
|
||||||
|
fullBleed: activeTab === 'map'
|
||||||
|
});
|
||||||
return () => setPageHeader(null);
|
return () => setPageHeader(null);
|
||||||
}, [setPageHeader, headerActions]);
|
}, [setPageHeader, headerActions, activeTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
239
admin/src/pages/DataQualityDashboardPage.tsx
Normal file
239
admin/src/pages/DataQualityDashboardPage.tsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Card, Row, Col, Statistic, Spin, Typography, Grid, Button, App } from 'antd';
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
|
import type { LocationStats } from '@/types/api';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default function DataQualityDashboardPage() {
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<LocationStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<LocationStats>('/map/locations/stats');
|
||||||
|
setStats(data);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load data quality stats');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
// Initial load + auto-refresh every 30s
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
const interval = setInterval(loadStats, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadStats]);
|
||||||
|
|
||||||
|
// Page header with refresh button
|
||||||
|
useEffect(() => {
|
||||||
|
setPageHeader({
|
||||||
|
title: 'Data Quality Dashboard',
|
||||||
|
actions: (
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
loadStats();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return () => setPageHeader(null);
|
||||||
|
}, [setPageHeader, loadStats]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: screens.md ? 24 : 16 }}>
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Total Locations"
|
||||||
|
value={stats.total}
|
||||||
|
prefix={<EnvironmentOutlined />}
|
||||||
|
valueStyle={{ color: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Geocoded"
|
||||||
|
value={stats.geocoded}
|
||||||
|
suffix={
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
({stats.total > 0 ? Math.round((stats.geocoded / stats.total) * 100) : 0}%)
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Ungeocoded"
|
||||||
|
value={stats.ungeocoded}
|
||||||
|
valueStyle={{ color: stats.ungeocoded > 0 ? '#ff4d4f' : '#8c8c8c' }}
|
||||||
|
prefix={stats.ungeocoded > 0 ? <WarningOutlined /> : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Average Confidence"
|
||||||
|
value={stats.confidence.average ?? 0}
|
||||||
|
suffix="%"
|
||||||
|
valueStyle={{
|
||||||
|
color:
|
||||||
|
!stats.confidence.average
|
||||||
|
? '#8c8c8c'
|
||||||
|
: stats.confidence.average >= 85
|
||||||
|
? '#52c41a'
|
||||||
|
: stats.confidence.average >= 60
|
||||||
|
? '#faad14'
|
||||||
|
: '#ff4d4f',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Geocoding Confidence */}
|
||||||
|
<Title level={5} style={{ marginTop: 16, marginBottom: 12 }}>
|
||||||
|
<BarChartOutlined /> Geocoding Confidence
|
||||||
|
</Title>
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="High Confidence"
|
||||||
|
value={stats.confidence.high}
|
||||||
|
prefix={<CheckCircleOutlined />}
|
||||||
|
suffix={<Text type="secondary" style={{ fontSize: 12 }}>≥85%</Text>}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Medium Confidence"
|
||||||
|
value={stats.confidence.medium}
|
||||||
|
prefix={<InfoCircleOutlined />}
|
||||||
|
suffix={<Text type="secondary" style={{ fontSize: 12 }}>60-84%</Text>}
|
||||||
|
valueStyle={{ color: '#faad14' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Low Confidence"
|
||||||
|
value={stats.confidence.low}
|
||||||
|
prefix={<WarningOutlined />}
|
||||||
|
suffix={<Text type="secondary" style={{ fontSize: 12 }}><60%</Text>}
|
||||||
|
valueStyle={{ color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Manual/None"
|
||||||
|
value={stats.confidence.none}
|
||||||
|
valueStyle={{ color: '#8c8c8c' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Provider Breakdown */}
|
||||||
|
<Title level={5} style={{ marginTop: 16, marginBottom: 12 }}>
|
||||||
|
Provider Distribution
|
||||||
|
</Title>
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
|
||||||
|
{Object.entries(stats.providers).map(([provider, count]) => (
|
||||||
|
<Col xs={24} sm={12} md={6} key={provider}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title={provider.charAt(0).toUpperCase() + provider.slice(1)}
|
||||||
|
value={count}
|
||||||
|
valueStyle={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Building Type Distribution */}
|
||||||
|
<Title level={5} style={{ marginTop: 16, marginBottom: 12 }}>
|
||||||
|
Building Types
|
||||||
|
</Title>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Single Family"
|
||||||
|
value={stats.buildingTypes.SINGLE_FAMILY}
|
||||||
|
valueStyle={{ color: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Multi-Unit"
|
||||||
|
value={stats.buildingTypes.MULTI_UNIT}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Mixed Use"
|
||||||
|
value={stats.buildingTypes.MIXED_USE}
|
||||||
|
valueStyle={{ color: '#faad14' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Commercial"
|
||||||
|
value={stats.buildingTypes.COMMERCIAL}
|
||||||
|
valueStyle={{ color: '#722ed1' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -47,13 +47,15 @@ import {
|
|||||||
TableOutlined,
|
TableOutlined,
|
||||||
FontSizeOutlined,
|
FontSizeOutlined,
|
||||||
BuildOutlined,
|
BuildOutlined,
|
||||||
|
HolderOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import type { OnMount } from '@monaco-editor/react';
|
import type { OnMount } from '@monaco-editor/react';
|
||||||
import type { editor as monacoEditor } from 'monaco-editor';
|
import type { editor as monacoEditor } from 'monaco-editor';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { buildServiceUrl } from '@/lib/service-url';
|
||||||
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
||||||
import type { FileNode } from '@/types/api';
|
import type { FileNode, ServicesConfig } from '@/types/api';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
|
|
||||||
type LayoutMode = 'split' | 'editor' | 'preview';
|
type LayoutMode = 'split' | 'editor' | 'preview';
|
||||||
@ -62,6 +64,9 @@ const LAYOUT_STORAGE_KEY = 'docs-editor-layout';
|
|||||||
const DIVIDER_STORAGE_KEY = 'docs-editor-split';
|
const DIVIDER_STORAGE_KEY = 'docs-editor-split';
|
||||||
const TREE_COLLAPSED_KEY = 'docs-tree-collapsed';
|
const TREE_COLLAPSED_KEY = 'docs-tree-collapsed';
|
||||||
const TREE_WIDTH_KEY = 'docs-tree-width';
|
const TREE_WIDTH_KEY = 'docs-tree-width';
|
||||||
|
const TREE_CACHE_KEY = 'docs-tree-cache';
|
||||||
|
const TREE_CACHE_TIMESTAMP_KEY = 'docs-tree-cache-timestamp';
|
||||||
|
const TREE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
const DEFAULT_TREE_WIDTH = 200;
|
const DEFAULT_TREE_WIDTH = 200;
|
||||||
const MIN_TREE_WIDTH = 160;
|
const MIN_TREE_WIDTH = 160;
|
||||||
const MAX_TREE_WIDTH = 400;
|
const MAX_TREE_WIDTH = 400;
|
||||||
@ -74,6 +79,127 @@ function filePathToMkDocsUrl(filePath: string): string {
|
|||||||
return '/mkdocs-proxy/' + url + (url ? '/' : '');
|
return '/mkdocs-proxy/' + url + (url ? '/' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tree cache helpers
|
||||||
|
function getCachedTree(): FileNode[] | null {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(TREE_CACHE_KEY);
|
||||||
|
const timestamp = localStorage.getItem(TREE_CACHE_TIMESTAMP_KEY);
|
||||||
|
if (!cached || !timestamp) return null;
|
||||||
|
|
||||||
|
const age = Date.now() - parseInt(timestamp, 10);
|
||||||
|
if (age > TREE_CACHE_TTL) {
|
||||||
|
// Cache expired
|
||||||
|
localStorage.removeItem(TREE_CACHE_KEY);
|
||||||
|
localStorage.removeItem(TREE_CACHE_TIMESTAMP_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(cached) as FileNode[];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedTree(tree: FileNode[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TREE_CACHE_KEY, JSON.stringify(tree));
|
||||||
|
localStorage.setItem(TREE_CACHE_TIMESTAMP_KEY, Date.now().toString());
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateTreeCache(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TREE_CACHE_KEY);
|
||||||
|
localStorage.removeItem(TREE_CACHE_TIMESTAMP_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL Preview Bar Component (shows production + localhost URLs above iframe)
|
||||||
|
const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config: ServicesConfig | null }) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
// Only show for markdown files
|
||||||
|
if (!filePath || !filePath.endsWith('.md')) return null;
|
||||||
|
|
||||||
|
// Transform file path to URL path (reuse existing logic from filePathToMkDocsUrl)
|
||||||
|
let urlPath = filePath.replace(/\.md$/, '');
|
||||||
|
if (urlPath.endsWith('/index') || urlPath === 'index') {
|
||||||
|
urlPath = urlPath.replace(/\/?index$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use buildServiceUrl for environment-aware URL construction
|
||||||
|
const baseUrl = config
|
||||||
|
? buildServiceUrl(config.mkdocsSubdomain, config.domain, config.mkdocsPort)
|
||||||
|
: null;
|
||||||
|
const productionUrl = baseUrl ? `${baseUrl}/${urlPath}${urlPath ? '/' : ''}` : '';
|
||||||
|
const localhostUrl = productionUrl; // Same URL works for both environments now
|
||||||
|
|
||||||
|
const openUrl = (url: string) => {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 32,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 8px',
|
||||||
|
background: token.colorBgElevated,
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
gap: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: token.colorTextSecondary,
|
||||||
|
fontWeight: 600,
|
||||||
|
userSelect: 'none',
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview:
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
<Space size={8}>
|
||||||
|
{/* Production URL Button */}
|
||||||
|
<Tooltip title={productionUrl} mouseEnterDelay={0.3}>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
size="small"
|
||||||
|
icon={<ExportOutlined />}
|
||||||
|
onClick={() => openUrl(productionUrl)}
|
||||||
|
style={{ height: 24, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Production
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Localhost URL Button */}
|
||||||
|
<Tooltip title={localhostUrl} mouseEnterDelay={0.3}>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
size="small"
|
||||||
|
icon={<ExportOutlined />}
|
||||||
|
onClick={() => openUrl(localhostUrl)}
|
||||||
|
style={{ height: 24, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Localhost
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function fileNodeToTreeData(nodes: FileNode[]): TreeDataNode[] {
|
function fileNodeToTreeData(nodes: FileNode[]): TreeDataNode[] {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
const displayName = !node.isDirectory && node.name.endsWith('.md')
|
const displayName = !node.isDirectory && node.name.endsWith('.md')
|
||||||
@ -92,10 +218,6 @@ function fileNodeToTreeData(nodes: FileNode[]): TreeDataNode[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectTopLevelKeys(nodes: FileNode[]): string[] {
|
|
||||||
return nodes.filter(n => n.isDirectory).map(n => n.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collect all keys (dirs + files) for expand-all on filter */
|
/** Collect all keys (dirs + files) for expand-all on filter */
|
||||||
function collectAllDirKeys(nodes: FileNode[]): string[] {
|
function collectAllDirKeys(nodes: FileNode[]): string[] {
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
@ -239,7 +361,8 @@ export default function DocsPage() {
|
|||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
||||||
|
|
||||||
const [fileTree, setFileTree] = useState<FileNode[]>([]);
|
const [fileTree, setFileTree] = useState<FileNode[]>(() => getCachedTree() || []);
|
||||||
|
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [fetchError, setFetchError] = useState(false);
|
const [fetchError, setFetchError] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
@ -248,6 +371,7 @@ export default function DocsPage() {
|
|||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [fileLoading, setFileLoading] = useState(false);
|
const [fileLoading, setFileLoading] = useState(false);
|
||||||
|
const [fileContentCache, setFileContentCache] = useState<Map<string, string>>(new Map());
|
||||||
const [layout, setLayout] = useState<LayoutMode>(
|
const [layout, setLayout] = useState<LayoutMode>(
|
||||||
() => (localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode) || 'split',
|
() => (localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode) || 'split',
|
||||||
);
|
);
|
||||||
@ -278,21 +402,46 @@ export default function DocsPage() {
|
|||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
// Fetch file tree
|
// Fetch file tree
|
||||||
const fetchTree = useCallback(async () => {
|
const fetchTree = useCallback(async (showLoading = true) => {
|
||||||
try {
|
try {
|
||||||
|
if (showLoading) setLoading(true);
|
||||||
|
setFetchError(false);
|
||||||
const res = await api.get<FileNode[]>('/docs/files');
|
const res = await api.get<FileNode[]>('/docs/files');
|
||||||
setFileTree(res.data);
|
setFileTree(res.data);
|
||||||
|
setCachedTree(res.data);
|
||||||
} catch {
|
} catch {
|
||||||
setFetchError(true);
|
setFetchError(true);
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<ServicesConfig>('/services/config');
|
||||||
|
setConfig(res.data);
|
||||||
|
} catch {
|
||||||
|
// Config fetch failed — leave null
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
|
const cached = getCachedTree();
|
||||||
|
if (cached) {
|
||||||
|
// Show cached tree immediately
|
||||||
|
setFileTree(cached);
|
||||||
|
setLoading(false);
|
||||||
|
// Refresh in background
|
||||||
|
fetchTree(false);
|
||||||
|
} else {
|
||||||
|
// No cache - show loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setFetchError(false);
|
setFetchError(false);
|
||||||
await fetchTree();
|
await fetchTree(true);
|
||||||
setLoading(false);
|
}
|
||||||
}, [fetchTree]);
|
// Fetch config in parallel
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchTree, fetchConfig]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
useEffect(() => { localStorage.setItem(LAYOUT_STORAGE_KEY, layout); }, [layout]);
|
useEffect(() => { localStorage.setItem(LAYOUT_STORAGE_KEY, layout); }, [layout]);
|
||||||
@ -302,11 +451,30 @@ export default function DocsPage() {
|
|||||||
|
|
||||||
// Load file content when selected
|
// Load file content when selected
|
||||||
const loadFile = useCallback(async (filePath: string) => {
|
const loadFile = useCallback(async (filePath: string) => {
|
||||||
|
// Check cache first
|
||||||
|
const cached = fileContentCache.get(filePath);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
setFileContent(cached);
|
||||||
|
setOriginalContent(cached);
|
||||||
|
setSelectedFile(filePath);
|
||||||
|
setDirty(false);
|
||||||
|
if (previewIframeRef.current && filePath.endsWith('.md')) {
|
||||||
|
previewIframeRef.current.src = filePathToMkDocsUrl(filePath);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - fetch from API
|
||||||
setFileLoading(true);
|
setFileLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{ path: string; content: string }>(`/docs/files/${filePath}`);
|
const res = await api.get<{ path: string; content: string }>(`/docs/files/${filePath}`);
|
||||||
setFileContent(res.data.content);
|
const content = res.data.content;
|
||||||
setOriginalContent(res.data.content);
|
|
||||||
|
// Update cache
|
||||||
|
setFileContentCache(prev => new Map(prev).set(filePath, content));
|
||||||
|
|
||||||
|
setFileContent(content);
|
||||||
|
setOriginalContent(content);
|
||||||
setSelectedFile(filePath);
|
setSelectedFile(filePath);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
if (previewIframeRef.current && filePath.endsWith('.md')) {
|
if (previewIframeRef.current && filePath.endsWith('.md')) {
|
||||||
@ -317,7 +485,7 @@ export default function DocsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setFileLoading(false);
|
setFileLoading(false);
|
||||||
}
|
}
|
||||||
}, [messageApi]);
|
}, [fileContentCache, messageApi]);
|
||||||
|
|
||||||
// Save file
|
// Save file
|
||||||
const saveFile = useCallback(async () => {
|
const saveFile = useCallback(async () => {
|
||||||
@ -327,6 +495,10 @@ export default function DocsPage() {
|
|||||||
await api.put(`/docs/files/${selectedFile}`, { content: fileContent });
|
await api.put(`/docs/files/${selectedFile}`, { content: fileContent });
|
||||||
setOriginalContent(fileContent);
|
setOriginalContent(fileContent);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
|
||||||
|
// Update cache with new content
|
||||||
|
setFileContentCache(prev => new Map(prev).set(selectedFile, fileContent));
|
||||||
|
|
||||||
messageApi.success('Saved');
|
messageApi.success('Saved');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (previewIframeRef.current) {
|
if (previewIframeRef.current) {
|
||||||
@ -545,6 +717,15 @@ export default function DocsPage() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/docs/files/${filePath}`);
|
await api.delete(`/docs/files/${filePath}`);
|
||||||
messageApi.success('Deleted');
|
messageApi.success('Deleted');
|
||||||
|
|
||||||
|
// Invalidate caches
|
||||||
|
invalidateTreeCache();
|
||||||
|
setFileContentCache(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(filePath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
if (selectedFile === filePath) {
|
if (selectedFile === filePath) {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setFileContent('');
|
setFileContent('');
|
||||||
@ -567,18 +748,35 @@ export default function DocsPage() {
|
|||||||
const path = contextPath ? `${contextPath}/${name}` : name;
|
const path = contextPath ? `${contextPath}/${name}` : name;
|
||||||
await api.post(`/docs/files/${path}`, { content: `# ${modalInput.replace(/\.md$/, '')}\n` });
|
await api.post(`/docs/files/${path}`, { content: `# ${modalInput.replace(/\.md$/, '')}\n` });
|
||||||
messageApi.success('File created');
|
messageApi.success('File created');
|
||||||
fetchTree();
|
// Invalidate tree cache (structure changed)
|
||||||
loadFile(path);
|
invalidateTreeCache();
|
||||||
|
// Parallel loading for faster workflow
|
||||||
|
await Promise.all([fetchTree(), loadFile(path)]);
|
||||||
} else if (modalType === 'newFolder') {
|
} else if (modalType === 'newFolder') {
|
||||||
const path = contextPath ? `${contextPath}/${modalInput}` : modalInput;
|
const path = contextPath ? `${contextPath}/${modalInput}` : modalInput;
|
||||||
await api.post(`/docs/files/${path}`, { isDirectory: true });
|
await api.post(`/docs/files/${path}`, { isDirectory: true });
|
||||||
messageApi.success('Folder created');
|
messageApi.success('Folder created');
|
||||||
|
// Invalidate tree cache (structure changed)
|
||||||
|
invalidateTreeCache();
|
||||||
fetchTree();
|
fetchTree();
|
||||||
} else if (modalType === 'rename') {
|
} else if (modalType === 'rename') {
|
||||||
const parentDir = contextPath.includes('/') ? contextPath.substring(0, contextPath.lastIndexOf('/')) : '';
|
const parentDir = contextPath.includes('/') ? contextPath.substring(0, contextPath.lastIndexOf('/')) : '';
|
||||||
const newPath = parentDir ? `${parentDir}/${modalInput}` : modalInput;
|
const newPath = parentDir ? `${parentDir}/${modalInput}` : modalInput;
|
||||||
await api.post('/docs/files/rename', { from: contextPath, to: newPath });
|
await api.post('/docs/files/rename', { from: contextPath, to: newPath });
|
||||||
messageApi.success('Renamed');
|
messageApi.success('Renamed');
|
||||||
|
|
||||||
|
// Invalidate tree cache (structure changed)
|
||||||
|
invalidateTreeCache();
|
||||||
|
|
||||||
|
// Update file content cache: move cached content from old path to new path
|
||||||
|
setFileContentCache(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const cached = next.get(contextPath);
|
||||||
|
next.delete(contextPath);
|
||||||
|
if (cached) next.set(newPath, cached);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
if (selectedFile === contextPath) setSelectedFile(newPath);
|
if (selectedFile === contextPath) setSelectedFile(newPath);
|
||||||
fetchTree();
|
fetchTree();
|
||||||
}
|
}
|
||||||
@ -591,7 +789,9 @@ export default function DocsPage() {
|
|||||||
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
|
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
|
||||||
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
|
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
|
||||||
|
|
||||||
const mkdocsDirectUrl = `//${window.location.hostname}:${4003}`;
|
const mkdocsDirectUrl = config
|
||||||
|
? buildServiceUrl(config.mkdocsSubdomain, config.domain, config.mkdocsPort)
|
||||||
|
: null;
|
||||||
const toggleTree = useCallback(() => setTreeCollapsed(c => !c), []);
|
const toggleTree = useCallback(() => setTreeCollapsed(c => !c), []);
|
||||||
const toggleFilter = useCallback(() => {
|
const toggleFilter = useCallback(() => {
|
||||||
setFilterVisible(v => {
|
setFilterVisible(v => {
|
||||||
@ -608,6 +808,43 @@ export default function DocsPage() {
|
|||||||
setExpandedKeys([]);
|
setExpandedKeys([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleExpand = useCallback((key: string) => {
|
||||||
|
setExpandedKeys(prev =>
|
||||||
|
prev.includes(key)
|
||||||
|
? prev.filter(k => k !== key)
|
||||||
|
: [...prev, key]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Resize indicator component for dividers
|
||||||
|
const ResizeIndicator = () => (
|
||||||
|
<Tooltip title="Drag to resize" mouseEnterDelay={0.5}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 8,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: token.colorBgElevated,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
color: token.colorTextTertiary,
|
||||||
|
fontSize: 12,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
opacity: 0.8,
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HolderOutlined />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
// Header actions
|
// Header actions
|
||||||
const headerActions = useMemo(() => (
|
const headerActions = useMemo(() => (
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
@ -629,9 +866,11 @@ export default function DocsPage() {
|
|||||||
<Tooltip title="Refresh Preview">
|
<Tooltip title="Refresh Preview">
|
||||||
<Button type="text" icon={<ReloadOutlined />} onClick={refreshPreview} size="middle" />
|
<Button type="text" icon={<ReloadOutlined />} onClick={refreshPreview} size="middle" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{mkdocsDirectUrl && (
|
||||||
<Tooltip title="Open MkDocs in new tab">
|
<Tooltip title="Open MkDocs in new tab">
|
||||||
<Button type="text" icon={<ExportOutlined />} onClick={() => window.open(mkdocsDirectUrl, '_blank')} size="middle" />
|
<Button type="text" icon={<ExportOutlined />} onClick={() => window.open(mkdocsDirectUrl, '_blank')} size="middle" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<div style={{ width: 1, height: 24, background: token.colorBorderSecondary, margin: '0 4px' }} />
|
<div style={{ width: 1, height: 24, background: token.colorBorderSecondary, margin: '0 4px' }} />
|
||||||
@ -662,10 +901,10 @@ export default function DocsPage() {
|
|||||||
|
|
||||||
const treeData = useMemo(() => fileNodeToTreeData(filteredTree), [filteredTree]);
|
const treeData = useMemo(() => fileNodeToTreeData(filteredTree), [filteredTree]);
|
||||||
|
|
||||||
// When filtering, expand all; otherwise just top-level
|
// When filtering, expand all; otherwise collapsed
|
||||||
const expandedKeysForFilter = useMemo(() => {
|
const expandedKeysForFilter = useMemo(() => {
|
||||||
if (filterQuery.trim()) return collectAllDirKeys(filteredTree);
|
if (filterQuery.trim()) return collectAllDirKeys(filteredTree);
|
||||||
return collectTopLevelKeys(fileTree);
|
return [];
|
||||||
}, [filterQuery, filteredTree, fileTree]);
|
}, [filterQuery, filteredTree, fileTree]);
|
||||||
|
|
||||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||||
@ -722,7 +961,7 @@ export default function DocsPage() {
|
|||||||
line-height: 28px !important;
|
line-height: 28px !important;
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
transition: background 0.15s !important;
|
transition: background 0.05s !important;
|
||||||
}
|
}
|
||||||
.docs-tree .ant-tree-treenode:hover {
|
.docs-tree .ant-tree-treenode:hover {
|
||||||
background: rgba(255,255,255,0.06) !important;
|
background: rgba(255,255,255,0.06) !important;
|
||||||
@ -857,6 +1096,7 @@ export default function DocsPage() {
|
|||||||
treeData={treeData}
|
treeData={treeData}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
showLine={false}
|
showLine={false}
|
||||||
|
motion={false}
|
||||||
selectedKeys={selectedFile ? [selectedFile] : []}
|
selectedKeys={selectedFile ? [selectedFile] : []}
|
||||||
expandedKeys={expandedKeys}
|
expandedKeys={expandedKeys}
|
||||||
onExpand={(keys) => setExpandedKeys(keys)}
|
onExpand={(keys) => setExpandedKeys(keys)}
|
||||||
@ -876,6 +1116,12 @@ export default function DocsPage() {
|
|||||||
trigger={['contextMenu']}
|
trigger={['contextMenu']}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
onClick={(e) => {
|
||||||
|
if (isDir) {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpand(nodePath);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -884,6 +1130,7 @@ export default function DocsPage() {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineHeight: '24px',
|
lineHeight: '24px',
|
||||||
color: isDir ? token.colorTextSecondary : token.colorText,
|
color: isDir ? token.colorTextSecondary : token.colorText,
|
||||||
|
cursor: isDir ? 'pointer' : 'default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodeData.title as string}
|
{nodeData.title as string}
|
||||||
@ -919,6 +1166,7 @@ export default function DocsPage() {
|
|||||||
onMouseEnter={(e) => { (e.currentTarget.style.background = token.colorPrimary); (e.currentTarget.style.width = '3px'); }}
|
onMouseEnter={(e) => { (e.currentTarget.style.background = token.colorPrimary); (e.currentTarget.style.width = '3px'); }}
|
||||||
onMouseLeave={(e) => { (e.currentTarget.style.background = token.colorBorderSecondary); (e.currentTarget.style.width = '1px'); }}
|
onMouseLeave={(e) => { (e.currentTarget.style.background = token.colorBorderSecondary); (e.currentTarget.style.width = '1px'); }}
|
||||||
/>
|
/>
|
||||||
|
<ResizeIndicator />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -1102,19 +1350,34 @@ export default function DocsPage() {
|
|||||||
background: token.colorBorderSecondary,
|
background: token.colorBorderSecondary,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.background = token.colorPrimary; }}
|
onMouseEnter={(e) => { (e.target as HTMLElement).style.background = token.colorPrimary; }}
|
||||||
onMouseLeave={(e) => { if (!dragging.current) (e.target as HTMLElement).style.background = token.colorBorderSecondary; }}
|
onMouseLeave={(e) => { if (!dragging.current) (e.target as HTMLElement).style.background = token.colorBorderSecondary; }}
|
||||||
/>
|
>
|
||||||
|
<ResizeIndicator />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MkDocs Preview Panel */}
|
{/* MkDocs Preview Panel */}
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div style={{ flex: layout === 'preview' ? 1 : undefined, width: layout === 'split' ? `${100 - splitPercent}%` : undefined, height: '100%' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
flex: layout === 'preview' ? 1 : undefined,
|
||||||
|
width: layout === 'split' ? `${100 - splitPercent}%` : undefined,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* URL Preview Bar */}
|
||||||
|
<URLPreviewBar filePath={selectedFile} config={config} />
|
||||||
|
|
||||||
|
{/* Preview iframe */}
|
||||||
<iframe
|
<iframe
|
||||||
ref={previewIframeRef}
|
ref={previewIframeRef}
|
||||||
src="/mkdocs-proxy/"
|
src="/mkdocs-proxy/"
|
||||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
style={{ width: '100%', flex: 1, border: 'none' }}
|
||||||
title="MkDocs Preview"
|
title="MkDocs Preview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
363
admin/src/pages/EmailTemplateEditorPage.tsx
Normal file
363
admin/src/pages/EmailTemplateEditorPage.tsx
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Grid,
|
||||||
|
Result,
|
||||||
|
theme,
|
||||||
|
Input,
|
||||||
|
Tabs,
|
||||||
|
Table,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function EmailTemplateEditorPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
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 [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();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);
|
||||||
|
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');
|
||||||
|
navigate('/app/email-templates');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTemplate();
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!template) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {
|
||||||
|
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, id, 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 processTemplate = (content: string, data: Record<string, string>): string => {
|
||||||
|
let processed = content;
|
||||||
|
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 (
|
||||||
|
<Result
|
||||||
|
status="warning"
|
||||||
|
title="Desktop Required"
|
||||||
|
subTitle="The email template editor requires a desktop browser."
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => navigate('/app/email-templates')}>
|
||||||
|
Back to Templates
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const variableColumns = [
|
||||||
|
{
|
||||||
|
title: 'Variable',
|
||||||
|
dataIndex: 'key',
|
||||||
|
key: 'key',
|
||||||
|
render: (key: string) => <Text code>{'{{' + key + '}}'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Label',
|
||||||
|
dataIndex: 'label',
|
||||||
|
key: 'label',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Description',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
render: (desc: string | null) => <Text type="secondary">{desc || '—'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Required',
|
||||||
|
dataIndex: 'isRequired',
|
||||||
|
key: 'isRequired',
|
||||||
|
render: (isRequired: boolean) => (isRequired ? <Tag color="red">Required</Tag> : <Tag>Optional</Tag>),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', backgroundColor: token.colorBgContainer }}>
|
||||||
|
{/* Top Toolbar */}
|
||||||
|
<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={() => navigate('/app/email-templates')}
|
||||||
|
/>
|
||||||
|
<Text strong>{template.name}</Text>
|
||||||
|
<Tag color={getCategoryColor(template.category)}>{template.category}</Tag>
|
||||||
|
{template.isSystem && <Tag color="blue">SYSTEM</Tag>}
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setTestModalOpen(true)} icon={<SendOutlined />}>
|
||||||
|
Test Email
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" loading={saving} onClick={handleSave} icon={<SaveOutlined />}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subject Line Input */}
|
||||||
|
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
|
<Input
|
||||||
|
value={subjectLine}
|
||||||
|
onChange={(e) => setSubjectLine(e.target.value)}
|
||||||
|
placeholder="Email Subject Line (use {{VARIABLES}})"
|
||||||
|
prefix={<MailOutlined />}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Editor Layout */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
{/* HTML Editor */}
|
||||||
|
<div style={{ flex: '0 0 40%', borderRight: `1px solid ${token.colorBorderSecondary}`, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ padding: '8px 12px', borderBottom: `1px solid ${token.colorBorderSecondary}`, backgroundColor: token.colorBgLayout }}>
|
||||||
|
<Text strong>HTML Content</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Editor */}
|
||||||
|
<div style={{ flex: '0 0 40%', borderRight: `1px solid ${token.colorBorderSecondary}`, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ padding: '8px 12px', borderBottom: `1px solid ${token.colorBorderSecondary}`, backgroundColor: token.colorBgLayout }}>
|
||||||
|
<Text strong>Plain Text Content</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<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 Panel - Variables & Preview */}
|
||||||
|
<div style={{ flex: '0 0 20%', display: 'flex', flexDirection: 'column', backgroundColor: token.colorBgContainer }}>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'variables',
|
||||||
|
label: 'Variables',
|
||||||
|
children: (
|
||||||
|
<div style={{ padding: 12, height: '100%', overflow: 'auto' }}>
|
||||||
|
<Table
|
||||||
|
dataSource={template.variables}
|
||||||
|
columns={variableColumns}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Text strong>Sample Data (for preview):</Text>
|
||||||
|
{template.variables.map((v) => (
|
||||||
|
<div key={v.key} style={{ marginTop: 8 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{v.label}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={sampleData[v.key] || ''}
|
||||||
|
onChange={(e) => setSampleData({ ...sampleData, [v.key]: e.target.value })}
|
||||||
|
placeholder={v.sampleValue || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Email Modal */}
|
||||||
|
{testModalOpen && (
|
||||||
|
<TestEmailModal
|
||||||
|
open={testModalOpen}
|
||||||
|
template={template}
|
||||||
|
onClose={() => setTestModalOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
message.success('Test email sent successfully');
|
||||||
|
setTestModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
admin/src/pages/EmailTemplatesPage.tsx
Normal file
345
admin/src/pages/EmailTemplatesPage.tsx
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
Typography,
|
||||||
|
Badge,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type {
|
||||||
|
EmailTemplate,
|
||||||
|
EmailTemplatesListResponse,
|
||||||
|
EmailTemplatesListParams,
|
||||||
|
EmailTemplateCategory,
|
||||||
|
PaginationMeta,
|
||||||
|
} from '@/types/api';
|
||||||
|
import TestEmailModal from '@/components/email-templates/TestEmailModal';
|
||||||
|
import VersionHistoryDrawer from '@/components/email-templates/VersionHistoryDrawer';
|
||||||
|
import EmailTemplateEditor from '@/components/email-templates/EmailTemplateEditor';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const categoryOptions: { value: EmailTemplateCategory | 'ALL'; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: 'All Categories' },
|
||||||
|
{ value: 'INFLUENCE', label: 'Influence' },
|
||||||
|
{ value: 'MAP', label: 'Map' },
|
||||||
|
{ value: 'SYSTEM', label: 'System' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeOptions = [
|
||||||
|
{ value: 'ALL', label: 'All Status' },
|
||||||
|
{ value: 'ACTIVE', label: 'Active' },
|
||||||
|
{ value: 'INACTIVE', label: 'Inactive' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EmailTemplatesPage() {
|
||||||
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<EmailTemplateCategory | 'ALL'>('ALL');
|
||||||
|
const [activeFilter, setActiveFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL');
|
||||||
|
const [testModalOpen, setTestModalOpen] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);
|
||||||
|
const [versionDrawerOpen, setVersionDrawerOpen] = useState(false);
|
||||||
|
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearch(value);
|
||||||
|
clearTimeout(searchTimerRef.current);
|
||||||
|
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearTimeout(searchTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchTemplates = useCallback(
|
||||||
|
async (params?: EmailTemplatesListParams) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<EmailTemplatesListResponse>('/email-templates', {
|
||||||
|
params: {
|
||||||
|
page: params?.page ?? 1,
|
||||||
|
limit: params?.limit ?? 20,
|
||||||
|
search: params?.search ?? (debouncedSearch || undefined),
|
||||||
|
category: categoryFilter !== 'ALL' ? categoryFilter : undefined,
|
||||||
|
isActive: activeFilter !== 'ALL' ? activeFilter === 'ACTIVE' : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTemplates(data.templates);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load templates');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[debouncedSearch, categoryFilter, activeFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplates({ page: 1 });
|
||||||
|
}, [debouncedSearch, categoryFilter, activeFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||||
|
fetchTemplates({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/email-templates/${id}`);
|
||||||
|
message.success('Template deleted');
|
||||||
|
fetchTemplates();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||||
|
'Failed to delete template';
|
||||||
|
message.error(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTestEmailModal = (template: EmailTemplate) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setTestModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openVersionDrawer = (template: EmailTemplate) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setVersionDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditor = (template: EmailTemplate) => {
|
||||||
|
setEditingTemplateId(template.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
setEditingTemplateId(null);
|
||||||
|
fetchTemplates(); // Refresh list when returning
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category: EmailTemplateCategory): string => {
|
||||||
|
const colors: Record<EmailTemplateCategory, string> = {
|
||||||
|
INFLUENCE: 'blue',
|
||||||
|
MAP: 'green',
|
||||||
|
SYSTEM: 'purple',
|
||||||
|
};
|
||||||
|
return colors[category];
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<EmailTemplate> = [
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name, record) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Space>
|
||||||
|
<Text strong>{name}</Text>
|
||||||
|
{record.isSystem && <Tag color="blue">SYSTEM</Tag>}
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{record.key}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Category',
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category',
|
||||||
|
responsive: ['md'],
|
||||||
|
render: (category: EmailTemplateCategory) => (
|
||||||
|
<Tag color={getCategoryColor(category)}>{category}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Subject',
|
||||||
|
dataIndex: 'subjectLine',
|
||||||
|
key: 'subject',
|
||||||
|
responsive: ['lg'],
|
||||||
|
render: (subject: string) => (
|
||||||
|
<Text ellipsis style={{ maxWidth: 300 }}>
|
||||||
|
{subject.length > 50 ? `${subject.slice(0, 50)}...` : subject}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
responsive: ['md'],
|
||||||
|
render: (isActive: boolean) => (
|
||||||
|
<Badge status={isActive ? 'success' : 'default'} text={isActive ? 'Active' : 'Inactive'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Updated',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
key: 'updatedAt',
|
||||||
|
responsive: ['md'],
|
||||||
|
render: (date: string) => dayjs(date).fromNow(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
key: 'actions',
|
||||||
|
fixed: 'right',
|
||||||
|
width: 280,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => openEditor(record)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<MailOutlined />}
|
||||||
|
onClick={() => openTestEmailModal(record)}
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<HistoryOutlined />}
|
||||||
|
onClick={() => openVersionDrawer(record)}
|
||||||
|
>
|
||||||
|
Versions
|
||||||
|
</Button>
|
||||||
|
{!record.isSystem && (
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete template?"
|
||||||
|
description="This action cannot be undone."
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// If editing a template, show the editor instead of the list
|
||||||
|
if (editingTemplateId) {
|
||||||
|
return (
|
||||||
|
<EmailTemplateEditor
|
||||||
|
templateId={editingTemplateId}
|
||||||
|
onClose={closeEditor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Title level={2}>Email Templates</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
Manage email templates for campaigns, shifts, and system notifications
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or key..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
options={categoryOptions}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={activeFilter}
|
||||||
|
onChange={setActiveFilter}
|
||||||
|
options={activeOptions}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={templates}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.page,
|
||||||
|
pageSize: pagination.limit,
|
||||||
|
total: pagination.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `Total ${total} templates`,
|
||||||
|
pageSizeOptions: ['10', '20', '50', '100'],
|
||||||
|
}}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{selectedTemplate && (
|
||||||
|
<TestEmailModal
|
||||||
|
open={testModalOpen}
|
||||||
|
template={selectedTemplate}
|
||||||
|
onClose={() => {
|
||||||
|
setTestModalOpen(false);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
message.success('Test email sent successfully');
|
||||||
|
setTestModalOpen(false);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTemplate && (
|
||||||
|
<VersionHistoryDrawer
|
||||||
|
open={versionDrawerOpen}
|
||||||
|
templateId={selectedTemplate.id}
|
||||||
|
templateName={selectedTemplate.name}
|
||||||
|
onClose={() => {
|
||||||
|
setVersionDrawerOpen(false);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}}
|
||||||
|
onRollbackSuccess={() => {
|
||||||
|
fetchTemplates();
|
||||||
|
setVersionDrawerOpen(false);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
admin/src/pages/ExcalidrawPage.tsx
Normal file
127
admin/src/pages/ExcalidrawPage.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
||||||
|
import { ReloadOutlined, LinkOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
|
import type { ServicesStatus, ServicesConfig } from '@/types/api';
|
||||||
|
import { buildServiceUrl } from '@/lib/service-url';
|
||||||
|
|
||||||
|
export default function ExcalidrawPage() {
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
|
const [online, setOnline] = useState<boolean | null>(null);
|
||||||
|
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [statusRes, configRes] = await Promise.all([
|
||||||
|
api.get<ServicesStatus>('/services/status'),
|
||||||
|
api.get<ServicesConfig>('/services/config'),
|
||||||
|
]);
|
||||||
|
setOnline(statusRes.data.excalidraw.online);
|
||||||
|
setConfig(configRes.data);
|
||||||
|
} catch {
|
||||||
|
setOnline(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const serviceUrl = config
|
||||||
|
? buildServiceUrl(config.excalidrawSubdomain, config.domain, config.excalidrawPort)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const headerActions = useMemo(() => (
|
||||||
|
<Space>
|
||||||
|
<Badge
|
||||||
|
status={online === null ? 'processing' : online ? 'success' : 'error'}
|
||||||
|
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{serviceUrl && (
|
||||||
|
<Button
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
href={serviceUrl}
|
||||||
|
target="_blank"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Open in New Tab
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
), [online, handleRefresh, serviceUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageHeader({
|
||||||
|
title: 'Whiteboard',
|
||||||
|
actions: headerActions,
|
||||||
|
fullBleed: true
|
||||||
|
});
|
||||||
|
return () => setPageHeader(null);
|
||||||
|
}, [setPageHeader, headerActions]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="info"
|
||||||
|
title="Desktop Required"
|
||||||
|
subTitle="Excalidraw requires a desktop browser with a larger screen for optimal experience."
|
||||||
|
icon={<EditOutlined style={{ fontSize: 48 }} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!online || !serviceUrl) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Excalidraw Unavailable"
|
||||||
|
subTitle="Excalidraw is not running or could not be reached. Check that the Excalidraw container is started."
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={handleRefresh}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={serviceUrl}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'calc(100vh - 64px)',
|
||||||
|
border: 'none',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
title="Excalidraw Whiteboard"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -29,10 +29,11 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
||||||
import type { LandingPage, EditorMode, LandingPagesListResponse, LandingPagesListParams } from '@/types/api';
|
import LandingPageEditor from '@/components/landing-pages/LandingPageEditor';
|
||||||
|
import type { LandingPage, EditorMode, LandingPagesListResponse, LandingPagesListParams, AppOutletContext } from '@/types/api';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@ -44,10 +45,12 @@ const publishedOptions = [
|
|||||||
|
|
||||||
export default function LandingPagesPage() {
|
export default function LandingPagesPage() {
|
||||||
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const [pages, setPages] = useState<LandingPage[]>([]);
|
const [pages, setPages] = useState<LandingPage[]>([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [validating, setValidating] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
@ -55,9 +58,9 @@ export default function LandingPagesPage() {
|
|||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
|
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
|
||||||
|
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [settingsForm] = Form.useForm();
|
const [settingsForm] = Form.useForm();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
@ -103,7 +106,7 @@ export default function LandingPagesPage() {
|
|||||||
message.success('Page created');
|
message.success('Page created');
|
||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
navigate(`/app/pages/${data.id}/edit`);
|
setEditingPageId(data.id);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
@ -129,6 +132,24 @@ export default function LandingPagesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleValidateExports = async () => {
|
||||||
|
setValidating(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{validated: number; repaired: number; errors: Array<{pageId: string; slug: string; error: string}>}>('/pages/validate');
|
||||||
|
if (data.repaired > 0 || data.errors.length > 0) {
|
||||||
|
const msg = `Validated ${data.validated} pages: ${data.repaired} repaired`;
|
||||||
|
data.errors.length > 0 ? message.warning(`${msg}, ${data.errors.length} errors`) : message.success(msg);
|
||||||
|
fetchPages();
|
||||||
|
} else {
|
||||||
|
message.info(`Validated ${data.validated} pages - all OK`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to validate exports');
|
||||||
|
} finally {
|
||||||
|
setValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSettingsSave = async (values: Record<string, unknown>) => {
|
const handleSettingsSave = async (values: Record<string, unknown>) => {
|
||||||
if (!editingPage) return;
|
if (!editingPage) return;
|
||||||
try {
|
try {
|
||||||
@ -175,6 +196,7 @@ export default function LandingPagesPage() {
|
|||||||
mkdocsExportMode: page.mkdocsExportMode,
|
mkdocsExportMode: page.mkdocsExportMode,
|
||||||
mkdocsHideNav: page.mkdocsHideNav,
|
mkdocsHideNav: page.mkdocsHideNav,
|
||||||
mkdocsHideToc: page.mkdocsHideToc,
|
mkdocsHideToc: page.mkdocsHideToc,
|
||||||
|
mkdocsSkipExport: page.mkdocsSkipExport,
|
||||||
seoTitle: page.seoTitle,
|
seoTitle: page.seoTitle,
|
||||||
seoDescription: page.seoDescription,
|
seoDescription: page.seoDescription,
|
||||||
seoImage: page.seoImage,
|
seoImage: page.seoImage,
|
||||||
@ -250,7 +272,7 @@ export default function LandingPagesPage() {
|
|||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={() => navigate(`/app/pages/${record.id}/edit`)}
|
onClick={() => setEditingPageId(record.id)}
|
||||||
title={record.editorMode === 'CODE' ? 'Edit code' : 'Edit in builder'}
|
title={record.editorMode === 'CODE' ? 'Edit code' : 'Edit in builder'}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@ -289,6 +311,29 @@ export default function LandingPagesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Set fullBleed when editor is open to remove AppLayout padding/margin
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingPageId) {
|
||||||
|
setPageHeader({ fullBleed: true });
|
||||||
|
} else {
|
||||||
|
setPageHeader(null);
|
||||||
|
}
|
||||||
|
return () => setPageHeader(null);
|
||||||
|
}, [editingPageId, setPageHeader]);
|
||||||
|
|
||||||
|
// If editing a page, show the editor instead of the list
|
||||||
|
if (editingPageId) {
|
||||||
|
return (
|
||||||
|
<LandingPageEditor
|
||||||
|
pageId={editingPageId}
|
||||||
|
onClose={() => {
|
||||||
|
setEditingPageId(null);
|
||||||
|
fetchPages(); // Refresh table data
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||||
@ -315,6 +360,14 @@ export default function LandingPagesPage() {
|
|||||||
>
|
>
|
||||||
Sync Overrides
|
Sync Overrides
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<SyncOutlined spin={validating} />}
|
||||||
|
loading={validating}
|
||||||
|
onClick={handleValidateExports}
|
||||||
|
title="Validate MkDocs export files and repair if missing"
|
||||||
|
>
|
||||||
|
Validate Exports
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
@ -432,6 +485,18 @@ export default function LandingPagesPage() {
|
|||||||
|
|
||||||
<Divider>MkDocs Integration</Divider>
|
<Divider>MkDocs Integration</Divider>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="mkdocsSkipExport"
|
||||||
|
valuePropName="checked"
|
||||||
|
help="When enabled, this page will not be exported to MkDocs even when published. Use for pages that should only be accessible via /p/:slug."
|
||||||
|
>
|
||||||
|
<Checkbox>Skip MkDocs Export</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mkdocsSkipExport !== cur.mkdocsSkipExport}>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
!getFieldValue('mkdocsSkipExport') && (
|
||||||
|
<>
|
||||||
<Form.Item name="mkdocsPath" label="Override Path">
|
<Form.Item name="mkdocsPath" label="Override Path">
|
||||||
<Input placeholder="e.g. about.html" />
|
<Input placeholder="e.g. about.html" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -458,6 +523,10 @@ export default function LandingPagesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -27,12 +27,14 @@ import dayjs from 'dayjs';
|
|||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { buildServiceUrl } from '@/lib/service-url';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
import type {
|
import type {
|
||||||
ListmonkStatus,
|
ListmonkStatus,
|
||||||
ListmonkStats,
|
ListmonkStats,
|
||||||
ListmonkSyncResult,
|
ListmonkSyncResult,
|
||||||
ListmonkSyncAllResult,
|
ListmonkSyncAllResult,
|
||||||
|
ServicesConfig,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
@ -41,6 +43,7 @@ export default function ListmonkPage() {
|
|||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [status, setStatus] = useState<ListmonkStatus | null>(null);
|
const [status, setStatus] = useState<ListmonkStatus | null>(null);
|
||||||
const [stats, setStats] = useState<ListmonkStats | null>(null);
|
const [stats, setStats] = useState<ListmonkStats | null>(null);
|
||||||
|
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState<Record<string, boolean>>({});
|
const [syncing, setSyncing] = useState<Record<string, boolean>>({});
|
||||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||||
@ -67,11 +70,20 @@ export default function ListmonkPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<ServicesConfig>('/services/config');
|
||||||
|
setConfig(res.data);
|
||||||
|
} catch {
|
||||||
|
// Config fetch failed — leave null
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchAll = useCallback(async () => {
|
const fetchAll = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await Promise.all([fetchStatus(), fetchStats()]);
|
await Promise.all([fetchStatus(), fetchStats(), fetchConfig()]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [fetchStatus, fetchStats]);
|
}, [fetchStatus, fetchStats, fetchConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAll();
|
fetchAll();
|
||||||
@ -170,7 +182,9 @@ export default function ListmonkPage() {
|
|||||||
}
|
}
|
||||||
}, [iframeSrc]);
|
}, [iframeSrc]);
|
||||||
|
|
||||||
const listmonkAdminUrl = `//${window.location.hostname}:9001`;
|
const listmonkAdminUrl = config
|
||||||
|
? buildServiceUrl(config.listmonkSubdomain, config.domain, config.listmonkPort)
|
||||||
|
: null;
|
||||||
|
|
||||||
const headerActions = useMemo(() => (
|
const headerActions = useMemo(() => (
|
||||||
<Space>
|
<Space>
|
||||||
@ -195,6 +209,7 @@ export default function ListmonkPage() {
|
|||||||
>
|
>
|
||||||
Test Connection
|
Test Connection
|
||||||
</Button>
|
</Button>
|
||||||
|
{listmonkAdminUrl && (
|
||||||
<Button
|
<Button
|
||||||
icon={<LinkOutlined />}
|
icon={<LinkOutlined />}
|
||||||
href={listmonkAdminUrl}
|
href={listmonkAdminUrl}
|
||||||
@ -202,6 +217,7 @@ export default function ListmonkPage() {
|
|||||||
>
|
>
|
||||||
Open Listmonk
|
Open Listmonk
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
), [activeTab, syncing.test, handleTestConnection, listmonkAdminUrl, loadIframe]);
|
), [activeTab, syncing.test, handleTestConnection, listmonkAdminUrl, loadIframe]);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
123
admin/src/pages/MiniQRPage.tsx
Normal file
123
admin/src/pages/MiniQRPage.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
||||||
|
import { ReloadOutlined, LinkOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
|
import type { ServicesStatus, ServicesConfig } from '@/types/api';
|
||||||
|
import { buildServiceUrl } from '@/lib/service-url';
|
||||||
|
|
||||||
|
export default function MiniQRPage() {
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
|
const [online, setOnline] = useState<boolean | null>(null);
|
||||||
|
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [statusRes, configRes] = await Promise.all([
|
||||||
|
api.get<ServicesStatus>('/services/status'),
|
||||||
|
api.get<ServicesConfig>('/services/config'),
|
||||||
|
]);
|
||||||
|
setOnline(statusRes.data.miniqr.online);
|
||||||
|
setConfig(configRes.data);
|
||||||
|
} catch {
|
||||||
|
setOnline(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const serviceUrl = config
|
||||||
|
? buildServiceUrl(config.miniqrSubdomain, config.domain, config.miniqrPort)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const headerActions = useMemo(() => (
|
||||||
|
<Space>
|
||||||
|
<Badge
|
||||||
|
status={online === null ? 'processing' : online ? 'success' : 'error'}
|
||||||
|
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{serviceUrl && (
|
||||||
|
<Button
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
href={serviceUrl}
|
||||||
|
target="_blank"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Open in New Tab
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
), [online, handleRefresh, serviceUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageHeader({ title: 'QR Code Generator', actions: headerActions, fullBleed: true });
|
||||||
|
return () => setPageHeader(null);
|
||||||
|
}, [setPageHeader, headerActions]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="info"
|
||||||
|
title="Desktop Required"
|
||||||
|
subTitle="Mini QR requires a desktop browser with a larger screen."
|
||||||
|
icon={<QrcodeOutlined style={{ fontSize: 48 }} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!online || !serviceUrl) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Mini QR Unavailable"
|
||||||
|
subTitle="Mini QR is not running or could not be reached. Check that the Mini QR container is started."
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={handleRefresh}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={serviceUrl}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'calc(100vh - 64px)',
|
||||||
|
border: 'none',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
title="Mini QR Code Generator"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
admin/src/pages/ObservabilityPage.tsx
Normal file
353
admin/src/pages/ObservabilityPage.tsx
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Spin,
|
||||||
|
Radio,
|
||||||
|
Alert,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
AlertOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { buildServiceUrl } from '@/lib/service-url';
|
||||||
|
import { ServiceStatusCard } from '@/components/observability/ServiceStatusCard';
|
||||||
|
import { MetricsGrid } from '@/components/observability/MetricsGrid';
|
||||||
|
import { AlertsTable } from '@/components/observability/AlertsTable';
|
||||||
|
import { IframeErrorBoundary } from '@/components/observability/IframeErrorBoundary';
|
||||||
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
|
import type {
|
||||||
|
ObservabilityStatus,
|
||||||
|
MetricsSummary,
|
||||||
|
AlertsResponse,
|
||||||
|
ServiceStatus,
|
||||||
|
ServicesConfig,
|
||||||
|
} from '@/types/api';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(duration);
|
||||||
|
|
||||||
|
type TabKey = 'overview' | 'monitoring' | 'alerts';
|
||||||
|
|
||||||
|
export default function ObservabilityPage() {
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const [status, setStatus] = useState<ObservabilityStatus | null>(null);
|
||||||
|
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<MetricsSummary | null>(null);
|
||||||
|
const [alerts, setAlerts] = useState<AlertsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>('overview');
|
||||||
|
const [grafanaIframeSrc, setGrafanaIframeSrc] = useState<string | null>(null);
|
||||||
|
const [alertmanagerIframeSrc, setAlertmanagerIframeSrc] = useState<string | null>(null);
|
||||||
|
const grafanaInitialized = useRef(false);
|
||||||
|
const alertmanagerInitialized = useRef(false);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<ObservabilityStatus>('/observability/status');
|
||||||
|
setStatus(res.data);
|
||||||
|
} catch {
|
||||||
|
// Status fetch failed — leave null
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<ServicesConfig>('/services/config');
|
||||||
|
setConfig(res.data);
|
||||||
|
} catch {
|
||||||
|
// Config fetch failed — leave null
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchMetrics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<MetricsSummary>('/observability/metrics-summary');
|
||||||
|
setMetrics(res.data);
|
||||||
|
} catch {
|
||||||
|
// Metrics fetch may fail if Prometheus is offline
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAlerts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<AlertsResponse>('/observability/alerts');
|
||||||
|
setAlerts(res.data);
|
||||||
|
} catch {
|
||||||
|
// Alerts fetch may fail if Alertmanager is offline
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await Promise.all([fetchStatus(), fetchConfig(), fetchMetrics(), fetchAlerts()]);
|
||||||
|
setLoading(false);
|
||||||
|
}, [fetchStatus, fetchConfig, fetchMetrics, fetchAlerts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
// Lazy-load Grafana iframe
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'monitoring' && !grafanaInitialized.current && status?.grafana.online && config) {
|
||||||
|
try {
|
||||||
|
const baseUrl = buildServiceUrl(config.grafanaSubdomain, config.domain, config.grafanaPort);
|
||||||
|
const url = `${baseUrl}/d/changemaker-overview/changemaker-overview`;
|
||||||
|
setGrafanaIframeSrc(url);
|
||||||
|
grafanaInitialized.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to construct Grafana URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeTab, status, config]);
|
||||||
|
|
||||||
|
// Lazy-load Alertmanager iframe
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'alerts' && !alertmanagerInitialized.current && status?.alertmanager.online && config) {
|
||||||
|
try {
|
||||||
|
const url = buildServiceUrl(config.alertmanagerSubdomain, config.domain, config.alertmanagerPort);
|
||||||
|
setAlertmanagerIframeSrc(url);
|
||||||
|
alertmanagerInitialized.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to construct Alertmanager URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeTab, status, config]);
|
||||||
|
|
||||||
|
// Set page header with tab switcher
|
||||||
|
useEffect(() => {
|
||||||
|
setPageHeader({
|
||||||
|
title: 'Observability',
|
||||||
|
subtitle: 'System monitoring, metrics, and alerts',
|
||||||
|
actions: (
|
||||||
|
<Space>
|
||||||
|
<Radio.Group
|
||||||
|
value={activeTab}
|
||||||
|
onChange={e => setActiveTab(e.target.value)}
|
||||||
|
buttonStyle="solid"
|
||||||
|
>
|
||||||
|
<Radio.Button value="overview">
|
||||||
|
<DashboardOutlined /> Overview
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value="monitoring">
|
||||||
|
<LineChartOutlined /> Monitoring
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value="alerts">
|
||||||
|
<AlertOutlined /> Alerts
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={fetchAll}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{status?.grafana.online && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
href={status.grafana.url}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Open Grafana
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => setPageHeader(null);
|
||||||
|
}, [setPageHeader, activeTab, fetchAll, status]);
|
||||||
|
|
||||||
|
const servicesOnline = status
|
||||||
|
? Object.values(status).filter((s: ServiceStatus) => s.online).length
|
||||||
|
: 0;
|
||||||
|
const allOffline = servicesOnline === 0;
|
||||||
|
|
||||||
|
const renderOverviewTab = () => (
|
||||||
|
<>
|
||||||
|
{allOffline && (
|
||||||
|
<Alert
|
||||||
|
message="Monitoring services are offline"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Start monitoring services with: <code>docker compose --profile monitoring up -d</code>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Service Status Cards */}
|
||||||
|
<Card title="Service Status" style={{ marginBottom: 16 }}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="Prometheus"
|
||||||
|
online={status?.prometheus?.online || false}
|
||||||
|
url={status?.prometheus?.url || ''}
|
||||||
|
icon={<DashboardOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="Grafana"
|
||||||
|
online={status?.grafana?.online || false}
|
||||||
|
url={status?.grafana?.url || ''}
|
||||||
|
icon={<LineChartOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="Alertmanager"
|
||||||
|
online={status?.alertmanager?.online || false}
|
||||||
|
url={status?.alertmanager?.url || ''}
|
||||||
|
icon={<AlertOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="cAdvisor"
|
||||||
|
online={status?.cadvisor?.online || false}
|
||||||
|
url={status?.cadvisor?.url || ''}
|
||||||
|
icon={<DashboardOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="Node Exporter"
|
||||||
|
online={status?.nodeExporter?.online || false}
|
||||||
|
url={status?.nodeExporter?.url || ''}
|
||||||
|
icon={<DashboardOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="Redis Exporter"
|
||||||
|
online={status?.redisExporter?.online || false}
|
||||||
|
url={status?.redisExporter?.url || ''}
|
||||||
|
icon={<DashboardOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="Gotify"
|
||||||
|
online={status?.gotify?.online || false}
|
||||||
|
url={status?.gotify?.url || ''}
|
||||||
|
icon={<AlertOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Key Metrics */}
|
||||||
|
{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}
|
||||||
|
|
||||||
|
{/* Active Alerts */}
|
||||||
|
{!allOffline && alerts && (
|
||||||
|
<AlertsTable alerts={alerts.alerts || []} loading={loading} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMonitoringTab = () => {
|
||||||
|
if (!status?.grafana.online) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="Grafana is offline"
|
||||||
|
description="Start monitoring services to view dashboards"
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IframeErrorBoundary serviceName="Grafana">
|
||||||
|
<Card styles={{ body: { padding: 0 } }}>
|
||||||
|
{grafanaIframeSrc ? (
|
||||||
|
<iframe
|
||||||
|
src={grafanaIframeSrc}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'calc(100vh - 200px)',
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
title="Grafana Dashboard"
|
||||||
|
aria-label="Embedded Grafana application overview dashboard"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Spin />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</IframeErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAlertsTab = () => {
|
||||||
|
if (!status?.alertmanager.online) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="Alertmanager is offline"
|
||||||
|
description="Start monitoring services to manage alerts"
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IframeErrorBoundary serviceName="Alertmanager">
|
||||||
|
<Card styles={{ body: { padding: 0 } }}>
|
||||||
|
{alertmanagerIframeSrc ? (
|
||||||
|
<iframe
|
||||||
|
src={alertmanagerIframeSrc}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'calc(100vh - 200px)',
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
title="Alertmanager"
|
||||||
|
aria-label="Embedded Alertmanager alert management interface"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Spin />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</IframeErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{activeTab === 'overview' && renderOverviewTab()}
|
||||||
|
{activeTab === 'monitoring' && renderMonitoringTab()}
|
||||||
|
{activeTab === 'alerts' && renderAlertsTab()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,17 +2,70 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, Popconfirm, App,
|
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, Popconfirm, App,
|
||||||
|
Modal, Checkbox, Select,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CloudServerOutlined, SyncOutlined, DeleteOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
CloudServerOutlined, SyncOutlined, DeleteOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||||
RocketOutlined, CopyOutlined,
|
RocketOutlined, CopyOutlined, EyeOutlined, EyeInvisibleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
import type { PangolinStatus, PangolinConfig, PangolinResource } from '@/types/api';
|
import type { PangolinStatus, PangolinConfig, PangolinResource, PangolinNewtStatus, PangolinSite, PangolinExitNode } from '@/types/api';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
// Simple text sanitizer for external API data (defense-in-depth)
|
||||||
|
const sanitizeText = (text: string | undefined): string => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.replace(/[<>'"&]/g, (char) => {
|
||||||
|
const escapeMap: Record<string, string> = {
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
"'": ''',
|
||||||
|
'"': '"',
|
||||||
|
'&': '&',
|
||||||
|
};
|
||||||
|
return escapeMap[char] || char;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to calculate next available subnet from existing sites
|
||||||
|
const suggestNextSubnet = (sites: PangolinSite[]): string => {
|
||||||
|
if (!sites || sites.length === 0) {
|
||||||
|
return '100.90.128.0/24'; // Default first subnet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract subnet or address field from existing sites (e.g., "100.90.128.2/24")
|
||||||
|
const subnets = sites
|
||||||
|
.map(s => s.address || s.subnet)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (subnets.length === 0) {
|
||||||
|
return '100.90.128.0/24';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the last subnet to get the network octet
|
||||||
|
const lastSubnet = subnets[subnets.length - 1];
|
||||||
|
if (!lastSubnet) {
|
||||||
|
return '100.90.128.3/24';
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = lastSubnet.match(/^100\.90\.128\.(\d+)\/24$/);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
const lastOctet = parseInt(match[1], 10);
|
||||||
|
const nextOctet = lastOctet + 1;
|
||||||
|
|
||||||
|
if (nextOctet <= 255) {
|
||||||
|
return `100.90.128.${nextOctet}/24`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if parsing fails
|
||||||
|
return '100.90.128.3/24';
|
||||||
|
};
|
||||||
|
|
||||||
export default function PangolinPage() {
|
export default function PangolinPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
@ -23,6 +76,25 @@ export default function PangolinPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [setupResult, setSetupResult] = useState<Record<string, unknown> | null>(null);
|
const [setupResult, setSetupResult] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
const [editingResource, setEditingResource] = useState<PangolinResource | null>(null);
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [newtStatus, setNewtStatus] = useState<PangolinNewtStatus | null>(null);
|
||||||
|
const [newtLoading, setNewtLoading] = useState(false);
|
||||||
|
const [restartLoading, setRestartLoading] = useState(false);
|
||||||
|
const [suggestedSubnet, setSuggestedSubnet] = useState<string>('100.90.128.3/24');
|
||||||
|
const [setupForm] = Form.useForm();
|
||||||
|
const [exitNodes, setExitNodes] = useState<PangolinExitNode[]>([]);
|
||||||
|
const [exitNodesLoading, setExitNodesLoading] = useState(false);
|
||||||
|
const [showCredentials, setShowCredentials] = useState(false);
|
||||||
|
const [resourceDefinitions, setResourceDefinitions] = useState<Array<{
|
||||||
|
name: string;
|
||||||
|
subdomain: string;
|
||||||
|
fullDomain: string;
|
||||||
|
port: number;
|
||||||
|
container: string;
|
||||||
|
required: boolean;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'Tunnel Management' });
|
setPageHeader({ title: 'Tunnel Management' });
|
||||||
@ -32,16 +104,18 @@ export default function PangolinPage() {
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statusRes, configRes] = await Promise.all([
|
const [statusRes, configRes, resourceDefsRes] = await Promise.all([
|
||||||
api.get<PangolinStatus>('/api/pangolin/status'),
|
api.get<PangolinStatus>('/pangolin/status'),
|
||||||
api.get<PangolinConfig>('/api/pangolin/config'),
|
api.get<PangolinConfig>('/pangolin/config'),
|
||||||
|
api.get<{ domain: string; resources: Array<{ name: string; subdomain: string; fullDomain: string; port: number; container: string; required: boolean }> }>('/pangolin/resource-definitions'),
|
||||||
]);
|
]);
|
||||||
setStatus(statusRes.data);
|
setStatus(statusRes.data);
|
||||||
setConfig(configRes.data);
|
setConfig(configRes.data);
|
||||||
|
setResourceDefinitions(resourceDefsRes.data.resources);
|
||||||
|
|
||||||
if (statusRes.data.configured) {
|
if (statusRes.data.configured) {
|
||||||
try {
|
try {
|
||||||
const resourcesRes = await api.get<{ resources: PangolinResource[] }>('/api/pangolin/resources');
|
const resourcesRes = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');
|
||||||
setResources(resourcesRes.data.resources);
|
setResources(resourcesRes.data.resources);
|
||||||
} catch {
|
} catch {
|
||||||
// Resources may not load if site isn't set up
|
// Resources may not load if site isn't set up
|
||||||
@ -56,26 +130,153 @@ export default function PangolinPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
const handleSetup = async (values: { siteName?: string }) => {
|
const fetchNewtStatus = useCallback(async () => {
|
||||||
|
if (!status?.newtConfigured) return; // Don't check if not configured
|
||||||
|
|
||||||
|
setNewtLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<PangolinNewtStatus>('/pangolin/newt-status');
|
||||||
|
setNewtStatus(res.data);
|
||||||
|
} catch {
|
||||||
|
// Silently fail - status card will show "unknown"
|
||||||
|
} finally {
|
||||||
|
setNewtLoading(false);
|
||||||
|
}
|
||||||
|
}, [status?.newtConfigured]);
|
||||||
|
|
||||||
|
// Fetch newt status after main data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.newtConfigured) {
|
||||||
|
fetchNewtStatus();
|
||||||
|
}
|
||||||
|
}, [status?.newtConfigured, fetchNewtStatus]);
|
||||||
|
|
||||||
|
// Fetch existing sites and auto-suggest next subnet
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.configured && !config?.siteId) {
|
||||||
|
api.get<{ sites: PangolinSite[] }>('/pangolin/sites')
|
||||||
|
.then(res => {
|
||||||
|
const suggested = suggestNextSubnet(res.data.sites);
|
||||||
|
setSuggestedSubnet(suggested);
|
||||||
|
setupForm.setFieldsValue({ subnet: suggested });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore errors, use default suggestion
|
||||||
|
setupForm.setFieldsValue({ subnet: '100.90.128.3/24' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [status?.configured, config?.siteId, setupForm]);
|
||||||
|
|
||||||
|
// Fetch available exit nodes for site creation
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.configured && !config?.siteId) {
|
||||||
|
setExitNodesLoading(true);
|
||||||
|
api.get<{ exitNodes: PangolinExitNode[] }>('/pangolin/exit-nodes')
|
||||||
|
.then(res => {
|
||||||
|
setExitNodes(res.data.exitNodes);
|
||||||
|
|
||||||
|
// Auto-select if only one ONLINE exit node available
|
||||||
|
const onlineNodes = res.data.exitNodes.filter(n => n.online);
|
||||||
|
if (onlineNodes.length === 1 && onlineNodes[0]) {
|
||||||
|
setupForm.setFieldsValue({ exitNodeId: onlineNodes[0].exitNodeId });
|
||||||
|
} else if (onlineNodes.length > 1 && onlineNodes.length < res.data.exitNodes.length) {
|
||||||
|
// Some nodes are offline, but others are available
|
||||||
|
message.info('Some exit nodes are offline. Select from available nodes.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Exit nodes not available - this is OK for self-hosted setups
|
||||||
|
// Don't show error messages, just leave the list empty
|
||||||
|
})
|
||||||
|
.finally(() => setExitNodesLoading(false));
|
||||||
|
}
|
||||||
|
}, [status?.configured, config?.siteId, setupForm, message]);
|
||||||
|
|
||||||
|
const handleSetup = async (values: { siteName?: string; subnet?: string; exitNodeId?: string }) => {
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/api/pangolin/setup', { siteName: values.siteName });
|
const res = await api.post('/pangolin/setup', {
|
||||||
setSetupResult(res.data);
|
siteName: values.siteName,
|
||||||
|
subnet: values.subnet,
|
||||||
|
exitNodeId: values.exitNodeId,
|
||||||
|
});
|
||||||
|
setSetupResult(res.data ?? {});
|
||||||
message.success('Setup complete! See credentials below.');
|
message.success('Setup complete! See credentials below.');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Setup failed';
|
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ?? 'Setup failed';
|
||||||
message.error(msg);
|
message.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ created: number; skipped: number; errors: number }>('/api/pangolin/sync');
|
const res = await api.post<{
|
||||||
message.success(`Sync complete: ${res.data.created} created, ${res.data.skipped} skipped`);
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
warnings: number;
|
||||||
|
errors: number;
|
||||||
|
details?: {
|
||||||
|
created: string[];
|
||||||
|
updated: string[];
|
||||||
|
skipped: string[];
|
||||||
|
warnings: string[];
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
}>('/pangolin/sync');
|
||||||
|
|
||||||
|
// Enhanced success message with all details
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (res.data.created > 0) parts.push(`${res.data.created} created`);
|
||||||
|
if (res.data.updated > 0) parts.push(`${res.data.updated} updated`);
|
||||||
|
if (res.data.skipped > 0) parts.push(`${res.data.skipped} skipped`);
|
||||||
|
if (res.data.warnings > 0) parts.push(`${res.data.warnings} warnings`);
|
||||||
|
if (res.data.errors > 0) parts.push(`${res.data.errors} errors`);
|
||||||
|
|
||||||
|
const summary = parts.join(', ') || 'No changes';
|
||||||
|
message.success(`Sync complete: ${summary}`);
|
||||||
|
|
||||||
|
// Show detailed warnings if any
|
||||||
|
if (res.data.details?.warnings && res.data.details.warnings.length > 0) {
|
||||||
|
Modal.info({
|
||||||
|
title: 'Sync Warnings',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>Some resources were skipped:</p>
|
||||||
|
<ul>
|
||||||
|
{res.data.details.warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
width: 600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show detailed errors if any
|
||||||
|
if (res.data.details?.errors && res.data.details.errors.length > 0) {
|
||||||
|
Modal.error({
|
||||||
|
title: 'Sync Errors',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>Some resources failed to sync:</p>
|
||||||
|
<ul>
|
||||||
|
{res.data.details.errors.map((e, i) => (
|
||||||
|
<li key={i}>{e}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
width: 600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Sync failed';
|
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Sync failed';
|
||||||
@ -87,7 +288,7 @@ export default function PangolinPage() {
|
|||||||
|
|
||||||
const handleDeleteResource = async (resourceId: string) => {
|
const handleDeleteResource = async (resourceId: string) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/pangolin/resource/${resourceId}`);
|
await api.delete(`/pangolin/resource/${resourceId}`);
|
||||||
message.success('Resource deleted');
|
message.success('Resource deleted');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch {
|
} catch {
|
||||||
@ -95,6 +296,55 @@ export default function PangolinPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditResource = (resource: PangolinResource) => {
|
||||||
|
setEditingResource(resource);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
name: resource.name,
|
||||||
|
ssl: resource.ssl ?? true,
|
||||||
|
active: resource.active ?? true,
|
||||||
|
blockAccess: resource.blockAccess ?? false,
|
||||||
|
proxyPort: resource.proxyPort ?? 80,
|
||||||
|
protocol: resource.protocol ?? 'http',
|
||||||
|
});
|
||||||
|
setEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateResource = async (values: Record<string, unknown>) => {
|
||||||
|
if (!editingResource) return;
|
||||||
|
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/pangolin/resource/${editingResource.resourceId}`, values);
|
||||||
|
message.success('Resource updated');
|
||||||
|
setEditModalVisible(false);
|
||||||
|
setEditingResource(null);
|
||||||
|
editForm.resetFields();
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to update resource');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestartNewt = async () => {
|
||||||
|
setRestartLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/pangolin/newt-restart');
|
||||||
|
message.success('Newt container restarted successfully. Checking status...');
|
||||||
|
|
||||||
|
// Poll status after restart (container takes a few seconds to start)
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchNewtStatus();
|
||||||
|
}, 3000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to restart container';
|
||||||
|
message.error(msg);
|
||||||
|
} finally {
|
||||||
|
setRestartLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||||
}
|
}
|
||||||
@ -120,10 +370,18 @@ export default function PangolinPage() {
|
|||||||
<Descriptions.Item label="API URL">
|
<Descriptions.Item label="API URL">
|
||||||
<Text copyable>{config?.pangolinApiUrl || 'Not set'}</Text>
|
<Text copyable>{config?.pangolinApiUrl || 'Not set'}</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Newt">
|
<Descriptions.Item label="Newt Container">
|
||||||
{status?.newtConfigured
|
{newtLoading ? (
|
||||||
? <Tag color="success">Configured</Tag>
|
<Spin size="small" />
|
||||||
: <Tag color="warning">Not configured</Tag>}
|
) : newtStatus?.ready ? (
|
||||||
|
<Tag icon={<CheckCircleOutlined />} color="success">Ready</Tag>
|
||||||
|
) : newtStatus?.containerRunning ? (
|
||||||
|
<Tag color="warning">Running (Not configured)</Tag>
|
||||||
|
) : status?.newtConfigured ? (
|
||||||
|
<Tag color="error">Stopped</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="default">Not configured</Tag>
|
||||||
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Organization ID">
|
<Descriptions.Item label="Organization ID">
|
||||||
<Text code>{config?.orgId || 'Not set'}</Text>
|
<Text code>{config?.orgId || 'Not set'}</Text>
|
||||||
@ -136,26 +394,211 @@ export default function PangolinPage() {
|
|||||||
|
|
||||||
{/* Setup Wizard — shown when not configured or no site */}
|
{/* Setup Wizard — shown when not configured or no site */}
|
||||||
{(!isConfigured || !config?.siteId) && (
|
{(!isConfigured || !config?.siteId) && (
|
||||||
<Card title={<><RocketOutlined /> Initial Setup</>}>
|
<Card title={<><RocketOutlined /> Setup Instructions</>}>
|
||||||
{!isConfigured ? (
|
{!isConfigured ? (
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="Pangolin Not Configured"
|
message="Step 1: Configure Pangolin Credentials"
|
||||||
description="Set PANGOLIN_API_URL, PANGOLIN_API_KEY, and PANGOLIN_ORG_ID in your .env file, then restart the API container."
|
description={
|
||||||
|
<div>
|
||||||
|
<Paragraph>Add the following variables to your <Text code>.env</Text> file:</Paragraph>
|
||||||
|
<pre style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6, fontSize: 12 }}>
|
||||||
|
PANGOLIN_API_URL=https://api.bnkserve.org/v1{'\n'}
|
||||||
|
PANGOLIN_API_KEY=your_api_key_here{'\n'}
|
||||||
|
PANGOLIN_ORG_ID=your_org_id
|
||||||
|
</pre>
|
||||||
|
<Paragraph>Then restart the API container:</Paragraph>
|
||||||
|
<pre style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6, fontSize: 12 }}>
|
||||||
|
docker compose restart api
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Form layout="inline" onFinish={handleSetup}>
|
<>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="Manual Setup Instructions"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Paragraph><strong>Follow these steps to set up your Pangolin tunnel:</strong></Paragraph>
|
||||||
|
|
||||||
|
<Paragraph strong style={{ marginTop: 16 }}>Step 1: Create a Site in Pangolin</Paragraph>
|
||||||
|
<ol style={{ marginLeft: 20 }}>
|
||||||
|
<li>Log in to your Pangolin dashboard at <Text code>{config?.pangolinApiUrl?.replace('/v1', '') || 'https://api.bnkserve.org'}</Text></li>
|
||||||
|
<li>Navigate to <strong>Sites</strong> → <strong>Create New Site</strong></li>
|
||||||
|
<li>Choose site type: <strong>Newt</strong></li>
|
||||||
|
<li>Enter a site name (e.g., <Text code>changemaker-{config?.domain || 'cmlite.org'}</Text>)</li>
|
||||||
|
<li>Choose a subnet (suggested: <Text code>{suggestedSubnet}</Text>)</li>
|
||||||
|
<li>Select an exit node (if using multi-node setup)</li>
|
||||||
|
<li>Click <strong>Create Site</strong></li>
|
||||||
|
<li>Copy the generated <strong>Newt ID</strong> and <strong>Newt Secret</strong> credentials</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<Paragraph strong style={{ marginTop: 16 }}>Step 2: Update Your .env File</Paragraph>
|
||||||
|
<Paragraph>Add these variables to your <Text code>.env</Text> file with the credentials from Step 1:</Paragraph>
|
||||||
|
<pre style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6, fontSize: 12 }}>
|
||||||
|
PANGOLIN_SITE_ID=your_site_id_here{'\n'}
|
||||||
|
PANGOLIN_NEWT_ID=your_newt_id_here{'\n'}
|
||||||
|
PANGOLIN_NEWT_SECRET=your_newt_secret_here{'\n'}
|
||||||
|
PANGOLIN_ENDPOINT={config?.pangolinEndpoint || 'https://api.bnkserve.org'}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<Paragraph strong style={{ marginTop: 16 }}>Step 3: Start the Newt Container</Paragraph>
|
||||||
|
<pre style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6, fontSize: 12 }}>
|
||||||
|
docker compose up -d newt
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<Paragraph strong style={{ marginTop: 16 }}>Step 4: Create Public HTTP Resources</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
In your Pangolin dashboard, navigate to <strong>Resources</strong> → <strong>Public</strong> → <strong>Add Resource</strong>.
|
||||||
|
Create an HTTP resource for each service below:
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={resourceDefinitions}
|
||||||
|
rowKey="fullDomain"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
style={{ marginTop: 12, marginBottom: 12 }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Service Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string, record) => (
|
||||||
|
<Space>
|
||||||
|
<Text strong>{name}</Text>
|
||||||
|
{record.required && <Tag color="red">Required</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Domain',
|
||||||
|
dataIndex: 'fullDomain',
|
||||||
|
key: 'fullDomain',
|
||||||
|
render: (domain: string) => <Text code copyable>{domain}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Target',
|
||||||
|
key: 'target',
|
||||||
|
render: () => <Text code>nginx:80</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Container',
|
||||||
|
dataIndex: 'container',
|
||||||
|
key: 'container',
|
||||||
|
render: (container: string) => <Text type="secondary" style={{ fontSize: 12 }}>{container}</Text>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="Resource Configuration"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p><strong>For each resource:</strong></p>
|
||||||
|
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
|
||||||
|
<li><strong>Protocol:</strong> HTTPS (SSL enabled)</li>
|
||||||
|
<li><strong>Target:</strong> nginx (all services route through nginx on port 80)</li>
|
||||||
|
<li><strong>Authentication:</strong> None (set as "Not Protected" for public access)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConfigured && (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
message="Alternative: API-Based Setup"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Paragraph>
|
||||||
|
You can also use the form below to create a site and resources via the API.
|
||||||
|
This still requires manually updating your <Text code>.env</Text> file with the credentials.
|
||||||
|
</Paragraph>
|
||||||
|
<div style={{ maxWidth: 600, marginTop: 16 }}>
|
||||||
|
<Form form={setupForm} layout="vertical" onFinish={handleSetup}>
|
||||||
<Form.Item name="siteName" label="Site Name">
|
<Form.Item name="siteName" label="Site Name">
|
||||||
<Input placeholder={`changemaker-${config?.domain || 'cmlite.org'}`} style={{ width: 300 }} />
|
<Input placeholder={`changemaker-${config?.domain || 'cmlite.org'}`} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="subnet"
|
||||||
|
label="Subnet (CIDR notation)"
|
||||||
|
tooltip="Network subnet for this site. Auto-suggested based on existing allocations. You can override if needed."
|
||||||
|
initialValue={suggestedSubnet}
|
||||||
|
>
|
||||||
|
<Input placeholder="100.90.128.3/24" />
|
||||||
|
</Form.Item>
|
||||||
|
{/* Only show exit node field if exit nodes are available */}
|
||||||
|
{exitNodes.length > 0 && (
|
||||||
|
<Form.Item
|
||||||
|
name="exitNodeId"
|
||||||
|
label="Exit Node (optional)"
|
||||||
|
tooltip="Network exit point for tunneled traffic. Only needed for multi-node Pangolin setups."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Select an exit node (optional)"
|
||||||
|
allowClear
|
||||||
|
loading={exitNodesLoading}
|
||||||
|
disabled={exitNodesLoading}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="children"
|
||||||
|
>
|
||||||
|
{exitNodes.map(node => (
|
||||||
|
<Select.Option
|
||||||
|
key={node.exitNodeId}
|
||||||
|
value={node.exitNodeId}
|
||||||
|
disabled={!node.online}
|
||||||
|
>
|
||||||
|
{sanitizeText(node.name)}
|
||||||
|
{node.location && ` (${sanitizeText(node.location)})`}
|
||||||
|
{!node.online && ' [OFFLINE]'}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show help text if no exit nodes available */}
|
||||||
|
{exitNodes.length === 0 && (
|
||||||
|
<Alert
|
||||||
|
message="Exit Nodes Not Required"
|
||||||
|
description="Your self-hosted Pangolin setup doesn't use separate exit nodes. You can proceed with site creation without selecting one."
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" loading={actionLoading} icon={<RocketOutlined />}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={actionLoading}
|
||||||
|
icon={<RocketOutlined />}
|
||||||
|
>
|
||||||
Create Site + Resources
|
Create Site + Resources
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{setupResult && (
|
{setupResult && (
|
||||||
@ -163,25 +606,73 @@ export default function PangolinPage() {
|
|||||||
type="success"
|
type="success"
|
||||||
showIcon
|
showIcon
|
||||||
closable
|
closable
|
||||||
onClose={() => setSetupResult(null)}
|
onClose={() => {
|
||||||
|
setSetupResult(null);
|
||||||
|
setShowCredentials(false);
|
||||||
|
}}
|
||||||
message="Setup Complete"
|
message="Setup Complete"
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<Paragraph>Add these to your <Text code>.env</Text> file:</Paragraph>
|
<Paragraph>
|
||||||
<pre style={{ background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 6, fontSize: 12, overflow: 'auto' }}>
|
<strong>Step 1:</strong> Add these to your <Text code>.env</Text> file:
|
||||||
{((setupResult as Record<string, unknown>).instructions as string[] || []).slice(1).join('\n')}
|
</Paragraph>
|
||||||
</pre>
|
<Space style={{ marginBottom: 12 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={showCredentials ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||||
|
onClick={() => setShowCredentials(!showCredentials)}
|
||||||
|
>
|
||||||
|
{showCredentials ? 'Hide' : 'Show'} Credentials
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<CopyOutlined />}
|
icon={<CopyOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = ((setupResult as Record<string, unknown>).instructions as string[] || []).slice(1).join('\n');
|
const text = ((setupResult as Record<string, unknown>).instructions as string[] || []).slice(1, -1).join('\n');
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
message.success('Copied to clipboard');
|
message.success('Copied to clipboard');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy
|
Copy to Clipboard
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
setSetupResult(null);
|
||||||
|
setShowCredentials(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Credentials
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{showCredentials && (
|
||||||
|
<pre style={{ background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 6, fontSize: 12, overflow: 'auto' }}>
|
||||||
|
{((setupResult as Record<string, unknown>).instructions as string[] || []).slice(1, -1).join('\n')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paragraph style={{ marginTop: 16 }}>
|
||||||
|
<strong>Step 2:</strong> After updating .env, restart the Newt container:
|
||||||
|
</Paragraph>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
loading={restartLoading}
|
||||||
|
onClick={handleRestartNewt}
|
||||||
|
>
|
||||||
|
Restart Newt Container
|
||||||
|
</Button>
|
||||||
|
{newtStatus?.ready && (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
message="Tunnel Ready!"
|
||||||
|
description="The Newt container is running and connected to Pangolin."
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
@ -190,61 +681,143 @@ export default function PangolinPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource Management — shown when configured */}
|
{/* Tunnel Management — shown when configured */}
|
||||||
{isConfigured && config?.siteId && (
|
{isConfigured && config?.siteId && (
|
||||||
<Card
|
<Card
|
||||||
title="Tunnel Resources"
|
title="Tunnel Management"
|
||||||
extra={
|
extra={
|
||||||
<Button icon={<SyncOutlined />} loading={actionLoading} onClick={handleSync}>
|
<Button
|
||||||
Sync Resources
|
icon={<SyncOutlined />}
|
||||||
|
loading={restartLoading}
|
||||||
|
onClick={handleRestartNewt}
|
||||||
|
title="Restart Newt Container"
|
||||||
|
>
|
||||||
|
Restart Newt Container
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
message="Tunnel Active"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Paragraph>
|
||||||
|
Your Pangolin tunnel is configured and running. Create public HTTP resources for your services in the Pangolin dashboard.
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph style={{ marginTop: 12, marginBottom: 0 }}>
|
||||||
|
<a href={`${config?.pangolinApiUrl?.replace('/v1', '') || 'https://api.bnkserve.org'}/the-bunker-operations/settings/resources/proxy`} target="_blank" rel="noopener noreferrer">
|
||||||
|
Open Pangolin Dashboard →
|
||||||
|
</a>
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Paragraph strong style={{ marginTop: 16 }}>Resources to Create</Paragraph>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
Create these public HTTP resources in the Pangolin dashboard (Resources → Public → Add Resource):
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
dataSource={resources}
|
dataSource={resourceDefinitions}
|
||||||
rowKey="resourceId"
|
rowKey="fullDomain"
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Service Name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
|
render: (name: string, record) => (
|
||||||
|
<Space>
|
||||||
|
<Text strong>{name}</Text>
|
||||||
|
{record.required && <Tag color="red">Required</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Domain',
|
title: 'Domain',
|
||||||
key: 'domain',
|
dataIndex: 'fullDomain',
|
||||||
render: (_, r: PangolinResource) => (
|
key: 'fullDomain',
|
||||||
<Text copyable>{r.fullDomain || r.subdomain || '(root)'}</Text>
|
render: (domain: string) => <Text code copyable>{domain}</Text>,
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'SSL',
|
title: 'Target',
|
||||||
dataIndex: 'ssl',
|
key: 'target',
|
||||||
key: 'ssl',
|
render: () => <Text code>nginx:80</Text>,
|
||||||
render: (ssl: boolean) => ssl ? <Tag color="green">Yes</Tag> : <Tag>No</Tag>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Active',
|
title: 'Container',
|
||||||
dataIndex: 'active',
|
dataIndex: 'container',
|
||||||
key: 'active',
|
key: 'container',
|
||||||
render: (active: boolean) => active !== false
|
render: (container: string) => <Text type="secondary" style={{ fontSize: 12 }}>{container}</Text>,
|
||||||
? <Tag color="success">Active</Tag>
|
|
||||||
: <Tag color="error">Inactive</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Actions',
|
|
||||||
key: 'actions',
|
|
||||||
render: (_, r: PangolinResource) => (
|
|
||||||
<Popconfirm title="Delete this resource?" onConfirm={() => handleDeleteResource(r.resourceId)}>
|
|
||||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
|
||||||
</Popconfirm>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="Configuration Details"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p><strong>For each resource:</strong></p>
|
||||||
|
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
|
||||||
|
<li><strong>Protocol:</strong> HTTPS (SSL enabled)</li>
|
||||||
|
<li><strong>Target:</strong> nginx (all services route through nginx on port 80)</li>
|
||||||
|
<li><strong>Authentication:</strong> None (set as "Not Protected" for public access)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Resource Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Edit Resource"
|
||||||
|
open={editModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditModalVisible(false);
|
||||||
|
setEditingResource(null);
|
||||||
|
editForm.resetFields();
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical" onFinish={handleUpdateResource}>
|
||||||
|
<Form.Item label="Name" name="name" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Protocol" name="protocol">
|
||||||
|
<Input placeholder="http" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Proxy Port" name="proxyPort">
|
||||||
|
<Input type="number" placeholder="80" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="ssl" valuePropName="checked">
|
||||||
|
<Checkbox>Enable SSL</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="active" valuePropName="checked">
|
||||||
|
<Checkbox>Active</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="blockAccess" valuePropName="checked">
|
||||||
|
<Checkbox>Block Access</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" loading={actionLoading}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setEditModalVisible(false)}>Cancel</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Tag,
|
Tag,
|
||||||
Space,
|
Space,
|
||||||
Modal,
|
|
||||||
Form,
|
Form,
|
||||||
Switch,
|
Switch,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@ -21,6 +20,10 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
Card,
|
Card,
|
||||||
Statistic,
|
Statistic,
|
||||||
|
Tabs,
|
||||||
|
Segmented,
|
||||||
|
Checkbox,
|
||||||
|
Alert,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -47,8 +50,14 @@ import type {
|
|||||||
ShiftStats,
|
ShiftStats,
|
||||||
ShiftStatus,
|
ShiftStatus,
|
||||||
Cut,
|
Cut,
|
||||||
|
CreateShiftSeriesInput,
|
||||||
|
CalendarData,
|
||||||
|
EditMode,
|
||||||
|
RecurrenceFrequency,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import { SHIFT_STATUS_COLORS, SHIFT_STATUS_LABELS, SIGNUP_SOURCE_COLORS } from '@/types/api';
|
import { SHIFT_STATUS_COLORS, SHIFT_STATUS_LABELS, SIGNUP_SOURCE_COLORS } from '@/types/api';
|
||||||
|
import EditModeModal from '@/components/shifts/EditModeModal';
|
||||||
|
import ShiftsCalendar from '@/components/shifts/ShiftsCalendar';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -57,6 +66,16 @@ const statusOptions = Object.entries(SHIFT_STATUS_LABELS).map(([value, label]) =
|
|||||||
label,
|
label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ label: 'Sun', value: 0 },
|
||||||
|
{ label: 'Mon', value: 1 },
|
||||||
|
{ label: 'Tue', value: 2 },
|
||||||
|
{ label: 'Wed', value: 3 },
|
||||||
|
{ label: 'Thu', value: 4 },
|
||||||
|
{ label: 'Fri', value: 5 },
|
||||||
|
{ label: 'Sat', value: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ShiftsPage() {
|
export default function ShiftsPage() {
|
||||||
const [shifts, setShifts] = useState<Shift[]>([]);
|
const [shifts, setShifts] = useState<Shift[]>([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
@ -67,8 +86,12 @@ export default function ShiftsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();
|
const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();
|
||||||
const [stats, setStats] = useState<ShiftStats | null>(null);
|
const [stats, setStats] = useState<ShiftStats | null>(null);
|
||||||
|
|
||||||
// Create modal
|
// Create drawer
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
|
||||||
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
const [createMode, setCreateMode] = useState<'single' | 'series'>('single');
|
||||||
|
const [frequency, setFrequency] = useState<RecurrenceFrequency>('WEEKLY');
|
||||||
|
const [estimatedCount, setEstimatedCount] = useState<number>(0);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
|
|
||||||
// Edit drawer
|
// Edit drawer
|
||||||
@ -87,6 +110,14 @@ export default function ShiftsPage() {
|
|||||||
// Cuts for area dropdown
|
// Cuts for area dropdown
|
||||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||||
|
|
||||||
|
// Calendar view state
|
||||||
|
const [activeTab, setActiveTab] = useState<'table' | 'calendar'>('table');
|
||||||
|
const [editModeModalOpen, setEditModeModalOpen] = useState(false);
|
||||||
|
const [editingSeriesShift, setEditingSeriesShift] = useState<Shift | null>(null);
|
||||||
|
const [calendarData, setCalendarData] = useState<CalendarData['dates']>({});
|
||||||
|
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(dayjs());
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
clearTimeout(searchTimerRef.current);
|
clearTimeout(searchTimerRef.current);
|
||||||
@ -97,6 +128,30 @@ export default function ShiftsPage() {
|
|||||||
return () => clearTimeout(searchTimerRef.current);
|
return () => clearTimeout(searchTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Estimate occurrence count for series mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (createMode !== 'series') return;
|
||||||
|
|
||||||
|
const values = createForm.getFieldsValue();
|
||||||
|
if (!values.startDate || !frequency) return;
|
||||||
|
|
||||||
|
const startDate = dayjs(values.startDate);
|
||||||
|
const endDate = values.endDate ? dayjs(values.endDate) : startDate.add(12, 'weeks');
|
||||||
|
const days = endDate.diff(startDate, 'days') + 1;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
if (frequency === 'DAILY') {
|
||||||
|
count = days;
|
||||||
|
} else if (frequency === 'WEEKLY') {
|
||||||
|
const daysOfWeek = values.daysOfWeek?.length || 0;
|
||||||
|
count = Math.floor(days / 7) * daysOfWeek;
|
||||||
|
} else if (frequency === 'MONTHLY') {
|
||||||
|
count = Math.floor(days / 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEstimatedCount(Math.min(count, 100)); // Cap at 100
|
||||||
|
}, [createForm, frequency, createMode]);
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
const fetchStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<ShiftStats>('/map/shifts/stats');
|
const { data } = await api.get<ShiftStats>('/map/shifts/stats');
|
||||||
@ -115,6 +170,24 @@ export default function ShiftsPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchCalendarData = useCallback(async (month: dayjs.Dayjs) => {
|
||||||
|
setCalendarLoading(true);
|
||||||
|
try {
|
||||||
|
const startDate = month.startOf('month').format('YYYY-MM-DD');
|
||||||
|
const endDate = month.endOf('month').format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const { data } = await api.get<CalendarData>('/map/shifts/calendar', {
|
||||||
|
params: { startDate, endDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
setCalendarData(data.dates);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load calendar data');
|
||||||
|
} finally {
|
||||||
|
setCalendarLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchShifts = useCallback(async (params?: ShiftsListParams) => {
|
const fetchShifts = useCallback(async (params?: ShiftsListParams) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -141,12 +214,21 @@ export default function ShiftsPage() {
|
|||||||
fetchCuts();
|
fetchCuts();
|
||||||
}, [debouncedSearch, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [debouncedSearch, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'calendar') {
|
||||||
|
fetchCalendarData(currentMonth);
|
||||||
|
}
|
||||||
|
}, [activeTab, currentMonth, fetchCalendarData]);
|
||||||
|
|
||||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||||
fetchShifts({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
fetchShifts({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async (values: Record<string, unknown>) => {
|
const handleCreate = async (values: Record<string, unknown>) => {
|
||||||
|
setCreateLoading(true);
|
||||||
try {
|
try {
|
||||||
|
if (createMode === 'single') {
|
||||||
|
// Single shift creation
|
||||||
const payload = {
|
const payload = {
|
||||||
title: values.title,
|
title: values.title,
|
||||||
description: values.description || undefined,
|
description: values.description || undefined,
|
||||||
@ -160,15 +242,41 @@ export default function ShiftsPage() {
|
|||||||
};
|
};
|
||||||
await api.post('/map/shifts', payload);
|
await api.post('/map/shifts', payload);
|
||||||
message.success('Shift created');
|
message.success('Shift created');
|
||||||
setCreateModalOpen(false);
|
} else {
|
||||||
|
// Series creation
|
||||||
|
const seriesPayload: CreateShiftSeriesInput = {
|
||||||
|
title: values.title as string,
|
||||||
|
description: values.description as string | undefined,
|
||||||
|
startTime: dayjs(values.startTime as string).format('HH:mm'),
|
||||||
|
endTime: dayjs(values.endTime as string).format('HH:mm'),
|
||||||
|
location: values.location as string | undefined,
|
||||||
|
maxVolunteers: values.maxVolunteers as number,
|
||||||
|
isPublic: (values.isPublic as boolean) ?? false,
|
||||||
|
cutId: values.cutId as string | undefined,
|
||||||
|
frequency: values.frequency as 'DAILY' | 'WEEKLY' | 'MONTHLY',
|
||||||
|
daysOfWeek: values.frequency === 'WEEKLY' ? (values.daysOfWeek as number[]) : undefined,
|
||||||
|
startDate: dayjs(values.startDate as string).format('YYYY-MM-DD'),
|
||||||
|
endDate: values.endDate ? dayjs(values.endDate as string).format('YYYY-MM-DD') : undefined,
|
||||||
|
};
|
||||||
|
await api.post('/map/shifts/series', seriesPayload);
|
||||||
|
message.success('Shift series created');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
|
setCreateMode('single'); // Reset to single mode
|
||||||
fetchShifts({ page: 1 });
|
fetchShifts({ page: 1 });
|
||||||
fetchStats();
|
fetchStats();
|
||||||
|
if (activeTab === 'calendar') {
|
||||||
|
fetchCalendarData(currentMonth);
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
?.response?.data?.error?.message || 'Failed to create shift';
|
?.response?.data?.error?.message || `Failed to create ${createMode === 'single' ? 'shift' : 'shift series'}`;
|
||||||
message.error(msg);
|
message.error(msg);
|
||||||
|
} finally {
|
||||||
|
setCreateLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -213,6 +321,37 @@ export default function ShiftsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleEditMode = (editMode: EditMode) => {
|
||||||
|
if (!editingSeriesShift) return;
|
||||||
|
|
||||||
|
setEditModeModalOpen(false);
|
||||||
|
|
||||||
|
// If mode is THIS, set isException and break from series
|
||||||
|
if (editMode.mode === 'THIS') {
|
||||||
|
// Open edit drawer with pre-filled values
|
||||||
|
setEditingShift(editingSeriesShift);
|
||||||
|
setEditDrawerOpen(true);
|
||||||
|
} else {
|
||||||
|
// For FUTURE and ALL, show edit drawer with special handling
|
||||||
|
setEditingShift(editingSeriesShift);
|
||||||
|
setEditDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingSeriesShift(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditShift = (shift: Shift) => {
|
||||||
|
if (shift.seriesId && !shift.isException) {
|
||||||
|
// Part of a series - show edit mode modal
|
||||||
|
setEditingSeriesShift(shift);
|
||||||
|
setEditModeModalOpen(true);
|
||||||
|
} else {
|
||||||
|
// Regular shift or exception - edit normally
|
||||||
|
openEdit(shift);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openEdit = (shift: Shift) => {
|
const openEdit = (shift: Shift) => {
|
||||||
setEditingShift(shift);
|
setEditingShift(shift);
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
@ -373,7 +512,7 @@ export default function ShiftsPage() {
|
|||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={(e) => { e.stopPropagation(); openEdit(record); }}
|
onClick={(e) => { e.stopPropagation(); handleEditShift(record); }}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
/>
|
/>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
@ -451,6 +590,21 @@ export default function ShiftsPage() {
|
|||||||
|
|
||||||
const shiftFormFields = (isEdit = false) => (
|
const shiftFormFields = (isEdit = false) => (
|
||||||
<>
|
<>
|
||||||
|
{/* Mode Selector (only for create, not edit) */}
|
||||||
|
{!isEdit && (
|
||||||
|
<Form.Item label="Type">
|
||||||
|
<Segmented
|
||||||
|
value={createMode}
|
||||||
|
onChange={(value) => setCreateMode(value as 'single' | 'series')}
|
||||||
|
options={[
|
||||||
|
{ label: 'Single Shift', value: 'single' },
|
||||||
|
{ label: 'Shift Series', value: 'series' },
|
||||||
|
]}
|
||||||
|
block
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="title"
|
name="title"
|
||||||
label="Title"
|
label="Title"
|
||||||
@ -461,6 +615,9 @@ export default function ShiftsPage() {
|
|||||||
<Form.Item name="description" label="Description">
|
<Form.Item name="description" label="Description">
|
||||||
<Input.TextArea rows={3} placeholder="Shift details and instructions" />
|
<Input.TextArea rows={3} placeholder="Shift details and instructions" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Date field(s) - conditional based on mode */}
|
||||||
|
{createMode === 'single' || isEdit ? (
|
||||||
<Row gutter={12}>
|
<Row gutter={12}>
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={8}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@ -490,6 +647,83 @@ export default function ShiftsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col xs={12} sm={8}>
|
||||||
|
<Form.Item
|
||||||
|
name="startTime"
|
||||||
|
label="Start Time"
|
||||||
|
rules={[{ required: true, message: 'Required' }]}
|
||||||
|
>
|
||||||
|
<TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8}>
|
||||||
|
<Form.Item
|
||||||
|
name="endTime"
|
||||||
|
label="End Time"
|
||||||
|
rules={[{ required: true, message: 'Required' }]}
|
||||||
|
>
|
||||||
|
<TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Recurrence Section for Series Mode */}
|
||||||
|
<div style={{ marginTop: 16, marginBottom: 16, padding: 16, background: 'rgba(255, 255, 255, 0.05)', borderRadius: 8, border: '1px solid rgba(255, 255, 255, 0.1)' }}>
|
||||||
|
<Typography.Title level={5}>Recurrence</Typography.Title>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="frequency"
|
||||||
|
label="Repeat"
|
||||||
|
initialValue="WEEKLY"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Select onChange={(value) => setFrequency(value as RecurrenceFrequency)}>
|
||||||
|
<Select.Option value="DAILY">Daily</Select.Option>
|
||||||
|
<Select.Option value="WEEKLY">Weekly</Select.Option>
|
||||||
|
<Select.Option value="MONTHLY">Monthly</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{frequency === 'WEEKLY' && (
|
||||||
|
<Form.Item
|
||||||
|
name="daysOfWeek"
|
||||||
|
label="Days of Week"
|
||||||
|
rules={[{ required: true, message: 'Select at least one day' }]}
|
||||||
|
>
|
||||||
|
<Checkbox.Group options={DAYS_OF_WEEK} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col xs={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="startDate"
|
||||||
|
label="Start Date"
|
||||||
|
rules={[{ required: true, message: 'Required' }]}
|
||||||
|
>
|
||||||
|
<DatePicker style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12}>
|
||||||
|
<Form.Item name="endDate" label="End Date (Optional)">
|
||||||
|
<DatePicker style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{estimatedCount > 0 && (
|
||||||
|
<Alert
|
||||||
|
message={`This will create approximately ${estimatedCount} shift${estimatedCount > 1 ? 's' : ''}`}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Form.Item name="location" label="Location">
|
<Form.Item name="location" label="Location">
|
||||||
<Input placeholder="e.g. Campaign HQ, 123 Main St" />
|
<Input placeholder="e.g. Campaign HQ, 123 Main St" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -536,7 +770,7 @@ export default function ShiftsPage() {
|
|||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
setCreateModalOpen(true);
|
setCreateDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create Shift
|
Create Shift
|
||||||
@ -548,8 +782,25 @@ export default function ShiftsPage() {
|
|||||||
return () => setPageHeader(null);
|
return () => setPageHeader(null);
|
||||||
}, [setPageHeader, headerActions]);
|
}, [setPageHeader, headerActions]);
|
||||||
|
|
||||||
|
// Calculate the active drawer width for content adjustment
|
||||||
|
const getActiveDrawerWidth = () => {
|
||||||
|
if (createDrawerOpen) return createMode === 'series' ? 700 : 600;
|
||||||
|
if (editDrawerOpen) return 520;
|
||||||
|
if (signupsDrawerOpen) return 640;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeDrawerWidth = getActiveDrawerWidth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Main Content Container - shifts when drawer opens */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginRight: activeDrawerWidth,
|
||||||
|
transition: 'margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Stats Row */}
|
{/* Stats Row */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
@ -586,6 +837,16 @@ export default function ShiftsPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tabs: Table and Calendar Views */}
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={(key) => setActiveTab(key as 'table' | 'calendar')}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'table',
|
||||||
|
label: 'Table View',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} sm={12} md={8}>
|
<Col xs={24} sm={12} md={8}>
|
||||||
@ -628,30 +889,91 @@ export default function ShiftsPage() {
|
|||||||
style: { cursor: 'pointer' },
|
style: { cursor: 'pointer' },
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
{/* Create Modal */}
|
),
|
||||||
<Modal
|
},
|
||||||
title="Create Shift"
|
{
|
||||||
open={createModalOpen}
|
key: 'calendar',
|
||||||
destroyOnHidden
|
label: 'Calendar View',
|
||||||
width={560}
|
children: (
|
||||||
onCancel={() => {
|
<div style={{ marginTop: 16 }}>
|
||||||
setCreateModalOpen(false);
|
<ShiftsCalendar
|
||||||
createForm.resetFields();
|
loading={calendarLoading}
|
||||||
|
calendarData={calendarData}
|
||||||
|
onSelectDate={(date) => {
|
||||||
|
// Open create drawer with pre-filled date
|
||||||
|
createForm.setFieldsValue({
|
||||||
|
date: date,
|
||||||
|
startTime: dayjs('09:00', 'HH:mm'),
|
||||||
|
endTime: dayjs('12:00', 'HH:mm'),
|
||||||
|
maxVolunteers: 10,
|
||||||
|
isPublic: false,
|
||||||
|
});
|
||||||
|
setCreateDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
onOk={() => createForm.submit()}
|
onSelectShift={handleEditShift}
|
||||||
okText="Create"
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
tabBarExtraContent={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setCreateDrawerOpen(true)}
|
||||||
|
>
|
||||||
|
Create Shift
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Drawer */}
|
||||||
|
<Drawer
|
||||||
|
mask={false}
|
||||||
|
title={createMode === 'single' ? 'Create Shift' : 'Create Shift Series'}
|
||||||
|
open={createDrawerOpen}
|
||||||
|
placement="right"
|
||||||
|
width={createMode === 'series' ? 700 : 600}
|
||||||
|
destroyOnClose
|
||||||
|
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateDrawerOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
setCreateMode('single'); // Reset to single mode
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setCreateDrawerOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
setCreateMode('single');
|
||||||
|
}} disabled={createLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={createLoading}
|
||||||
|
onClick={() => createForm.submit()}
|
||||||
|
>
|
||||||
|
{createMode === 'single' ? 'Create Shift' : 'Create Series'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
||||||
{shiftFormFields(false)}
|
{shiftFormFields(false)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* Edit Drawer */}
|
{/* Edit Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title="Edit Shift"
|
title="Edit Shift"
|
||||||
open={editDrawerOpen}
|
open={editDrawerOpen}
|
||||||
width={520}
|
width={520}
|
||||||
|
mask={false}
|
||||||
|
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditDrawerOpen(false);
|
setEditDrawerOpen(false);
|
||||||
setEditingShift(null);
|
setEditingShift(null);
|
||||||
@ -678,6 +1000,8 @@ export default function ShiftsPage() {
|
|||||||
}
|
}
|
||||||
open={signupsDrawerOpen}
|
open={signupsDrawerOpen}
|
||||||
width={640}
|
width={640}
|
||||||
|
mask={false}
|
||||||
|
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSignupsDrawerOpen(false);
|
setSignupsDrawerOpen(false);
|
||||||
setSignupsShift(null);
|
setSignupsShift(null);
|
||||||
@ -751,6 +1075,18 @@ export default function ShiftsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Edit Mode Modal */}
|
||||||
|
<EditModeModal
|
||||||
|
open={editModeModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditModeModalOpen(false);
|
||||||
|
setEditingSeriesShift(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleEditMode}
|
||||||
|
shiftDate={editingSeriesShift?.date || ''}
|
||||||
|
shiftsCount={0} // TODO: fetch series shifts count
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Tag,
|
Tag,
|
||||||
Space,
|
Space,
|
||||||
Modal,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@ -76,12 +76,20 @@ export default function UsersPage() {
|
|||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const [roleFilter, setRoleFilter] = useState<UserRole | undefined>();
|
const [roleFilter, setRoleFilter] = useState<UserRole | undefined>();
|
||||||
const [statusFilter, setStatusFilter] = useState<UserStatus | undefined>();
|
const [statusFilter, setStatusFilter] = useState<UserStatus | undefined>();
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
|
|
||||||
|
const getActiveDrawerWidth = () => {
|
||||||
|
if (createDrawerOpen) return 520;
|
||||||
|
if (editDrawerOpen) return 520;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeDrawerWidth = getActiveDrawerWidth();
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
clearTimeout(searchTimerRef.current);
|
clearTimeout(searchTimerRef.current);
|
||||||
@ -130,7 +138,7 @@ export default function UsersPage() {
|
|||||||
delete (payload as unknown as Record<string, unknown>).expiresAtDate;
|
delete (payload as unknown as Record<string, unknown>).expiresAtDate;
|
||||||
await api.post('/users', payload);
|
await api.post('/users', payload);
|
||||||
message.success('User created');
|
message.success('User created');
|
||||||
setCreateModalOpen(false);
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchUsers({ page: 1 });
|
fetchUsers({ page: 1 });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -155,7 +163,7 @@ export default function UsersPage() {
|
|||||||
if (!payload.password) delete payload.password;
|
if (!payload.password) delete payload.password;
|
||||||
await api.put(`/users/${editingUser.id}`, payload);
|
await api.put(`/users/${editingUser.id}`, payload);
|
||||||
message.success('User updated');
|
message.success('User updated');
|
||||||
setEditModalOpen(false);
|
setEditDrawerOpen(false);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
editForm.resetFields();
|
editForm.resetFields();
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@ -188,7 +196,7 @@ export default function UsersPage() {
|
|||||||
expireDays: user.expireDays,
|
expireDays: user.expireDays,
|
||||||
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
|
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
|
||||||
});
|
});
|
||||||
setEditModalOpen(true);
|
setEditDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<User> = [
|
const columns: ColumnsType<User> = [
|
||||||
@ -261,6 +269,13 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Main Content Container - shifts when drawer opens */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginRight: activeDrawerWidth,
|
||||||
|
transition: 'margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
@ -271,7 +286,7 @@ export default function UsersPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => setCreateModalOpen(true)}
|
onClick={() => setCreateDrawerOpen(true)}
|
||||||
>
|
>
|
||||||
Create User
|
Create User
|
||||||
</Button>
|
</Button>
|
||||||
@ -324,18 +339,34 @@ export default function UsersPage() {
|
|||||||
}}
|
}}
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Create User"
|
title="Create User"
|
||||||
open={createModalOpen}
|
open={createDrawerOpen}
|
||||||
destroyOnHidden
|
placement="right"
|
||||||
onCancel={() => {
|
width={520}
|
||||||
setCreateModalOpen(false);
|
mask={false}
|
||||||
|
destroyOnClose
|
||||||
|
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => createForm.submit()}
|
footer={
|
||||||
okText="Create"
|
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setCreateDrawerOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => createForm.submit()}>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@ -391,20 +422,36 @@ export default function UsersPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Edit User"
|
title="Edit User"
|
||||||
open={editModalOpen}
|
open={editDrawerOpen}
|
||||||
destroyOnHidden
|
placement="right"
|
||||||
onCancel={() => {
|
width={520}
|
||||||
setEditModalOpen(false);
|
mask={false}
|
||||||
|
destroyOnClose
|
||||||
|
rootStyle={{ top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setEditDrawerOpen(false);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
editForm.resetFields();
|
editForm.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => editForm.submit()}
|
footer={
|
||||||
okText="Save"
|
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setEditDrawerOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
editForm.resetFields();
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => editForm.submit()}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@ -457,7 +504,7 @@ export default function UsersPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user