Compare commits
No commits in common. "a7978de5a020a52d7f6b372f1ca734e14ad577ad" and "a77306fac2a000d71775ef8242753e1764f44343" have entirely different histories.
a7978de5a0
...
a77306fac2
@ -1,180 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
838
CLAUDE.md
838
CLAUDE.md
@ -6,734 +6,212 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
|
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
|
||||||
|
|
||||||
**Current state:** V2 rebuild substantially complete on the `v2` branch. Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
|
**Current state:** V2 rebuild in progress on the `v2` branch. 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
|
## V2 Architecture (Active Development)
|
||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
|
|
||||||
- **Dual API Architecture**
|
- **Single unified Express.js API** — TypeScript, port 4000, Prisma ORM + PostgreSQL 16
|
||||||
- **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
|
||||||
- **Redis** — caching, rate limiting, BullMQ backend, geocoding queue (authenticated)
|
- **JWT auth** — access tokens (15min) + refresh tokens (7 days, stored in DB)
|
||||||
- **Monitoring Stack** (Docker profile: `monitoring`) — Prometheus, Grafana, Alertmanager, cAdvisor, exporters
|
- **BullMQ** — async email job queue, **Listmonk** for newsletters
|
||||||
|
- **Redis** — caching, rate limiting, BullMQ backend
|
||||||
|
|
||||||
### Authentication & Security
|
### Directory Structure
|
||||||
|
|
||||||
- **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/ # Dual API servers (Express + Fastify)
|
├── api/ # Unified Express.js API (TypeScript)
|
||||||
│ ├── prisma/
|
│ ├── prisma/ # Schema, migrations, seed
|
||||||
│ │ ├── 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/
|
||||||
│ ├── server.ts # Express API entry point (port 4000)
|
│ ├── config/ # env.ts, database.ts, redis.ts
|
||||||
│ ├── media-server.ts # Fastify media API entry point (port 4100)
|
│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac
|
||||||
│ ├── config/
|
|
||||||
│ │ └── env.ts # Zod-validated environment config (100+ vars)
|
|
||||||
│ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler
|
|
||||||
│ ├── modules/
|
│ ├── modules/
|
||||||
│ │ ├── auth/ # JWT login, register, refresh, logout
|
│ │ ├── auth/ # auth.service, auth.routes, auth.schemas
|
||||||
│ │ ├── users/ # User CRUD + pagination + search
|
│ │ ├── users/ # users.service, users.routes, users.schemas
|
||||||
│ │ ├── settings/ # Site settings singleton
|
│ │ ├── influence/ # campaigns, representatives, responses, postal-codes
|
||||||
│ │ ├── services/ # Service health checks
|
│ │ └── map/ # locations, shifts, cuts
|
||||||
│ │ ├── influence/
|
│ ├── types/ # express.d.ts (Request augmentation)
|
||||||
│ │ │ ├── campaigns/ # Campaign CRUD + public routes
|
│ └── utils/ # logger.ts (Winston), metrics.ts (prom-client)
|
||||||
│ │ │ ├── representatives/ # Represent API integration + cache
|
├── admin/ # React Admin (Vite + Ant Design + Zustand)
|
||||||
│ │ │ ├── responses/ # Response wall + moderation + upvoting
|
|
||||||
│ │ │ ├── postal-codes/ # Postal code cache service
|
|
||||||
│ │ │ ├── campaign-emails/ # Email tracking + stats
|
|
||||||
│ │ │ └── email-queue/ # BullMQ queue admin
|
|
||||||
│ │ ├── map/
|
|
||||||
│ │ │ ├── locations/ # Location CRUD + geocoding + NAR import
|
|
||||||
│ │ │ ├── geocoding/ # Multi-provider geocoding (6 providers)
|
|
||||||
│ │ │ ├── cuts/ # Polygon CRUD + spatial queries
|
|
||||||
│ │ │ ├── shifts/ # Shift CRUD + signups
|
|
||||||
│ │ │ ├── canvass/ # Canvassing sessions + visits + routes
|
|
||||||
│ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes)
|
|
||||||
│ │ │ └── settings/ # Map settings singleton
|
|
||||||
│ │ ├── pages/
|
|
||||||
│ │ │ ├── pages-admin.routes.ts # Landing page CRUD
|
|
||||||
│ │ │ ├── pages-public.routes.ts # Public page renderer
|
|
||||||
│ │ │ └── blocks.routes.ts # Block library API
|
|
||||||
│ │ ├── email-templates/ # Email template CRUD + rendering
|
|
||||||
│ │ ├── media/ # Fastify media API (videos, reactions, jobs)
|
|
||||||
│ │ ├── listmonk/ # Newsletter sync admin routes
|
|
||||||
│ │ ├── pangolin/ # Tunnel management (Newt integration)
|
|
||||||
│ │ ├── docs/ # MkDocs + Code Server health checks
|
|
||||||
│ │ ├── qr/ # QR code PNG generation (public)
|
|
||||||
│ │ └── observability/ # Prometheus/Grafana/Alertmanager integration
|
|
||||||
│ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker
|
|
||||||
│ ├── types/ # express.d.ts (Request augmentation)
|
|
||||||
│ └── utils/ # logger (Winston), metrics (prom-client), spatial
|
|
||||||
│
|
|
||||||
├── admin/ # React Admin (Vite + Ant Design + Zustand)
|
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── App.tsx # Main router + route definitions
|
│ ├── components/ # ProtectedRoute, AppLayout
|
||||||
│ ├── components/
|
│ ├── pages/ # LoginPage, DashboardPage, UsersPage
|
||||||
│ │ ├── AppLayout.tsx # Admin sidebar layout
|
│ ├── stores/ # auth.store.ts (Zustand)
|
||||||
│ │ ├── PublicLayout.tsx # Public dark theme layout
|
│ ├── lib/ # api.ts (axios instance + interceptors)
|
||||||
│ │ ├── VolunteerLayout.tsx # Volunteer portal layout
|
│ └── types/ # api.ts (TypeScript interfaces)
|
||||||
│ │ ├── MediaPublicLayout.tsx # Public media gallery layout
|
├── nginx/ # Reverse proxy config
|
||||||
│ │ ├── GrapesJSEditor.tsx # Landing page editor wrapper (forwardRef, Ctrl+S)
|
├── public-web/ # Public landing pages
|
||||||
│ │ ├── map/ # Leaflet map components + controls + drawing modes
|
├── docker-compose.yml # V2 orchestration
|
||||||
│ │ ├── canvass/ # GPS tracking, markers, route, visit recording
|
├── docker-compose.v1.yml # V1 backup for reference
|
||||||
│ │ ├── media/ # VideoCard, BulkActions, gallery components
|
└── V2_PLAN.md # Full 14-phase roadmap
|
||||||
│ │ ├── email-templates/ # Email template components
|
|
||||||
│ │ └── observability/ # Monitoring components
|
|
||||||
│ ├── pages/
|
|
||||||
│ │ ├── auth/ # LoginPage
|
|
||||||
│ │ ├── DashboardPage.tsx # Admin dashboard
|
|
||||||
│ │ ├── UsersPage.tsx # User CRUD
|
|
||||||
│ │ ├── SettingsPage.tsx # Global site settings
|
|
||||||
│ │ ├── influence/
|
|
||||||
│ │ │ ├── CampaignsPage.tsx # Campaign management
|
|
||||||
│ │ │ ├── ResponsesPage.tsx # Response moderation
|
|
||||||
│ │ │ ├── RepresentativesPage.tsx # Rep cache admin
|
|
||||||
│ │ │ └── EmailQueuePage.tsx # Queue monitoring
|
|
||||||
│ │ ├── map/
|
|
||||||
│ │ │ ├── LocationsPage.tsx # Location CRUD + CSV + geocoding
|
|
||||||
│ │ │ ├── CutsPage.tsx # Cut table + map drawing editor
|
|
||||||
│ │ │ ├── ShiftsPage.tsx # Shift CRUD + signups drawer
|
|
||||||
│ │ │ ├── MapSettingsPage.tsx # Map settings
|
|
||||||
│ │ │ └── DataQualityDashboardPage.tsx # Geocoding quality metrics
|
|
||||||
│ │ ├── CanvassDashboardPage.tsx # Admin canvass overview
|
|
||||||
│ │ ├── WalkSheetPage.tsx # Printable walk sheet
|
|
||||||
│ │ ├── CutExportPage.tsx # Printable location report
|
|
||||||
│ │ ├── volunteer/
|
|
||||||
│ │ │ ├── VolunteerMapPage.tsx # Full-screen GPS canvass map
|
|
||||||
│ │ │ ├── VolunteerShiftsPage.tsx # Assigned shifts
|
|
||||||
│ │ │ ├── MyActivityPage.tsx # Visit history + outcomes
|
|
||||||
│ │ │ └── MyRoutesPage.tsx # Route history
|
|
||||||
│ │ ├── public/
|
|
||||||
│ │ │ ├── CampaignsListPage.tsx # Public campaign listing
|
|
||||||
│ │ │ ├── CampaignPage.tsx # Campaign detail + email form
|
|
||||||
│ │ │ ├── ResponseWallPage.tsx # Public response wall
|
|
||||||
│ │ │ ├── MapPage.tsx # Public Leaflet map
|
|
||||||
│ │ │ ├── ShiftsPage.tsx # Public shift signup
|
|
||||||
│ │ │ ├── LandingPage.tsx # Rendered landing page (/p/:slug)
|
|
||||||
│ │ │ ├── MediaGalleryPage.tsx # Public video gallery
|
|
||||||
│ │ │ └── MediaViewerPage.tsx # Video detail page
|
|
||||||
│ │ ├── media/
|
|
||||||
│ │ │ ├── LibraryPage.tsx # Video library management
|
|
||||||
│ │ │ ├── SharedMediaPage.tsx # Public gallery admin
|
|
||||||
│ │ │ └── MediaJobsPage.tsx # Job queue monitoring
|
|
||||||
│ │ ├── LandingPagesPage.tsx # Landing page manager
|
|
||||||
│ │ ├── PageEditorPage.tsx # Full-screen GrapesJS editor
|
|
||||||
│ │ ├── EmailTemplatesPage.tsx # Email template CRUD
|
|
||||||
│ │ ├── EmailTemplateEditorPage.tsx # Email template editor
|
|
||||||
│ │ ├── ListmonkPage.tsx # Newsletter sync management
|
|
||||||
│ │ ├── PangolinPage.tsx # Tunnel setup wizard
|
|
||||||
│ │ ├── DocsPage.tsx # MkDocs export management
|
|
||||||
│ │ ├── MkDocsSettingsPage.tsx # Documentation config
|
|
||||||
│ │ ├── ObservabilityPage.tsx # Monitoring dashboard
|
|
||||||
│ │ └── services/
|
|
||||||
│ │ ├── MiniQRPage.tsx # Mini QR iframe
|
|
||||||
│ │ ├── MailHogPage.tsx # Email capture UI
|
|
||||||
│ │ ├── CodeEditorPage.tsx # Code Server management
|
|
||||||
│ │ ├── N8nPage.tsx # Workflow automation
|
|
||||||
│ │ ├── GiteaPage.tsx # Git repository hosting
|
|
||||||
│ │ └── NocoDBPage.tsx # Data browser management
|
|
||||||
│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand)
|
|
||||||
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios)
|
|
||||||
│ ├── hooks/ # useDebounce, useLocalStorage
|
|
||||||
│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces)
|
|
||||||
│
|
|
||||||
├── media-manager/ # Legacy media manager (reference)
|
|
||||||
├── nginx/ # Reverse proxy config (subdomain routing + CSP)
|
|
||||||
├── configs/ # Prometheus, Grafana, Alertmanager configs
|
|
||||||
├── scripts/ # backup.sh, legacy Cloudflare scripts
|
|
||||||
├── docker-compose.yml # V2 orchestration (20+ services)
|
|
||||||
├── docker-compose.v1.yml # V1 backup (reference)
|
|
||||||
├── .env.example # All required environment variables
|
|
||||||
└── V2_PLAN.md # Full 14-phase roadmap
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `api/prisma/schema.prisma` | Full database schema (20+ models) |
|
||||||
|
| `api/src/server.ts` | API entry point, middleware stack, route wiring |
|
||||||
|
| `api/src/config/env.ts` | Zod-validated environment config |
|
||||||
|
| `api/src/modules/auth/` | JWT auth (login, register, refresh, logout) |
|
||||||
|
| `api/src/modules/users/` | User CRUD with pagination + search |
|
||||||
|
| `admin/src/App.tsx` | React admin shell with routing |
|
||||||
|
| `admin/src/stores/auth.store.ts` | Zustand auth state with token persistence |
|
||||||
|
| `admin/src/lib/api.ts` | Axios instance with 401 refresh interceptor |
|
||||||
|
| `docker-compose.yml` | V2 service orchestration |
|
||||||
|
| `.env.example` | All required environment variables |
|
||||||
|
|
||||||
|
### Auth Flow
|
||||||
|
|
||||||
|
- JWT-based: access tokens (15min) + refresh tokens (7 days, stored in DB)
|
||||||
|
- Login → verify bcrypt hash → generate token pair → return tokens + user
|
||||||
|
- Refresh → validate refresh token → rotate (invalidate old, issue new) → return new pair
|
||||||
|
- Roles: `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `USER`, `TEMP`
|
||||||
|
- RBAC middleware: `requireRole(...roles)`, `requireNonTemp`
|
||||||
|
|
||||||
|
### Nginx Routing
|
||||||
|
|
||||||
|
| Subdomain | Target |
|
||||||
|
|-----------|--------|
|
||||||
|
| `app.cmlite.org` | Admin React app (port 3000) |
|
||||||
|
| `api.cmlite.org` | Express API (port 4000) |
|
||||||
|
| `data.cmlite.org` | NocoDB read-only (port 8091) |
|
||||||
|
| `docs.cmlite.org` | MkDocs (port 4001) |
|
||||||
|
| `cmlite.org` | Public landing pages |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start Guide
|
## V2 Development Commands
|
||||||
|
|
||||||
### Initial Setup (First Time)
|
|
||||||
|
|
||||||
1. **Clone repository and checkout v2 branch:**
|
|
||||||
```bash
|
|
||||||
git clone <repo-url> changemaker.lite
|
|
||||||
cd changemaker.lite
|
|
||||||
git checkout v2
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create environment file:**
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env and set:
|
|
||||||
# - V2_POSTGRES_PASSWORD (strong password)
|
|
||||||
# - REDIS_PASSWORD (strong password)
|
|
||||||
# - JWT_ACCESS_SECRET (openssl rand -hex 32)
|
|
||||||
# - JWT_REFRESH_SECRET (openssl rand -hex 32)
|
|
||||||
# - ENCRYPTION_KEY (openssl rand -hex 32, must differ from JWT secrets)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Start core services:**
|
|
||||||
```bash
|
|
||||||
docker compose up -d v2-postgres redis api admin
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Run database migrations:**
|
|
||||||
```bash
|
|
||||||
docker compose exec api npx prisma migrate deploy
|
|
||||||
docker compose exec api npx prisma db seed
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Access the application:**
|
|
||||||
- Admin GUI: http://localhost:3000 (see INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env)
|
|
||||||
- API: http://localhost:4000
|
|
||||||
- **Change default password immediately**
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
|
|
||||||
**Starting services:**
|
|
||||||
```bash
|
|
||||||
# Core services
|
|
||||||
docker compose up -d v2-postgres redis api admin
|
|
||||||
|
|
||||||
# Include monitoring stack
|
|
||||||
docker compose --profile monitoring up -d
|
|
||||||
|
|
||||||
# Include media API
|
|
||||||
docker compose up -d media-api
|
|
||||||
```
|
|
||||||
|
|
||||||
**Local development (without Docker):**
|
|
||||||
```bash
|
|
||||||
# Terminal 1: API
|
|
||||||
cd api && npm install && npm run dev
|
|
||||||
|
|
||||||
# Terminal 2: Admin
|
|
||||||
cd admin && npm install && npm run dev
|
|
||||||
|
|
||||||
# Terminal 3 (optional): Media API
|
|
||||||
cd api && npm run dev:media
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accessing Services
|
|
||||||
|
|
||||||
| Service | URL | Default Credentials |
|
|
||||||
|---------|-----|---------------------|
|
|
||||||
| Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env |
|
|
||||||
| API | http://localhost:4000 | - |
|
|
||||||
| NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_ADMIN_PASSWORD` in .env |
|
|
||||||
| MailHog | http://localhost:8025 | - |
|
|
||||||
| Grafana | http://localhost:3001 | admin / admin |
|
|
||||||
| Prometheus | http://localhost:9090 | - |
|
|
||||||
| Listmonk | http://localhost:9001 | See `LISTMONK_WEB_ADMIN_USER`/`PASSWORD` in .env |
|
|
||||||
|
|
||||||
### Feature Flags
|
|
||||||
|
|
||||||
Enable optional features in `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Media Manager
|
|
||||||
ENABLE_MEDIA_FEATURES=true
|
|
||||||
|
|
||||||
# Listmonk Newsletter Sync
|
|
||||||
LISTMONK_SYNC_ENABLED=true
|
|
||||||
|
|
||||||
# Email Test Mode (sends to MailHog instead of SMTP)
|
|
||||||
EMAIL_TEST_MODE=true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
The user likes to use Docker - recereating services as if in production.
|
|
||||||
|
|
||||||
### API Development
|
### API Development
|
||||||
```bash
|
```bash
|
||||||
cd api && npm run dev # Express dev server (port 4000)
|
cd api && npm run dev # Dev server with tsx watch (auto-reload)
|
||||||
cd api && npm run dev:media # Fastify media dev server (port 4100)
|
cd api && npx tsc --noEmit # Type-check without emitting
|
||||||
cd api && npx tsc --noEmit # Type-check
|
cd api && npx prisma migrate dev # Run/create migrations
|
||||||
cd api && npx prisma migrate dev # Run/create Prisma migrations
|
cd api && npx prisma studio # Browse database in browser
|
||||||
cd api && npx prisma studio # Browse database
|
cd api && npx prisma generate # Regenerate Prisma client
|
||||||
cd api && npx drizzle-kit push # Push Drizzle schema changes (media)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Admin Development
|
### Admin GUI 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
|
cd admin && npx tsc --noEmit # Type-check without emitting
|
||||||
cd admin && npm run build # Production build
|
cd admin && npm run build # Production build (tsc + vite)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Operations
|
### Docker (V2 Services)
|
||||||
```bash
|
```bash
|
||||||
# Start services
|
docker compose up -d v2-postgres redis api # Start API + dependencies
|
||||||
docker compose up -d v2-postgres redis api admin
|
docker compose up -d admin # Start admin GUI
|
||||||
docker compose up -d media-api
|
docker compose up -d # Start all v2 services
|
||||||
docker compose --profile monitoring up -d
|
docker compose logs -f api # Tail API logs
|
||||||
|
docker compose exec api npx prisma migrate dev # Run migrations in container
|
||||||
# View logs
|
docker compose down # Stop all services
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing & Backup
|
### Type Checking (Both Projects)
|
||||||
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Modules Reference
|
## Port Reference (V2)
|
||||||
|
|
||||||
### Auth & Users
|
| Port | Service |
|
||||||
|
|------|---------|
|
||||||
**Files:**
|
| 3000 | Admin GUI (Vite dev / React) |
|
||||||
- `api/src/modules/auth/` — JWT login, register, refresh, logout
|
| 3001 | Grafana |
|
||||||
- `api/src/modules/users/` — User CRUD + pagination + search
|
| 3010 | Homepage |
|
||||||
- `api/src/middleware/auth.ts` — JWT verification + RBAC
|
| 3030 | Gitea |
|
||||||
- `admin/src/stores/auth.store.ts` — Zustand auth state + token persistence
|
| 4000 | V2 API (Express.js) |
|
||||||
- `admin/src/lib/api.ts` — Axios with 401 refresh interceptor
|
| 4001 | MkDocs (built static) |
|
||||||
|
| 5432 | Listmonk PostgreSQL |
|
||||||
**Features:** JWT access/refresh tokens, bcrypt passwords (12+ chars), role-based access control, user enumeration prevention, rate limiting
|
| 5433 | V2 PostgreSQL (localhost) |
|
||||||
|
| 5678 | n8n |
|
||||||
### Influence Module (Advocacy Campaigns)
|
| 6379 | Redis |
|
||||||
|
| 8025 | MailHog Web UI |
|
||||||
**Files:**
|
| 8080 | cAdvisor |
|
||||||
- `api/src/modules/influence/campaigns/` — Campaign CRUD + public routes
|
| 8089 | Mini QR |
|
||||||
- `api/src/modules/influence/representatives/` — Represent API client + cache
|
| 8091 | NocoDB v2 (read-only) |
|
||||||
- `api/src/modules/influence/responses/` — Response wall + moderation + upvoting
|
| 8888 | Code Server |
|
||||||
- `api/src/services/email-queue.service.ts` — BullMQ queue + worker
|
| 9001 | Listmonk |
|
||||||
- `admin/src/pages/CampaignsPage.tsx` — Campaign management
|
| 9090 | Prometheus |
|
||||||
- `admin/src/pages/public/CampaignPage.tsx` — Public campaign page
|
| 9093 | Alertmanager |
|
||||||
|
|
||||||
**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 archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two independent Express apps using NocoDB REST API. See individual README files for V1 documentation:
|
V1 code is preserved in `influence/` and `map/` directories and backed up in `docker-compose.v1.yml`.
|
||||||
- `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
|
||||||
- Both use session-based auth, bcryptjs passwords, Bull job queues
|
- `map/files-explainer.md` — File-by-file code documentation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Configuration Files
|
## Key Configuration Files
|
||||||
|
|
||||||
### Infrastructure
|
| File | Purpose |
|
||||||
- `docker-compose.yml` — V2 orchestration (20+ services, monitoring profile)
|
|------|---------|
|
||||||
- `.env` / `.env.example` — Environment variables (100+ vars)
|
| `docker-compose.yml` | V2 orchestration (all services) |
|
||||||
|
| `docker-compose.v1.yml` | V1 backup |
|
||||||
|
| `.env` / `.env.example` | Environment variables (never committed) |
|
||||||
|
| `api/prisma/schema.prisma` | Database schema |
|
||||||
|
| `nginx/` | Reverse proxy configuration |
|
||||||
|
| `configs/prometheus/prometheus.yml` | Monitoring scrape targets |
|
||||||
|
| `configs/cloudflare/tunnel-config.yml` | Production ingress routing |
|
||||||
|
|
||||||
### Database
|
## Networking
|
||||||
- `api/prisma/schema.prisma` — Main schema (30+ Prisma models)
|
|
||||||
- `api/drizzle.config.ts` — Drizzle config for media tables
|
|
||||||
- `api/prisma/seed.ts` — Database seeding
|
|
||||||
|
|
||||||
### Nginx
|
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.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
BIN
NARguide.pdf
Binary file not shown.
@ -1,181 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
# 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
BIN
RNAguide.pdf
Binary file not shown.
15
V2_PLAN.md
15
V2_PLAN.md
@ -364,22 +364,13 @@ changemaker.lite/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 15: Testing + Polish [IN PROGRESS]
|
### Phase 15: Testing + Polish [ ]
|
||||||
|
|
||||||
**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 (auth-security-reviewer for media features)
|
- [ ] Security audit
|
||||||
- [ ] UI design review (ui-design-critic for media components)
|
- [ ] Documentation updates
|
||||||
|
|
||||||
### 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,11 +11,9 @@
|
|||||||
"@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",
|
||||||
@ -28,14 +26,11 @@
|
|||||||
"grapesjs-tabs": "^1.0.6",
|
"grapesjs-tabs": "^1.0.6",
|
||||||
"grapesjs-touch": "^0.1.1",
|
"grapesjs-touch": "^0.1.1",
|
||||||
"grapesjs-typed": "^2.0.1",
|
"grapesjs-typed": "^2.0.1",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@ -906,6 +901,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@ -1063,40 +1059,6 @@
|
|||||||
"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",
|
||||||
@ -1428,16 +1390,6 @@
|
|||||||
"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",
|
||||||
@ -1488,69 +1440,6 @@
|
|||||||
"@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",
|
||||||
@ -1613,18 +1502,14 @@
|
|||||||
"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",
|
||||||
@ -1821,14 +1706,6 @@
|
|||||||
"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",
|
||||||
@ -1886,116 +1763,6 @@
|
|||||||
"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",
|
||||||
@ -2018,11 +1785,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@ -2032,9 +1794,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||||
|
"peer": true,
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@ -2099,11 +1862,6 @@
|
|||||||
"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",
|
||||||
@ -2154,11 +1912,6 @@
|
|||||||
"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",
|
||||||
@ -2409,23 +2162,6 @@
|
|||||||
"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",
|
||||||
@ -2464,29 +2200,12 @@
|
|||||||
"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",
|
||||||
@ -2545,15 +2264,6 @@
|
|||||||
"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",
|
||||||
@ -3250,43 +2960,6 @@
|
|||||||
"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",
|
||||||
@ -3332,50 +3005,6 @@
|
|||||||
"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",
|
||||||
@ -3484,11 +3113,6 @@
|
|||||||
"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",
|
||||||
@ -3558,35 +3182,6 @@
|
|||||||
"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,11 +12,9 @@
|
|||||||
"@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",
|
||||||
@ -29,14 +27,11 @@
|
|||||||
"grapesjs-tabs": "^1.0.6",
|
"grapesjs-tabs": "^1.0.6",
|
||||||
"grapesjs-touch": "^0.1.1",
|
"grapesjs-touch": "^0.1.1",
|
||||||
"grapesjs-typed": "^2.0.1",
|
"grapesjs-typed": "^2.0.1",
|
||||||
"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,17 +8,14 @@ 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';
|
||||||
@ -26,6 +23,7 @@ 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';
|
||||||
@ -33,45 +31,24 @@ 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 CommentModerationPage from '@/pages/media/CommentModerationPage';
|
|
||||||
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
|
|
||||||
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 CreateCampaignPage from '@/pages/public/CreateCampaignPage';
|
|
||||||
import MyCampaignsPage from '@/pages/public/MyCampaignsPage';
|
|
||||||
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 ShortsPage from '@/pages/public/ShortsPage';
|
|
||||||
import MediaViewerPage from '@/pages/public/MediaViewerPage';
|
|
||||||
import PlaylistBrowsePage from '@/pages/public/PlaylistBrowsePage';
|
|
||||||
import PlaylistViewerPage from '@/pages/public/PlaylistViewerPage';
|
|
||||||
import PlaylistManagementPage from '@/pages/media/PlaylistManagementPage';
|
|
||||||
import MyStatsPage from '@/pages/public/MyStatsPage';
|
|
||||||
import MySettingsPage from '@/pages/public/MySettingsPage';
|
|
||||||
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
|
import 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';
|
||||||
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
|
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
|
||||||
import { ADMIN_ROLES } from '@/types/api';
|
import { ADMIN_ROLES } from '@/types/api';
|
||||||
import { isAdmin } from '@/utils/roles';
|
|
||||||
import VerifyEmailPage from '@/pages/VerifyEmailPage';
|
|
||||||
import ResetPasswordPage from '@/pages/ResetPasswordPage';
|
|
||||||
|
|
||||||
function RoleAwareRedirect() {
|
function RoleAwareRedirect() {
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
if (user && isAdmin(user)) return <Navigate to="/app" replace />;
|
if (user && ADMIN_ROLES.includes(user.role)) return <Navigate to="/app" replace />;
|
||||||
return <Navigate to="/volunteer" replace />;
|
return <Navigate to="/volunteer" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,24 +116,6 @@ export default function App() {
|
|||||||
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<CampaignsListPage />} />
|
<Route index element={<CampaignsListPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/campaigns/create" element={
|
|
||||||
<FeatureGate feature="enableInfluence">
|
|
||||||
<ProtectedRoute>
|
|
||||||
<PublicLayout />
|
|
||||||
</ProtectedRoute>
|
|
||||||
</FeatureGate>
|
|
||||||
}>
|
|
||||||
<Route index element={<CreateCampaignPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/campaigns/mine" element={
|
|
||||||
<FeatureGate feature="enableInfluence">
|
|
||||||
<ProtectedRoute>
|
|
||||||
<PublicLayout />
|
|
||||||
</ProtectedRoute>
|
|
||||||
</FeatureGate>
|
|
||||||
}>
|
|
||||||
<Route index element={<MyCampaignsPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/campaign" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
<Route path="/campaign" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
||||||
<Route path=":slug" element={<CampaignPage />} />
|
<Route path=":slug" element={<CampaignPage />} />
|
||||||
<Route path=":slug/responses" element={<ResponseWallPage />} />
|
<Route path=":slug/responses" element={<ResponseWallPage />} />
|
||||||
@ -167,27 +126,6 @@ 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="shorts" element={<ShortsPage />} />
|
|
||||||
<Route path=":category" element={<MediaGalleryPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/gallery/curated" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
|
||||||
<Route index element={<PlaylistBrowsePage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/gallery/curated/share/:token" element={<FeatureGate feature="enableMediaFeatures"><PlaylistViewerPage /></FeatureGate>} />
|
|
||||||
<Route path="/gallery/curated/:playlistId" element={<FeatureGate feature="enableMediaFeatures"><PlaylistViewerPage /></FeatureGate>} />
|
|
||||||
<Route path="/gallery/my-stats" element={<FeatureGate feature="enableMediaFeatures"><ProtectedRoute><MediaPublicLayout /></ProtectedRoute></FeatureGate>}>
|
|
||||||
<Route index element={<MyStatsPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/gallery/my-settings" element={<FeatureGate feature="enableMediaFeatures"><ProtectedRoute><MediaPublicLayout /></ProtectedRoute></FeatureGate>}>
|
|
||||||
<Route index element={<MySettingsPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/gallery/watch/:id" element={<FeatureGate feature="enableMediaFeatures"><MediaViewerPage /></FeatureGate>} />
|
|
||||||
{/* Email link alias for video viewer */}
|
|
||||||
<Route path="/media/:id" element={<MediaViewerPage />} />
|
|
||||||
|
|
||||||
{/* Volunteer map — full-screen, default landing page */}
|
{/* Volunteer map — full-screen, default landing page */}
|
||||||
<Route
|
<Route
|
||||||
path="/volunteer"
|
path="/volunteer"
|
||||||
@ -218,8 +156,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
<Route
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
path="/app/pages/:id/edit"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<PageEditorPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/app"
|
path="/app"
|
||||||
element={
|
element={
|
||||||
@ -261,14 +205,6 @@ 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={
|
||||||
@ -277,14 +213,6 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="campaign-moderation"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
|
||||||
<CampaignModerationPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="listmonk"
|
path="listmonk"
|
||||||
element={
|
element={
|
||||||
@ -357,22 +285,6 @@ 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={
|
||||||
@ -389,14 +301,6 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="observability"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
|
||||||
<ObservabilityPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="map"
|
path="map"
|
||||||
element={
|
element={
|
||||||
@ -405,14 +309,6 @@ 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={
|
||||||
@ -453,46 +349,6 @@ 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
|
|
||||||
path="media/curated"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
|
||||||
<PlaylistManagementPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="media/moderation"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
|
||||||
<CommentModerationPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<RoleAwareRedirect />} />
|
<Route path="*" element={<RoleAwareRedirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } 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,28 +26,25 @@ import {
|
|||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
BranchesOutlined,
|
BranchesOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
QrcodeOutlined,
|
|
||||||
VideoCameraOutlined,
|
|
||||||
FolderOutlined,
|
|
||||||
HistoryOutlined,
|
|
||||||
LineChartOutlined,
|
|
||||||
BarChartOutlined,
|
|
||||||
SoundOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
OrderedListOutlined,
|
|
||||||
} 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'] = [
|
||||||
{
|
{
|
||||||
@ -64,7 +61,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
label: 'Influence',
|
label: 'Influence',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
|
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
|
||||||
{ key: '/app/campaign-moderation', icon: <FileTextOutlined />, label: 'Campaign Review' },
|
|
||||||
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
|
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
|
||||||
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
|
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
|
||||||
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
|
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
|
||||||
@ -74,13 +70,9 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
|
|
||||||
if (settings?.enableNewsletter !== false) {
|
if (settings?.enableNewsletter !== false) {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'broadcast-submenu',
|
key: '/app/listmonk',
|
||||||
icon: <NotificationOutlined />,
|
icon: <NotificationOutlined />,
|
||||||
label: 'Broadcast',
|
label: 'Newsletter',
|
||||||
children: [
|
|
||||||
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Listmonk' },
|
|
||||||
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,8 +97,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
icon: <EnvironmentOutlined />,
|
icon: <EnvironmentOutlined />,
|
||||||
label: 'Map',
|
label: 'Map',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
|
{ key: '/app/map', 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' },
|
||||||
@ -115,20 +106,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings?.enableMediaFeatures !== false) {
|
|
||||||
items.push({
|
|
||||||
key: 'media-submenu',
|
|
||||||
icon: <VideoCameraOutlined />,
|
|
||||||
label: 'Media Library',
|
|
||||||
children: [
|
|
||||||
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Videos' },
|
|
||||||
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
|
|
||||||
{ key: '/app/media/moderation', icon: <MessageOutlined />, label: 'Moderation' },
|
|
||||||
{ key: '/app/media/jobs', icon: <HistoryOutlined />, label: 'Processing Jobs' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
key: 'services-submenu',
|
key: 'services-submenu',
|
||||||
icon: <CloudServerOutlined />,
|
icon: <CloudServerOutlined />,
|
||||||
@ -138,10 +115,7 @@ 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' },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -256,6 +230,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
@ -329,22 +304,6 @@ 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 && (
|
||||||
|
|||||||
@ -1,242 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Modal, Form, Input, Button, Alert, Segmented, Typography } from 'antd';
|
|
||||||
import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
|
||||||
|
|
||||||
type AuthMode = 'signin' | 'register';
|
|
||||||
|
|
||||||
interface AuthModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthModal({ open, onCancel, onSuccess, title, subtitle }: AuthModalProps) {
|
|
||||||
const { login, register, isLoading, error, errorCode, registrationMessage, clearError } = useAuthStore();
|
|
||||||
const [mode, setMode] = useState<AuthMode>('signin');
|
|
||||||
const [loginForm] = Form.useForm();
|
|
||||||
const [registerForm] = Form.useForm();
|
|
||||||
const [resendLoading, setResendLoading] = useState(false);
|
|
||||||
const [resendSent, setResendSent] = useState(false);
|
|
||||||
|
|
||||||
// Clear errors when switching modes
|
|
||||||
useEffect(() => {
|
|
||||||
clearError();
|
|
||||||
setResendSent(false);
|
|
||||||
}, [mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const handleLogin = async (values: { email: string; password: string }) => {
|
|
||||||
try {
|
|
||||||
await login(values.email, values.password);
|
|
||||||
loginForm.resetFields();
|
|
||||||
onSuccess();
|
|
||||||
} catch {
|
|
||||||
// Error is set in store
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegister = async (values: { name: string; email: string; password: string }) => {
|
|
||||||
try {
|
|
||||||
const result = await register(values.name, values.email, values.password);
|
|
||||||
if (result?.requiresVerification) {
|
|
||||||
// Stay open to show verification message — don't call onSuccess
|
|
||||||
registerForm.resetFields();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
registerForm.resetFields();
|
|
||||||
onSuccess();
|
|
||||||
} catch {
|
|
||||||
// Error is set in store
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResendVerification = async () => {
|
|
||||||
const email = loginForm.getFieldValue('email');
|
|
||||||
if (!email) return;
|
|
||||||
setResendLoading(true);
|
|
||||||
try {
|
|
||||||
await axios.post(`${API_URL}/api/auth/resend-verification`, { email });
|
|
||||||
setResendSent(true);
|
|
||||||
} catch {
|
|
||||||
// Ignore — always show success for security
|
|
||||||
setResendSent(true);
|
|
||||||
} finally {
|
|
||||||
setResendLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
loginForm.resetFields();
|
|
||||||
registerForm.resetFields();
|
|
||||||
clearError();
|
|
||||||
onCancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
footer={null}
|
|
||||||
destroyOnHidden
|
|
||||||
width={420}
|
|
||||||
>
|
|
||||||
{title && (
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: 4 }}>
|
|
||||||
<Text strong style={{ fontSize: 18 }}>{title}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subtitle && (
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 13 }}>{subtitle}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 20 }}>
|
|
||||||
<Segmented
|
|
||||||
options={[
|
|
||||||
{ label: 'Sign In', value: 'signin' },
|
|
||||||
{ label: 'Register', value: 'register' },
|
|
||||||
]}
|
|
||||||
value={mode}
|
|
||||||
onChange={(val) => setMode(val as AuthMode)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Registration success — verification required */}
|
|
||||||
{registrationMessage && (
|
|
||||||
<Alert
|
|
||||||
message="Check Your Email"
|
|
||||||
description={registrationMessage}
|
|
||||||
type="success"
|
|
||||||
showIcon
|
|
||||||
icon={<CheckCircleOutlined />}
|
|
||||||
closable
|
|
||||||
onClose={() => clearError()}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert
|
|
||||||
message={error}
|
|
||||||
type="error"
|
|
||||||
showIcon
|
|
||||||
closable
|
|
||||||
onClose={() => clearError()}
|
|
||||||
description={
|
|
||||||
errorCode === 'EMAIL_NOT_VERIFIED' ? (
|
|
||||||
resendSent ? (
|
|
||||||
<Text type="success" style={{ fontSize: 12 }}>Verification email sent! Check your inbox.</Text>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
loading={resendLoading}
|
|
||||||
onClick={handleResendVerification}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
Resend verification email
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
) : errorCode === 'ACCOUNT_PENDING' ? (
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
An admin will review your account shortly.
|
|
||||||
</Text>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === 'signin' ? (
|
|
||||||
<Form form={loginForm} onFinish={handleLogin} layout="vertical" size="large">
|
|
||||||
<Form.Item
|
|
||||||
name="email"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: 'Please enter your email' },
|
|
||||||
{ type: 'email', message: 'Please enter a valid email' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input prefix={<MailOutlined />} placeholder="Email" autoFocus />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="password"
|
|
||||||
rules={[{ required: true, message: 'Please enter your password' }]}
|
|
||||||
>
|
|
||||||
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
|
||||||
<Button type="primary" htmlType="submit" loading={isLoading} block>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
) : (
|
|
||||||
<Form form={registerForm} onFinish={handleRegister} layout="vertical" size="large">
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
rules={[{ required: true, message: 'Please enter your name' }]}
|
|
||||||
>
|
|
||||||
<Input prefix={<UserOutlined />} placeholder="Full Name" autoFocus />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="email"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: 'Please enter your email' },
|
|
||||||
{ type: 'email', message: 'Please enter a valid email' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input prefix={<MailOutlined />} placeholder="Email" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="password"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: 'Please enter a password' },
|
|
||||||
{ min: 12, message: 'Password must be at least 12 characters' },
|
|
||||||
{
|
|
||||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
|
||||||
message: 'Must contain uppercase, lowercase, and a digit',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input.Password prefix={<LockOutlined />} placeholder="Password (12+ chars)" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="confirmPassword"
|
|
||||||
dependencies={['password']}
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: 'Please confirm your password' },
|
|
||||||
({ getFieldValue }) => ({
|
|
||||||
validator(_, value) {
|
|
||||||
if (!value || getFieldValue('password') === value) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('Passwords do not match'));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input.Password prefix={<LockOutlined />} placeholder="Confirm Password" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
|
||||||
<Button type="primary" htmlType="submit" loading={isLoading} block>
|
|
||||||
Create Account
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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' | 'enableMediaFeatures'>;
|
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter'>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -215,38 +215,6 @@ 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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { ConfigProvider, Layout, theme, Grid } from 'antd';
|
|
||||||
import { Outlet } from 'react-router-dom';
|
|
||||||
import MediaSidebar from '@/components/media/MediaSidebar';
|
|
||||||
import MediaBottomNav from '@/components/media/MediaBottomNav';
|
|
||||||
import ChatNotificationToast from '@/components/media/ChatNotificationToast';
|
|
||||||
import { ChatBarProvider } from '@/components/media/chatbar/ChatBarContext';
|
|
||||||
import ChatBar from '@/components/media/chatbar/ChatBar';
|
|
||||||
import { useChatNotifications } from '@/hooks/useChatNotifications';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { hexToRgba } from '@/utils/color';
|
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
|
|
||||||
export default function MediaPublicLayout() {
|
|
||||||
const { settings } = useSettingsStore();
|
|
||||||
const { notifications, clearNotification } = useChatNotifications();
|
|
||||||
|
|
||||||
// Read colors from site settings (same source as PublicLayout)
|
|
||||||
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
|
|
||||||
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
|
||||||
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
|
|
||||||
const orgName = settings?.organizationName ?? 'Changemaker Lite';
|
|
||||||
|
|
||||||
const screens = useBreakpoint();
|
|
||||||
const isMobile = !screens.md; // < 768px
|
|
||||||
|
|
||||||
// Get sidebar collapse state from localStorage
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('media_sidebar_collapsed');
|
|
||||||
return saved ? JSON.parse(saved) : false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for sidebar collapse state changes
|
|
||||||
useEffect(() => {
|
|
||||||
const handleStorage = () => {
|
|
||||||
const saved = localStorage.getItem('media_sidebar_collapsed');
|
|
||||||
if (saved) {
|
|
||||||
setSidebarCollapsed(JSON.parse(saved));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorage);
|
|
||||||
// Also poll localStorage every 100ms to catch same-window changes
|
|
||||||
const interval = setInterval(handleStorage, 100);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('storage', handleStorage);
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Set document title for media pages
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `Media Gallery | ${orgName}`;
|
|
||||||
}, [orgName]);
|
|
||||||
|
|
||||||
// Calculate main content left margin based on sidebar state and screen size
|
|
||||||
const mainContentMarginLeft = isMobile ? 0 : sidebarCollapsed ? 64 : 256;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfigProvider
|
|
||||||
theme={{
|
|
||||||
algorithm: theme.darkAlgorithm,
|
|
||||||
token: {
|
|
||||||
colorPrimary,
|
|
||||||
colorBgBase,
|
|
||||||
colorBgContainer,
|
|
||||||
colorBgElevated: colorBgContainer,
|
|
||||||
colorBorder: hexToRgba(colorPrimary, 0.2),
|
|
||||||
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
|
||||||
borderRadius: 12,
|
|
||||||
colorLink: colorPrimary,
|
|
||||||
colorText: 'rgba(255, 255, 255, 0.85)',
|
|
||||||
colorTextSecondary: 'rgba(255, 255, 255, 0.65)',
|
|
||||||
colorTextTertiary: 'rgba(255, 255, 255, 0.45)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChatBarProvider>
|
|
||||||
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
|
|
||||||
{/* Desktop: Show sidebar, Mobile: Hide */}
|
|
||||||
{!isMobile && <MediaSidebar />}
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<main
|
|
||||||
style={{
|
|
||||||
marginLeft: mainContentMarginLeft,
|
|
||||||
minHeight: '100vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingBottom: 48, // Space for bottom search bar
|
|
||||||
transition: 'margin-left 0.3s ease',
|
|
||||||
background: colorBgBase,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
margin: '0 auto',
|
|
||||||
padding: isMobile ? '8px 8px' : '12px 12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Mobile: Show bottom nav, Desktop: Hide */}
|
|
||||||
<MediaBottomNav />
|
|
||||||
|
|
||||||
{/* Chat reply notifications */}
|
|
||||||
<ChatNotificationToast
|
|
||||||
notifications={notifications}
|
|
||||||
clearNotification={clearNotification}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Messenger-style chat bar */}
|
|
||||||
<ChatBar />
|
|
||||||
</Layout>
|
|
||||||
</ChatBarProvider>
|
|
||||||
</ConfigProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { Spin, Result } from 'antd';
|
import { Spin, Result } from 'antd';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import { hasAnyRole } from '@/utils/roles';
|
|
||||||
import type { UserRole } from '@/types/api';
|
import type { UserRole } from '@/types/api';
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
@ -34,7 +33,7 @@ export default function ProtectedRoute({
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiredRoles && user && !hasAnyRole(user, requiredRoles)) {
|
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
|
||||||
return (
|
return (
|
||||||
<Result
|
<Result
|
||||||
status="403"
|
status="403"
|
||||||
|
|||||||
@ -1,65 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ConfigProvider, Layout, Typography, theme, Space } from 'antd';
|
import { ConfigProvider, Layout, Typography, theme } from 'antd';
|
||||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
import { Outlet, Link } from 'react-router-dom';
|
||||||
import { PlayCircleOutlined, PlusCircleOutlined, FileTextOutlined, LoginOutlined, LogoutOutlined } from '@ant-design/icons';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
import AuthModal from '@/components/AuthModal';
|
|
||||||
|
|
||||||
const { Header, Content, Footer } = Layout;
|
const { Header, Content, Footer } = Layout;
|
||||||
|
|
||||||
const navItemStyle: React.CSSProperties = {
|
|
||||||
color: 'rgba(255, 255, 255, 0.85)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 14,
|
|
||||||
transition: 'color 0.2s',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: 0,
|
|
||||||
font: 'inherit',
|
|
||||||
};
|
|
||||||
|
|
||||||
function NavLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
style={navItemStyle}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span>{label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavButton({ onClick, icon, label }: { onClick: () => void; icon: React.ReactNode; label: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') onClick(); }}
|
|
||||||
style={navItemStyle}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span>{label}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicLayout() {
|
export default function PublicLayout() {
|
||||||
const { settings } = useSettingsStore();
|
const { settings } = useSettingsStore();
|
||||||
const { isAuthenticated, logout } = useAuthStore();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
|
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
|
||||||
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
||||||
@ -106,13 +53,12 @@ export default function PublicLayout() {
|
|||||||
background: headerGradient,
|
background: headerGradient,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'center',
|
||||||
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
|
||||||
@ -125,23 +71,6 @@ export default function PublicLayout() {
|
|||||||
{orgName}
|
{orgName}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Right: Navigation */}
|
|
||||||
<Space size={16} wrap>
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<>
|
|
||||||
<NavLink to="/campaigns/create" icon={<PlusCircleOutlined />} label="Create Campaign" />
|
|
||||||
<NavLink to="/campaigns/mine" icon={<FileTextOutlined />} label="My Campaigns" />
|
|
||||||
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<NavButton onClick={() => setAuthModalOpen(true)} icon={<PlusCircleOutlined />} label="Create Campaign" />
|
|
||||||
<NavButton onClick={() => setAuthModalOpen(true)} icon={<LoginOutlined />} label="Sign In" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<NavLink to="/gallery" icon={<PlayCircleOutlined />} label="Media Gallery" />
|
|
||||||
</Space>
|
|
||||||
</Header>
|
</Header>
|
||||||
<Content
|
<Content
|
||||||
style={{
|
style={{
|
||||||
@ -165,29 +94,10 @@ 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 }}>
|
||||||
Campaigns
|
Return to Main Page
|
||||||
</Link>
|
|
||||||
{' • '}
|
|
||||||
<Link to="/campaigns/create" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
|
||||||
Create Campaign
|
|
||||||
</Link>
|
|
||||||
{' • '}
|
|
||||||
<Link to="/gallery" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
|
||||||
Media Gallery
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</Footer>
|
||||||
|
|
||||||
<AuthModal
|
|
||||||
open={authModalOpen}
|
|
||||||
onCancel={() => setAuthModalOpen(false)}
|
|
||||||
onSuccess={() => {
|
|
||||||
setAuthModalOpen(false);
|
|
||||||
navigate('/campaigns/create');
|
|
||||||
}}
|
|
||||||
title="Sign in to Create a Campaign"
|
|
||||||
subtitle="Sign in or create an account to submit your own campaign"
|
|
||||||
/>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
NodeIndexOutlined,
|
NodeIndexOutlined,
|
||||||
MenuOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@ -17,11 +16,9 @@ const NAV_ITEMS = [
|
|||||||
|
|
||||||
interface VolunteerFooterNavProps {
|
interface VolunteerFooterNavProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
onMenuOpen?: () => void;
|
|
||||||
menuActive?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = false }: VolunteerFooterNavProps) {
|
export default function VolunteerFooterNav({ style }: VolunteerFooterNavProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@ -50,29 +47,6 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
|
|||||||
...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,7 +15,6 @@ interface AddLocationDrawerProps {
|
|||||||
userRole: UserRole;
|
userRole: UserRole;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
shiftId?: string;
|
shiftId?: string;
|
||||||
zIndex?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcomeKeys: VisitOutcome[] = [
|
const outcomeKeys: VisitOutcome[] = [
|
||||||
@ -36,13 +35,12 @@ 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, reverseGeocode } = useCanvassStore();
|
const { addLocation, recordVisit, 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);
|
||||||
@ -101,16 +99,24 @@ export default function AddLocationDrawer({
|
|||||||
if (showDetailFields && notes) locationData.notes = notes;
|
if (showDetailFields && notes) locationData.notes = notes;
|
||||||
|
|
||||||
// Create location
|
// Create location
|
||||||
await addLocation(locationData);
|
const newLoc = 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');
|
||||||
|
|
||||||
// TODO: Record visit on the new address
|
// Record visit on the new location
|
||||||
// Need to get addressId from created location (returned from addLocation above)
|
await recordVisit({
|
||||||
// For now, just add the location - visit can be recorded separately
|
locationId: newLoc.id,
|
||||||
|
outcome,
|
||||||
|
supportLevel,
|
||||||
|
signRequested,
|
||||||
|
signSize,
|
||||||
|
notes: notes || undefined,
|
||||||
|
sessionId,
|
||||||
|
shiftId,
|
||||||
|
});
|
||||||
|
|
||||||
message.success('Location added successfully');
|
message.success('Location added & visit recorded');
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to add location');
|
message.error('Failed to add location');
|
||||||
@ -125,7 +131,6 @@ 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,23 +1,20 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Input, Button, App, Grid } from 'antd';
|
import { Input, Button, App } 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, style }: Props) {
|
export default function AddressSearchOverlay({ onFlyTo }: 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;
|
||||||
@ -40,7 +37,10 @@ export default function AddressSearchOverlay({ onFlyTo, style }: Props) {
|
|||||||
onClick={() => setExpanded(true)}
|
onClick={() => setExpanded(true)}
|
||||||
title="Search address"
|
title="Search address"
|
||||||
style={{
|
style={{
|
||||||
...style,
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: 60,
|
||||||
|
zIndex: 1000,
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@ -63,7 +63,10 @@ export default function AddressSearchOverlay({ onFlyTo, style }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...style,
|
position: 'absolute',
|
||||||
|
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)',
|
||||||
@ -80,13 +83,7 @@ export default function AddressSearchOverlay({ onFlyTo, style }: Props) {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onPressEnter={handleSearch}
|
onPressEnter={handleSearch}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{ width: 200, background: 'rgba(255,255,255,0.1)', border: 'none', color: '#fff' }}
|
||||||
width: isMobile ? '100%' : 200,
|
|
||||||
maxWidth: isMobile ? '100%' : 200,
|
|
||||||
background: 'rgba(255,255,255,0.1)',
|
|
||||||
border: 'none',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,17 +1,14 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { MapContainer } from 'react-leaflet';
|
import { MapContainer, TileLayer } 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;
|
||||||
@ -19,13 +16,10 @@ 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, visibleCutIds, onToggleCut }: AdminLiveMapProps) {
|
export default function AdminLiveMap({ cuts, mapSettings }: 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 () => {
|
||||||
@ -48,6 +42,8 @@ export default function AdminLiveMap({ cuts, mapSettings, visibleCutIds, onToggl
|
|||||||
: 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}
|
||||||
@ -56,22 +52,9 @@ export default function AdminLiveMap({ cuts, mapSettings, visibleCutIds, onToggl
|
|||||||
zoomControl={true}
|
zoomControl={true}
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
>
|
>
|
||||||
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
<TileLayer
|
||||||
|
attribution='© <a href="https://carto.com">CARTO</a>'
|
||||||
<TileLayerToggle
|
url={DARK_TILE}
|
||||||
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} />
|
||||||
|
|||||||
@ -1,418 +0,0 @@
|
|||||||
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,4 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Button, Badge } from 'antd';
|
import { Button, Badge } from 'antd';
|
||||||
import {
|
import {
|
||||||
AimOutlined,
|
AimOutlined,
|
||||||
@ -10,51 +9,6 @@ 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;
|
||||||
@ -93,8 +47,7 @@ export default function CanvassBottomToolbar({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: `max(${bottomOffset}px, calc(${bottomOffset}px + env(safe-area-inset-bottom)))`,
|
bottom: bottomOffset,
|
||||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
@ -107,53 +60,59 @@ export default function CanvassBottomToolbar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{onMenuOpen && (
|
{onMenuOpen && (
|
||||||
<ToolbarButton
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
icon={<MenuOutlined />}
|
icon={<MenuOutlined />}
|
||||||
onClick={onMenuOpen}
|
onClick={onMenuOpen}
|
||||||
label="Open menu"
|
size="middle"
|
||||||
|
aria-label="Open menu"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sessionActive && (
|
{sessionActive && (
|
||||||
<>
|
<>
|
||||||
<ToolbarButton
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<ArrowRightOutlined />}
|
icon={<ArrowRightOutlined />}
|
||||||
onClick={onNextDoor}
|
onClick={onNextDoor}
|
||||||
label="Next door"
|
size="middle"
|
||||||
|
aria-label="Next door"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</ToolbarButton>
|
</Button>
|
||||||
<ToolbarButton
|
<Button
|
||||||
type={routeVisible ? 'primary' : 'default'}
|
type={routeVisible ? 'primary' : 'default'}
|
||||||
icon={<NodeIndexOutlined />}
|
icon={<NodeIndexOutlined />}
|
||||||
onClick={onToggleRoute}
|
onClick={onToggleRoute}
|
||||||
|
size="middle"
|
||||||
ghost={routeVisible}
|
ghost={routeVisible}
|
||||||
label="Toggle walking route"
|
aria-label="Toggle walking route"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ToolbarButton
|
<Button
|
||||||
type={gpsFollowing ? 'primary' : 'default'}
|
type={gpsFollowing ? 'primary' : 'default'}
|
||||||
icon={<AimOutlined />}
|
icon={<AimOutlined />}
|
||||||
onClick={onToggleGps}
|
onClick={onToggleGps}
|
||||||
|
size="middle"
|
||||||
ghost={gpsFollowing}
|
ghost={gpsFollowing}
|
||||||
label="Toggle GPS following"
|
aria-label="Toggle GPS following"
|
||||||
/>
|
/>
|
||||||
{onToggleFullscreen && (
|
{onToggleFullscreen && (
|
||||||
<ToolbarButton
|
<Button
|
||||||
type={fullscreen ? 'primary' : 'default'}
|
type={fullscreen ? 'primary' : 'default'}
|
||||||
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||||
onClick={onToggleFullscreen}
|
onClick={onToggleFullscreen}
|
||||||
label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
size="middle"
|
||||||
|
aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onAddAtCenter && (
|
{onAddAtCenter && (
|
||||||
<ToolbarButton
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={onAddAtCenter}
|
onClick={onAddAtCenter}
|
||||||
label="Add location at crosshair"
|
size="middle"
|
||||||
|
aria-label="Add location at crosshair"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sessionActive && (
|
{sessionActive && (
|
||||||
|
|||||||
@ -9,11 +9,7 @@ const items: { key: string; label: string; color: string }[] = [
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
interface CanvassLegendProps {
|
export default function CanvassLegend() {
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CanvassLegend({ style }: CanvassLegendProps = {}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -25,7 +21,6 @@ export default function CanvassLegend({ style }: CanvassLegendProps = {}) {
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
maxWidth: 180,
|
maxWidth: 180,
|
||||||
...style,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Icon type indicators */}
|
{/* Icon type indicators */}
|
||||||
|
|||||||
@ -1,335 +0,0 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { Marker, Popup } from 'react-leaflet';
|
|
||||||
import { Alert, theme } from 'antd';
|
|
||||||
import L from 'leaflet';
|
|
||||||
import type { CanvassAddress, AddressGroup, VisitOutcome } from '@/types/canvass';
|
|
||||||
import { VISIT_OUTCOME_COLORS, VISIT_OUTCOME_LABELS } from '@/types/canvass';
|
|
||||||
import { sanitizeHtml } from '@/utils/sanitize';
|
|
||||||
|
|
||||||
interface CanvassMarkerGroupProps {
|
|
||||||
group: AddressGroup;
|
|
||||||
selectedAddressId: string | null;
|
|
||||||
onAddressClick: (addressId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marker size constants
|
|
||||||
const MARKER_SIZE_DEFAULT = 26;
|
|
||||||
const MARKER_SIZE_SELECTED = 34;
|
|
||||||
const MARKER_TOUCH_TARGET = 44;
|
|
||||||
const MARKER_ANCHOR_OFFSET = 22;
|
|
||||||
|
|
||||||
function getMarkerColor(address: CanvassAddress): string {
|
|
||||||
if (!address.lastVisit) return '#95a5a6'; // gray — unvisited
|
|
||||||
return VISIT_OUTCOME_COLORS[address.lastVisit.outcome] ?? '#95a5a6';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDominantOutcomeColor(addresses: CanvassAddress[]): string {
|
|
||||||
const outcomeCounts: Record<string, number> = {};
|
|
||||||
let unvisitedCount = 0;
|
|
||||||
|
|
||||||
for (const addr of addresses) {
|
|
||||||
if (addr.lastVisit) {
|
|
||||||
const outcome = addr.lastVisit.outcome;
|
|
||||||
outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1;
|
|
||||||
} else {
|
|
||||||
unvisitedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If any are unvisited, show gray
|
|
||||||
if (unvisitedCount > 0) return '#95a5a6';
|
|
||||||
|
|
||||||
// Otherwise, find dominant outcome
|
|
||||||
let dominant: string | null = null;
|
|
||||||
let maxCount = 0;
|
|
||||||
for (const [outcome, count] of Object.entries(outcomeCounts)) {
|
|
||||||
if (count > maxCount) {
|
|
||||||
maxCount = count;
|
|
||||||
dominant = outcome;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dominant ? VISIT_OUTCOME_COLORS[dominant as VisitOutcome] ?? '#95a5a6' : '#95a5a6';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline SVG for house icon
|
|
||||||
function houseSvg(color: string, size: number, selected: boolean): string {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" role="img" aria-label="Single-family home">
|
|
||||||
<title>Single-family home</title>
|
|
||||||
${selected ? `<circle cx="12" cy="12" r="12" fill="none" stroke="white" stroke-width="2" opacity="0.5"/>` : ''}
|
|
||||||
<path d="M12 3L4 10v10a1 1 0 001 1h4v-6h6v6h4a1 1 0 001-1V10z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
|
|
||||||
<path d="M12 3L4 10h16z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline SVG for apartment/building icon
|
|
||||||
function apartmentSvg(color: string, size: number, selected: boolean): string {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" role="img" aria-label="Multi-unit apartment building">
|
|
||||||
<title>Multi-unit apartment building</title>
|
|
||||||
${selected ? `<circle cx="12" cy="12" r="12" fill="none" stroke="white" stroke-width="2" opacity="0.5"/>` : ''}
|
|
||||||
<rect x="4" y="3" width="16" height="18" rx="1" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
|
|
||||||
<rect x="7" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
|
|
||||||
<rect x="14" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
|
|
||||||
<rect x="7" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
|
|
||||||
<rect x="14" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
|
|
||||||
<rect x="10" y="16" width="4" height="5" rx="0.3" fill="rgba(255,255,255,0.4)"/>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact dropdown UI for multi-unit buildings (replaces long scrollable list) */
|
|
||||||
function MultiUnitPopup({
|
|
||||||
group,
|
|
||||||
addresses,
|
|
||||||
selectedAddressId,
|
|
||||||
onAddressClick,
|
|
||||||
token,
|
|
||||||
}: {
|
|
||||||
group: AddressGroup;
|
|
||||||
addresses: CanvassAddress[];
|
|
||||||
selectedAddressId: string | null;
|
|
||||||
onAddressClick: (addressId: string) => void;
|
|
||||||
token: ReturnType<typeof theme.useToken>['token'];
|
|
||||||
}) {
|
|
||||||
// Default to the selected address in this building, or the first address
|
|
||||||
const initialId = addresses.find((a) => a.id === selectedAddressId)?.id ?? addresses[0]?.id ?? '';
|
|
||||||
const [viewingId, setViewingId] = useState(initialId);
|
|
||||||
const viewingAddr = addresses.find((a) => a.id === viewingId) ?? addresses[0];
|
|
||||||
|
|
||||||
const visitedCount = addresses.filter((a) => a.lastVisit).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: `2px solid ${token.colorPrimary}` }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: token.colorPrimary }}>
|
|
||||||
🏢 {group.baseAddress}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
|
|
||||||
{addresses.length} units · {visitedCount} visited
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Building notes */}
|
|
||||||
{group.buildingNotes && (
|
|
||||||
<Alert
|
|
||||||
message="Building Notes"
|
|
||||||
description={
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(group.buildingNotes) }} />
|
|
||||||
}
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 12, fontSize: 11 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Native <select> dropdown — Ant Design Select doesn't work inside Leaflet popups */}
|
|
||||||
<select
|
|
||||||
value={viewingId}
|
|
||||||
onChange={(e) => setViewingId(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 10px',
|
|
||||||
fontSize: 13,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid #d9d9d9',
|
|
||||||
background: '#fafafa',
|
|
||||||
marginBottom: 10,
|
|
||||||
cursor: 'pointer',
|
|
||||||
appearance: 'auto',
|
|
||||||
}}
|
|
||||||
aria-label="Select unit"
|
|
||||||
>
|
|
||||||
{addresses.map((addr) => {
|
|
||||||
const status = addr.lastVisit
|
|
||||||
? VISIT_OUTCOME_LABELS[addr.lastVisit.outcome]
|
|
||||||
: 'Not visited';
|
|
||||||
return (
|
|
||||||
<option key={addr.id} value={addr.id}>
|
|
||||||
Unit {addr.unitNumber || '—'} — {status}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Selected unit detail card */}
|
|
||||||
{viewingAddr && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onAddressClick(viewingAddr.id)}
|
|
||||||
aria-label={`Record visit for unit ${viewingAddr.unitNumber || 'main'}`}
|
|
||||||
style={{
|
|
||||||
all: 'unset',
|
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
padding: 10,
|
|
||||||
borderRadius: 8,
|
|
||||||
border: `1px solid ${viewingAddr.id === selectedAddressId ? token.colorPrimary : '#e8e8e8'}`,
|
|
||||||
background: viewingAddr.id === selectedAddressId ? 'rgba(52, 152, 219, 0.06)' : '#fafafa',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewingAddr.unitNumber && (
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#333', marginBottom: 4 }}>
|
|
||||||
Unit {viewingAddr.unitNumber}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{viewingAddr.firstName && (
|
|
||||||
<div style={{ fontSize: 12, color: '#555', marginBottom: 4 }}>
|
|
||||||
{viewingAddr.firstName} {viewingAddr.lastName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 12, marginBottom: viewingAddr.notes ? 4 : 0 }}>
|
|
||||||
{viewingAddr.lastVisit ? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: getMarkerColor(viewingAddr),
|
|
||||||
marginRight: 5,
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ verticalAlign: 'middle' }}>
|
|
||||||
{VISIT_OUTCOME_LABELS[viewingAddr.lastVisit.outcome]}
|
|
||||||
</span>
|
|
||||||
{viewingAddr.lastVisit.visitorName && (
|
|
||||||
<span style={{ fontSize: 11, color: '#888', marginLeft: 6 }}>
|
|
||||||
by {viewingAddr.lastVisit.visitorName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#999' }}>Not visited</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{viewingAddr.notes && (
|
|
||||||
<div style={{ fontSize: 11, color: '#888', fontStyle: 'italic' }}>
|
|
||||||
Note: {viewingAddr.notes}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 11, color: '#1890ff', marginTop: 8, textAlign: 'center' }}>
|
|
||||||
Tap to record visit
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CanvassMarkerGroup({ group, selectedAddressId, onAddressClick }: CanvassMarkerGroupProps) {
|
|
||||||
const addresses = group.addresses;
|
|
||||||
const isMultiUnit = group.isMultiUnit;
|
|
||||||
const hasAnySelected = addresses.some((addr) => addr.id === selectedAddressId);
|
|
||||||
const size = hasAnySelected ? MARKER_SIZE_SELECTED : MARKER_SIZE_DEFAULT;
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
|
|
||||||
// For multi-unit, use dominant outcome color; for single-unit, use that address's color
|
|
||||||
const color = useMemo(() => {
|
|
||||||
if (isMultiUnit) {
|
|
||||||
return getDominantOutcomeColor(addresses);
|
|
||||||
} else {
|
|
||||||
return getMarkerColor(addresses[0]!);
|
|
||||||
}
|
|
||||||
}, [addresses, isMultiUnit]);
|
|
||||||
|
|
||||||
const icon = useMemo(() => {
|
|
||||||
const svgHtml = isMultiUnit
|
|
||||||
? apartmentSvg(color, size, hasAnySelected)
|
|
||||||
: houseSvg(color, size, hasAnySelected);
|
|
||||||
|
|
||||||
return L.divIcon({
|
|
||||||
html: `<div style="width:${MARKER_TOUCH_TARGET}px;height:${MARKER_TOUCH_TARGET}px;display:flex;align-items:center;justify-content:center">${svgHtml}</div>`,
|
|
||||||
iconSize: [MARKER_TOUCH_TARGET, MARKER_TOUCH_TARGET],
|
|
||||||
iconAnchor: [MARKER_ANCHOR_OFFSET, MARKER_ANCHOR_OFFSET],
|
|
||||||
className: '',
|
|
||||||
});
|
|
||||||
}, [color, size, hasAnySelected, isMultiUnit]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
position={[group.latitude, group.longitude]}
|
|
||||||
icon={icon}
|
|
||||||
// @ts-expect-error - Pass group data for cluster icon access
|
|
||||||
addressGroup={group}
|
|
||||||
>
|
|
||||||
<Popup maxWidth={350} minWidth={250}>
|
|
||||||
<div style={{ minWidth: 230, maxWidth: 330 }}>
|
|
||||||
{isMultiUnit ? (
|
|
||||||
// Multi-unit building display — compact dropdown
|
|
||||||
<MultiUnitPopup
|
|
||||||
group={group}
|
|
||||||
addresses={addresses}
|
|
||||||
selectedAddressId={selectedAddressId}
|
|
||||||
onAddressClick={onAddressClick}
|
|
||||||
token={token}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
// Single unit display
|
|
||||||
<div style={{ cursor: 'pointer' }} onClick={() => onAddressClick(addresses[0]!.id)}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
|
|
||||||
{group.baseAddress}
|
|
||||||
</div>
|
|
||||||
{addresses[0]?.unitNumber && (
|
|
||||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
|
||||||
Unit {addresses[0].unitNumber}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{addresses[0]?.firstName && (
|
|
||||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
|
||||||
{addresses[0].firstName} {addresses[0].lastName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{addresses[0]?.lastVisit ? (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: getMarkerColor(addresses[0]),
|
|
||||||
marginRight: 4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{VISIT_OUTCOME_LABELS[addresses[0].lastVisit.outcome]}
|
|
||||||
</div>
|
|
||||||
{addresses[0].lastVisit.visitorName && (
|
|
||||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
|
|
||||||
by {addresses[0].lastVisit.visitorName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>Not visited</div>
|
|
||||||
)}
|
|
||||||
{addresses[0]?.notes && (
|
|
||||||
<div style={{ fontSize: 11, color: '#888', marginTop: 4, fontStyle: 'italic' }}>
|
|
||||||
Note: {addresses[0].notes}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 11, color: '#1890ff', marginTop: 8, textAlign: 'center' }}>
|
|
||||||
Click to record visit
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoize component to prevent re-renders when props haven't changed
|
|
||||||
export default React.memo(CanvassMarkerGroup, (prevProps, nextProps) => {
|
|
||||||
// Only re-render if these specific props change
|
|
||||||
return (
|
|
||||||
prevProps.group.locationId === nextProps.group.locationId &&
|
|
||||||
prevProps.group.addresses === nextProps.group.addresses &&
|
|
||||||
prevProps.selectedAddressId === nextProps.selectedAddressId &&
|
|
||||||
prevProps.onAddressClick === nextProps.onAddressClick
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -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,10 +19,9 @@ export default function LocationEditDrawer({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
location,
|
location,
|
||||||
zIndex = 1000,
|
|
||||||
}: LocationEditDrawerProps) {
|
}: LocationEditDrawerProps) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
// TODO: Update to work with Address model instead of deprecated CanvassLocation
|
const { updateLocationFields } = useCanvassStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location && open) {
|
if (location && open) {
|
||||||
@ -41,16 +40,20 @@ export default function LocationEditDrawer({
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!location) return;
|
if (!location) return;
|
||||||
message.warning('Location editing temporarily disabled - needs Address model update');
|
try {
|
||||||
onClose();
|
const values = await form.validateFields();
|
||||||
// TODO: Implement address update API call
|
await updateLocationFields(location.id, values);
|
||||||
|
message.success('Location updated');
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
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,23 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Input, Space, Typography, message, Alert, Dropdown, Modal, Row, Col, Grid } from 'antd';
|
import { Button, Input, Space, Typography, message } from 'antd';
|
||||||
import { FormOutlined, ArrowRightOutlined, WarningOutlined } from '@ant-design/icons';
|
import type { VisitOutcome, RecordVisitPayload, CanvassLocation } from '@/types/canvass';
|
||||||
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 {
|
||||||
address: CanvassAddress;
|
location: CanvassLocation;
|
||||||
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[] = [
|
||||||
@ -33,25 +27,18 @@ 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({
|
||||||
address,
|
location,
|
||||||
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';
|
||||||
|
|
||||||
@ -62,7 +49,7 @@ export default function VisitRecordingForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await onRecord({
|
await onRecord({
|
||||||
addressId: address.id, // Changed from locationId
|
locationId: location.id,
|
||||||
outcome,
|
outcome,
|
||||||
supportLevel,
|
supportLevel,
|
||||||
signRequested,
|
signRequested,
|
||||||
@ -72,11 +59,6 @@ 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);
|
||||||
@ -85,166 +67,81 @@ 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' }}>
|
||||||
{address.firstName && (
|
<Typography.Text strong style={{ fontSize: 15, display: 'block', marginBottom: 8 }}>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
{location.address || 'Unknown Address'}
|
||||||
{address.firstName} {address.lastName}
|
{location.unitNumber && ` #${location.unitNumber}`}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
{location.firstName && (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
{location.firstName} {location.lastName}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Building notes for multi-unit */}
|
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||||
{address.location.buildingNotes && (
|
Outcome
|
||||||
<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>
|
||||||
<Row gutter={[6, 6]} style={{ marginBottom: 12 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, 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 (
|
||||||
<Col
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
xs={isNarrow ? 12 : 8}
|
size="middle"
|
||||||
sm={8}
|
type={selected ? 'primary' : 'default'}
|
||||||
md={6}
|
style={{
|
||||||
|
borderColor: color,
|
||||||
|
background: selected ? color : 'transparent',
|
||||||
|
color: selected ? '#fff' : color,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
onClick={() => setOutcome(key)}
|
||||||
>
|
>
|
||||||
<Button
|
{VISIT_OUTCOME_LABELS[key]}
|
||||||
block
|
</Button>
|
||||||
size="large"
|
|
||||||
type={selected ? 'primary' : 'default'}
|
|
||||||
style={{
|
|
||||||
borderColor: color,
|
|
||||||
background: selected ? color : 'transparent',
|
|
||||||
color: selected ? '#fff' : color,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: key === 'SPOKE_WITH' ? 600 : 400,
|
|
||||||
}}
|
|
||||||
onClick={() => setOutcome(key)}
|
|
||||||
>
|
|
||||||
{VISIT_OUTCOME_LABELS[key]}
|
|
||||||
</Button>
|
|
||||||
</Col>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Row>
|
</div>
|
||||||
|
|
||||||
{showDetailFields && outcome === 'SPOKE_WITH' && (
|
{showDetailFields && outcome === 'SPOKE_WITH' && (
|
||||||
<>
|
<>
|
||||||
<Typography.Text
|
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Support Level
|
Support Level
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Row gutter={[8, 8]} justify="space-between" style={{ marginBottom: 12 }}>
|
<Space style={{ marginBottom: 12 }}>
|
||||||
{supportLevelKeys.map((key) => (
|
{supportLevelKeys.map((key) => (
|
||||||
<Col key={key} xs={6} sm={6}>
|
<Button
|
||||||
<div style={{ textAlign: 'center' }}>
|
key={key}
|
||||||
<Button
|
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],
|
fontWeight: 700,
|
||||||
fontWeight: 700,
|
}}
|
||||||
}}
|
onClick={() => setSupportLevel(supportLevel === key ? undefined : key)}
|
||||||
onClick={() => setSupportLevel(supportLevel === key ? undefined : key)}
|
>
|
||||||
>
|
{key.replace('LEVEL_', '')}
|
||||||
{key.replace('LEVEL_', '')}
|
</Button>
|
||||||
</Button>
|
|
||||||
<div style={{ fontSize: 11, marginTop: 4, color: 'rgba(255,255,255,0.6)' }}>
|
|
||||||
{SUPPORT_LEVEL_LABELS[key]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Space>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
{supportLevel && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{SUPPORT_LEVEL_LABELS[supportLevel]}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Typography.Text
|
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||||
strong
|
Sign
|
||||||
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
|
||||||
@ -273,18 +170,8 @@ export default function VisitRecordingForm({
|
|||||||
|
|
||||||
{showDetailFields && (
|
{showDetailFields && (
|
||||||
<>
|
<>
|
||||||
<Typography.Text
|
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||||
strong
|
Notes
|
||||||
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}
|
||||||
@ -296,63 +183,16 @@ export default function VisitRecordingForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Space style={{ width: '100%' }} direction="vertical" size="small">
|
<Button
|
||||||
<Button
|
type="primary"
|
||||||
type="primary"
|
block
|
||||||
block
|
size="large"
|
||||||
size="large"
|
onClick={handleSubmit}
|
||||||
onClick={handleSubmit}
|
loading={recording}
|
||||||
loading={recording}
|
disabled={!outcome}
|
||||||
disabled={!outcome}
|
>
|
||||||
>
|
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,16 +1,12 @@
|
|||||||
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, Grid, Alert } from 'antd';
|
import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List } 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';
|
||||||
@ -19,35 +15,21 @@ 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;
|
||||||
@ -63,93 +45,15 @@ export default function VolunteerMapDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
placement="bottom"
|
placement="left"
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
height="auto"
|
width={300}
|
||||||
closable={false}
|
|
||||||
mask={false}
|
|
||||||
maskClosable={false}
|
|
||||||
zIndex={1150}
|
|
||||||
styles={{
|
styles={{
|
||||||
wrapper: {
|
body: { padding: '16px', display: 'flex', flexDirection: 'column' },
|
||||||
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>
|
||||||
@ -167,8 +71,8 @@ export default function VolunteerMapDrawer({
|
|||||||
|
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
{/* Assignments (hidden when session active) */}
|
{/* Assignments */}
|
||||||
{!sessionActive && assignments.length > 0 && (
|
{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
|
||||||
@ -207,32 +111,28 @@ export default function VolunteerMapDrawer({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Free session — pick a cut (hidden when session active) */}
|
{/* Free session — pick a cut */}
|
||||||
{!sessionActive && (
|
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||||
<>
|
Start Session (Any Cut)
|
||||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
</Typography.Text>
|
||||||
Start Session (Any Cut)
|
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||||
</Typography.Text>
|
<Select
|
||||||
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
placeholder="Select a cut..."
|
||||||
<Select
|
style={{ flex: 1 }}
|
||||||
placeholder="Select a cut..."
|
value={freeCutId}
|
||||||
style={{ flex: 1 }}
|
onChange={setFreeCutId}
|
||||||
value={freeCutId}
|
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
|
||||||
onChange={setFreeCutId}
|
allowClear
|
||||||
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
|
/>
|
||||||
allowClear
|
<Button
|
||||||
/>
|
type="primary"
|
||||||
<Button
|
icon={<AimOutlined />}
|
||||||
type="primary"
|
disabled={!freeCutId}
|
||||||
icon={<AimOutlined />}
|
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
|
||||||
disabled={!freeCutId}
|
>
|
||||||
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
|
Go
|
||||||
>
|
</Button>
|
||||||
Go
|
</Space.Compact>
|
||||||
</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation links */}
|
{/* Navigation links */}
|
||||||
<Button
|
<Button
|
||||||
@ -258,7 +158,6 @@ export default function VolunteerMapDrawer({
|
|||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,8 +26,7 @@ export default function VolunteerSessionBar({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: `max(60px, calc(56px + 4px + env(safe-area-inset-bottom)))`,
|
bottom: 60,
|
||||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import {
|
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
|
||||||
ResponsiveContainer, Cell,
|
|
||||||
} from 'recharts';
|
|
||||||
import { Typography } from 'antd';
|
|
||||||
import type { ContainerResource } from '@/types/api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface ContainerMemoryChartProps {
|
|
||||||
containers: ContainerResource[];
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function memColor(mb: number, maxMb: number): string {
|
|
||||||
const ratio = maxMb > 0 ? mb / maxMb : 0;
|
|
||||||
if (ratio > 0.7) return '#ff4d4f';
|
|
||||||
if (ratio > 0.4) return '#faad14';
|
|
||||||
return '#52c41a';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContainerMemoryChart({ containers, height = 180 }: ContainerMemoryChartProps) {
|
|
||||||
const sorted = [...containers]
|
|
||||||
.filter(c => c.memoryMB > 0)
|
|
||||||
.sort((a, b) => b.memoryMB - a.memoryMB);
|
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
|
||||||
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 16 }}>No container data</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxMem = sorted[0]?.memoryMB ?? 1;
|
|
||||||
const chartData = sorted.map(c => ({ name: c.label, memory: c.memoryMB }));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 16, left: 4, bottom: 0 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} horizontal={false} />
|
|
||||||
<XAxis type="number" tick={{ fontSize: 10 }} unit="MB" />
|
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={70} />
|
|
||||||
<Tooltip formatter={(v) => `${v} MB`} contentStyle={{ fontSize: 12, borderRadius: 6 }} />
|
|
||||||
<Bar dataKey="memory" radius={[0, 4, 4, 0]}>
|
|
||||||
{chartData.map((entry, i) => (
|
|
||||||
<Cell key={i} fill={memColor(entry.memory, maxMem)} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { Popover, Progress, Typography, Space, Flex } from 'antd';
|
|
||||||
import type { ContainerResource } from '@/types/api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface ContainerPopoverProps {
|
|
||||||
resource?: ContainerResource;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContainerPopover({ resource, children }: ContainerPopoverProps) {
|
|
||||||
if (!resource) return <>{children}</>;
|
|
||||||
|
|
||||||
const memPct = resource.memoryLimitMB > 0
|
|
||||||
? Math.round((resource.memoryMB / resource.memoryLimitMB) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<Space direction="vertical" size={4} style={{ width: 200 }}>
|
|
||||||
<Flex justify="space-between">
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>CPU</Text>
|
|
||||||
<Text style={{ fontSize: 12 }}>{resource.cpuPercent.toFixed(1)}%</Text>
|
|
||||||
</Flex>
|
|
||||||
<Progress
|
|
||||||
percent={Math.min(resource.cpuPercent, 100)}
|
|
||||||
size="small"
|
|
||||||
showInfo={false}
|
|
||||||
strokeColor={resource.cpuPercent > 80 ? '#ff4d4f' : resource.cpuPercent > 50 ? '#faad14' : '#52c41a'}
|
|
||||||
/>
|
|
||||||
<Flex justify="space-between">
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>Memory</Text>
|
|
||||||
<Text style={{ fontSize: 12 }}>{resource.memoryMB}MB{resource.memoryLimitMB > 0 ? ` / ${resource.memoryLimitMB}MB` : ''}</Text>
|
|
||||||
</Flex>
|
|
||||||
{resource.memoryLimitMB > 0 && (
|
|
||||||
<Progress
|
|
||||||
percent={memPct}
|
|
||||||
size="small"
|
|
||||||
showInfo={false}
|
|
||||||
strokeColor={memPct > 80 ? '#ff4d4f' : memPct > 60 ? '#faad14' : '#52c41a'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Flex justify="space-between">
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>Network Rx</Text>
|
|
||||||
<Text style={{ fontSize: 12 }}>{resource.networkRxKBps.toFixed(1)} KB/s</Text>
|
|
||||||
</Flex>
|
|
||||||
<Flex justify="space-between">
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>Network Tx</Text>
|
|
||||||
<Text style={{ fontSize: 12 }}>{resource.networkTxKBps.toFixed(1)} KB/s</Text>
|
|
||||||
</Flex>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover content={content} title={resource.label} trigger="hover" placement="top">
|
|
||||||
{children}
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import {
|
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
|
||||||
Legend, ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import { Typography } from 'antd';
|
|
||||||
import type { TimeSeriesResult } from '@/types/api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface LatencyBandsChartProps {
|
|
||||||
data: TimeSeriesResult;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
|
||||||
const d = new Date(ts * 1000);
|
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LatencyBandsChart({ data, height = 200 }: LatencyBandsChartProps) {
|
|
||||||
const p50 = data.latency_p50;
|
|
||||||
const p95 = data.latency_p95;
|
|
||||||
const p99 = data.latency_p99;
|
|
||||||
|
|
||||||
if (!p50?.timestamps?.length) {
|
|
||||||
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 24 }}>No latency data</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartData = p50.timestamps.map((ts, i) => ({
|
|
||||||
time: formatTime(ts),
|
|
||||||
p50: Math.round((p50.values[i] || 0) * 1000),
|
|
||||||
p95: Math.round((p95?.values[i] || 0) * 1000),
|
|
||||||
p99: Math.round((p99?.values[i] || 0) * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
|
|
||||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
|
||||||
<YAxis tick={{ fontSize: 10 }} unit="ms" />
|
|
||||||
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} formatter={(v) => `${v}ms`} />
|
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
|
||||||
<Area type="monotone" dataKey="p99" stroke="#ff4d4f" fill="#ff4d4f" fillOpacity={0.15} />
|
|
||||||
<Area type="monotone" dataKey="p95" stroke="#faad14" fill="#faad14" fillOpacity={0.25} />
|
|
||||||
<Area type="monotone" dataKey="p50" stroke="#52c41a" fill="#52c41a" fillOpacity={0.4} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
|
||||||
|
|
||||||
interface DonutDatum {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MiniDonutChartProps {
|
|
||||||
data: DonutDatum[];
|
|
||||||
height?: number;
|
|
||||||
innerRadius?: number;
|
|
||||||
outerRadius?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MiniDonutChart({
|
|
||||||
data,
|
|
||||||
height = 120,
|
|
||||||
innerRadius = 28,
|
|
||||||
outerRadius = 48,
|
|
||||||
}: MiniDonutChartProps) {
|
|
||||||
const filtered = data.filter(d => d.value > 0);
|
|
||||||
if (filtered.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={filtered}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={innerRadius}
|
|
||||||
outerRadius={outerRadius}
|
|
||||||
paddingAngle={2}
|
|
||||||
dataKey="value"
|
|
||||||
stroke="none"
|
|
||||||
>
|
|
||||||
{filtered.map((entry, i) => (
|
|
||||||
<Cell key={i} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value, name) => [`${value}`, `${name}`]}
|
|
||||||
contentStyle={{ fontSize: 12, padding: '4px 8px', borderRadius: 6 }}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import {
|
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
|
||||||
Legend, ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import { Typography } from 'antd';
|
|
||||||
import type { TimeSeriesResult } from '@/types/api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface RequestTrafficChartProps {
|
|
||||||
data: TimeSeriesResult;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
|
||||||
const d = new Date(ts * 1000);
|
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RequestTrafficChart({ data, height = 200 }: RequestTrafficChartProps) {
|
|
||||||
const series2xx = data.request_rate_2xx;
|
|
||||||
const series4xx = data.request_rate_4xx;
|
|
||||||
const series5xx = data.request_rate_5xx;
|
|
||||||
|
|
||||||
if (!series2xx?.timestamps?.length) {
|
|
||||||
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 24 }}>No traffic data</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartData = series2xx.timestamps.map((ts, i) => ({
|
|
||||||
time: formatTime(ts),
|
|
||||||
'2xx': series2xx.values[i] || 0,
|
|
||||||
'4xx': series4xx?.values[i] || 0,
|
|
||||||
'5xx': series5xx?.values[i] || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
|
|
||||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
|
||||||
<YAxis tick={{ fontSize: 10 }} />
|
|
||||||
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} />
|
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
|
||||||
<Area type="monotone" dataKey="2xx" stackId="1" stroke="#52c41a" fill="#52c41a" fillOpacity={0.6} />
|
|
||||||
<Area type="monotone" dataKey="4xx" stackId="1" stroke="#faad14" fill="#faad14" fillOpacity={0.6} />
|
|
||||||
<Area type="monotone" dataKey="5xx" stackId="1" stroke="#ff4d4f" fill="#ff4d4f" fillOpacity={0.6} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { Progress, Flex, Typography } from 'antd';
|
|
||||||
import type { SystemInfo } from '@/types/api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
function gaugeColor(percent: number): string {
|
|
||||||
if (percent > 90) return '#ff4d4f';
|
|
||||||
if (percent > 70) return '#faad14';
|
|
||||||
return '#52c41a';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SystemGaugesProps {
|
|
||||||
systemInfo: SystemInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SystemGauges({ systemInfo }: SystemGaugesProps) {
|
|
||||||
const cpuPercent = Math.min(
|
|
||||||
Math.round(((systemInfo.cpu.loadAvg[0] ?? 0) / systemInfo.cpu.cores) * 100),
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex justify="space-around" align="center" wrap="wrap" gap={8}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Progress
|
|
||||||
type="circle"
|
|
||||||
percent={cpuPercent}
|
|
||||||
size={72}
|
|
||||||
strokeColor={gaugeColor(cpuPercent)}
|
|
||||||
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
|
|
||||||
/>
|
|
||||||
<div><Text type="secondary" style={{ fontSize: 11 }}>CPU</Text></div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Progress
|
|
||||||
type="circle"
|
|
||||||
percent={systemInfo.memory.usagePercent}
|
|
||||||
size={72}
|
|
||||||
strokeColor={gaugeColor(systemInfo.memory.usagePercent)}
|
|
||||||
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
|
|
||||||
/>
|
|
||||||
<div><Text type="secondary" style={{ fontSize: 11 }}>Memory</Text></div>
|
|
||||||
</div>
|
|
||||||
{systemInfo.disk && (
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Progress
|
|
||||||
type="circle"
|
|
||||||
percent={systemInfo.disk.usagePercent}
|
|
||||||
size={72}
|
|
||||||
strokeColor={gaugeColor(systemInfo.disk.usagePercent)}
|
|
||||||
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
|
|
||||||
/>
|
|
||||||
<div><Text type="secondary" style={{ fontSize: 11 }}>Disk</Text></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,481 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,470 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,247 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -2,23 +2,12 @@ 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';
|
||||||
@ -49,32 +38,6 @@ 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;
|
||||||
@ -113,28 +76,19 @@ function FullscreenInvalidator() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MapEventsHandler({
|
function MapEventsHandler({ onMove }: { onMove?: (map: LeafletMap) => void }) {
|
||||||
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) {
|
||||||
setMapInstance(map); // Trigger debounced callback
|
onMove?.(map);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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) {
|
||||||
setMapInstance(map);
|
onMove?.(map);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
@ -150,22 +104,6 @@ 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,
|
||||||
@ -189,11 +127,6 @@ 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());
|
||||||
@ -202,20 +135,6 @@ 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 } })
|
||||||
@ -253,12 +172,12 @@ export default function AdminMapView({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const groups = useMemo(() => groupLocations(locations as any), [locations]);
|
const groups = useMemo(() => groupLocations(locations), [locations]);
|
||||||
|
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
return groups.filter((g) =>
|
return groups.filter((g) =>
|
||||||
g.location.addresses.some((addr) => {
|
g.locations.some((loc) => {
|
||||||
const level = addr.supportLevel || 'NONE';
|
const level = loc.supportLevel || 'NONE';
|
||||||
return visibleLevels.has(level);
|
return visibleLevels.has(level);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -346,203 +265,6 @@ 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 }}>
|
||||||
@ -555,7 +277,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 - 240px)', minHeight: 500, background: '#1a1025' }}
|
style={{ position: 'relative', width: '100%', height: 'calc(100vh - 340px)', minHeight: 500, background: '#1a1025' }}
|
||||||
>
|
>
|
||||||
{/* Support level filter overlay */}
|
{/* Support level filter overlay */}
|
||||||
<div
|
<div
|
||||||
@ -564,14 +286,13 @@ export default function AdminMapView({
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 10,
|
top: 10,
|
||||||
left: 70,
|
left: 10,
|
||||||
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 }}>
|
||||||
@ -644,39 +365,6 @@ 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
|
||||||
@ -688,12 +376,7 @@ export default function AdminMapView({
|
|||||||
>
|
>
|
||||||
<InvalidateSizeOnVisible visible={visible} />
|
<InvalidateSizeOnVisible visible={visible} />
|
||||||
<FullscreenInvalidator />
|
<FullscreenInvalidator />
|
||||||
<CenterOnSettings settings={settings} />
|
<MapEventsHandler onMove={onMapMove} />
|
||||||
<MapEventsHandler
|
|
||||||
onMove={onMapMove}
|
|
||||||
setMapInstance={setMapInstance}
|
|
||||||
setCurrentZoom={setCurrentZoom}
|
|
||||||
/>
|
|
||||||
{flyTo && <FlyToPosition position={flyTo} />}
|
{flyTo && <FlyToPosition position={flyTo} />}
|
||||||
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
||||||
|
|
||||||
@ -733,13 +416,104 @@ export default function AdminMapView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location markers with clustering */}
|
{/* Location markers */}
|
||||||
<MarkerClusterGroup
|
{filteredGroups.map((group, idx) => {
|
||||||
key={currentZoom >= 18 ? 'unclustered' : 'clustered'}
|
const color = getMarkerColor(group.dominantLevel);
|
||||||
{...clusterConfig}
|
const radius = group.isMultiUnit ? 10 : 7;
|
||||||
>
|
|
||||||
{markers}
|
return (
|
||||||
</MarkerClusterGroup>
|
<CircleMarker
|
||||||
|
key={idx}
|
||||||
|
center={[group.latitude, group.longitude]}
|
||||||
|
radius={radius}
|
||||||
|
pathOptions={{
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
color: '#fff',
|
||||||
|
weight: group.isMultiUnit ? 2 : 1,
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div style={{ minWidth: 200, maxWidth: 280 }}>
|
||||||
|
{group.locations.map((loc, i) => {
|
||||||
|
const name = [loc.firstName, loc.lastName].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={loc.id}
|
||||||
|
style={{
|
||||||
|
marginBottom: i < group.locations.length - 1 ? 10 : 0,
|
||||||
|
paddingBottom: i < group.locations.length - 1 ? 10 : 0,
|
||||||
|
borderBottom: i < group.locations.length - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
|
||||||
|
{loc.address || 'Unknown address'}
|
||||||
|
{loc.unitNumber && <Text type="secondary" style={{ fontSize: 12 }}> Unit {loc.unitNumber}</Text>}
|
||||||
|
</div>
|
||||||
|
{name && <div style={{ fontSize: 12, marginBottom: 2 }}>{name}</div>}
|
||||||
|
{loc.email && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{loc.email}</div>}
|
||||||
|
{loc.phone && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{loc.phone}</div>}
|
||||||
|
{loc.supportLevel && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: getMarkerColor(loc.supportLevel),
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{SUPPORT_LEVEL_LABELS[loc.supportLevel]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)', marginTop: 2 }}>
|
||||||
|
{loc.sign && <>Sign{loc.signSize ? ` (${loc.signSize})` : ''} · </>}
|
||||||
|
{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" />
|
||||||
@ -747,7 +521,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-left"
|
position="bottom-right"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cut overlay controls */}
|
{/* Cut overlay controls */}
|
||||||
@ -757,7 +531,6 @@ 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>
|
||||||
|
|||||||
@ -1,586 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Steps,
|
|
||||||
Button,
|
|
||||||
Space,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
Select,
|
|
||||||
Slider,
|
|
||||||
InputNumber,
|
|
||||||
Statistic,
|
|
||||||
Alert,
|
|
||||||
Progress,
|
|
||||||
Tag,
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Typography,
|
|
||||||
Spin,
|
|
||||||
Result,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
GlobalOutlined,
|
|
||||||
DatabaseOutlined,
|
|
||||||
CompassOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
CloseCircleOutlined,
|
|
||||||
LoadingOutlined,
|
|
||||||
MinusCircleOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import type {
|
|
||||||
Cut,
|
|
||||||
MapSettings,
|
|
||||||
AreaImportPreviewResult,
|
|
||||||
AreaImportProgress,
|
|
||||||
AreaImportSourceStatus,
|
|
||||||
} from '@/types/api';
|
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
|
||||||
|
|
||||||
interface AreaImportWizardProps {
|
|
||||||
cuts: Cut[];
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOURCE_STATUS_ICONS: Record<AreaImportSourceStatus, React.ReactNode> = {
|
|
||||||
pending: <MinusCircleOutlined style={{ color: '#8c8c8c' }} />,
|
|
||||||
running: <LoadingOutlined style={{ color: '#1890ff' }} spin />,
|
|
||||||
complete: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
|
||||||
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
|
|
||||||
skipped: <MinusCircleOutlined style={{ color: '#d9d9d9' }} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardProps) {
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
|
|
||||||
// Step 0: Define area
|
|
||||||
const [areaType, setAreaType] = useState<'cut' | 'viewport'>('cut');
|
|
||||||
const [selectedCutId, setSelectedCutId] = useState<string | undefined>();
|
|
||||||
const [mapSettings, setMapSettings] = useState<MapSettings | null>(null);
|
|
||||||
const [mapSettingsLoading, setMapSettingsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Step 1: Sources
|
|
||||||
const [osmEnabled, setOsmEnabled] = useState(true);
|
|
||||||
const [narEnabled, setNarEnabled] = useState(true);
|
|
||||||
const [narResidentialOnly, setNarResidentialOnly] = useState(true);
|
|
||||||
const [rgEnabled, setRgEnabled] = useState(false);
|
|
||||||
const [rgSpacing, setRgSpacing] = useState(100);
|
|
||||||
const [rgMaxPoints, setRgMaxPoints] = useState(500);
|
|
||||||
|
|
||||||
// Step 2: Preview
|
|
||||||
const [preview, setPreview] = useState<AreaImportPreviewResult | null>(null);
|
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Step 3: Progress
|
|
||||||
const [progress, setProgress] = useState<AreaImportProgress | null>(null);
|
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
||||||
|
|
||||||
// Load map settings for viewport mode
|
|
||||||
useEffect(() => {
|
|
||||||
if (areaType === 'viewport' && !mapSettings) {
|
|
||||||
setMapSettingsLoading(true);
|
|
||||||
api.get('/map/settings')
|
|
||||||
.then(({ data }) => setMapSettings(data))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setMapSettingsLoading(false));
|
|
||||||
}
|
|
||||||
}, [areaType, mapSettings]);
|
|
||||||
|
|
||||||
// Cleanup polling on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const buildRequestBody = useCallback(() => {
|
|
||||||
const sources: Record<string, unknown> = {
|
|
||||||
osm: osmEnabled,
|
|
||||||
nar: narEnabled ? { residentialOnly: narResidentialOnly } : false,
|
|
||||||
reverseGeocode: rgEnabled ? { gridSpacingMeters: rgSpacing, maxPoints: rgMaxPoints } : false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = { sources };
|
|
||||||
|
|
||||||
if (areaType === 'cut') {
|
|
||||||
body.areaType = 'cut';
|
|
||||||
body.cutId = selectedCutId;
|
|
||||||
} else {
|
|
||||||
body.areaType = 'viewport';
|
|
||||||
body.center = {
|
|
||||||
lat: mapSettings?.latitude ? Number(mapSettings.latitude) : 53.5,
|
|
||||||
lng: mapSettings?.longitude ? Number(mapSettings.longitude) : -113.5,
|
|
||||||
};
|
|
||||||
body.zoom = mapSettings?.zoom ?? 13;
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}, [areaType, selectedCutId, mapSettings, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]);
|
|
||||||
|
|
||||||
const fetchPreview = async () => {
|
|
||||||
setPreviewLoading(true);
|
|
||||||
setPreviewError(null);
|
|
||||||
try {
|
|
||||||
const { data } = await api.post('/map/area-import/preview', buildRequestBody());
|
|
||||||
setPreview(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed');
|
|
||||||
} finally {
|
|
||||||
setPreviewLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startImport = async () => {
|
|
||||||
setImporting(true);
|
|
||||||
try {
|
|
||||||
const body = { ...buildRequestBody(), deduplicateRadius: 5, batchSize: 1000 };
|
|
||||||
const { data } = await api.post('/map/area-import', body);
|
|
||||||
const currentImportId = data.importId;
|
|
||||||
setCurrentStep(3);
|
|
||||||
|
|
||||||
// Start polling
|
|
||||||
pollRef.current = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const { data: prog } = await api.get(`/map/area-import/status/${currentImportId}`);
|
|
||||||
setProgress(prog);
|
|
||||||
if (prog.status === 'complete' || prog.status === 'failed') {
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore polling errors
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
} catch (err: any) {
|
|
||||||
setPreviewError(err?.response?.data?.error?.message || 'Failed to start import');
|
|
||||||
setImporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canProceedStep0 = areaType === 'cut' ? !!selectedCutId : (!!mapSettings?.latitude && !!mapSettings?.longitude);
|
|
||||||
const canProceedStep1 = osmEnabled || narEnabled || rgEnabled;
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
title: 'Define Area',
|
|
||||||
content: (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Text strong>Area Source:</Text>
|
|
||||||
<Select
|
|
||||||
value={areaType}
|
|
||||||
onChange={(val) => setAreaType(val)}
|
|
||||||
style={{ width: 200, marginLeft: 12 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'cut', label: 'From Cut Boundary' },
|
|
||||||
{ value: 'viewport', label: 'From Map Settings' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{areaType === 'cut' && (
|
|
||||||
<div>
|
|
||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
|
||||||
Select a cut polygon to define the import area.
|
|
||||||
</Text>
|
|
||||||
<Select
|
|
||||||
placeholder="Select a cut..."
|
|
||||||
value={selectedCutId}
|
|
||||||
onChange={setSelectedCutId}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
options={cuts.map((c) => ({ value: c.id, label: c.name }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{areaType === 'viewport' && (
|
|
||||||
<div>
|
|
||||||
{mapSettingsLoading ? (
|
|
||||||
<Spin size="small" />
|
|
||||||
) : mapSettings?.latitude && mapSettings?.longitude ? (
|
|
||||||
<Card size="small">
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="Center Lat" value={Number(mapSettings.latitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="Center Lng" value={Number(mapSettings.longitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="Zoom" value={mapSettings.zoom ?? 13} valueStyle={{ fontSize: 16 }} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: 'block' }}>
|
|
||||||
A bounding box will be derived from the map center and zoom level.
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Alert
|
|
||||||
message="Map settings not configured"
|
|
||||||
description="Please set a center and zoom level in Map Settings first."
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Sources',
|
|
||||||
content: (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
style={{ borderColor: osmEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
|
|
||||||
onClick={() => setOsmEnabled(!osmEnabled)}
|
|
||||||
>
|
|
||||||
<Checkbox checked={osmEnabled} onChange={(e) => { e.stopPropagation(); setOsmEnabled(e.target.checked); }}>
|
|
||||||
<Space>
|
|
||||||
<GlobalOutlined />
|
|
||||||
<Text strong>OpenStreetMap (Overpass API)</Text>
|
|
||||||
</Space>
|
|
||||||
</Checkbox>
|
|
||||||
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
|
|
||||||
Fetches address nodes and building footprints from OSM. Best for urban areas with good community mapping.
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
style={{ borderColor: narEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
|
|
||||||
onClick={() => setNarEnabled(!narEnabled)}
|
|
||||||
>
|
|
||||||
<Checkbox checked={narEnabled} onChange={(e) => { e.stopPropagation(); setNarEnabled(e.target.checked); }}>
|
|
||||||
<Space>
|
|
||||||
<DatabaseOutlined />
|
|
||||||
<Text strong>NAR (National Address Register)</Text>
|
|
||||||
</Space>
|
|
||||||
</Checkbox>
|
|
||||||
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
|
|
||||||
Official Canadian address data. Requires NAR files on server. Highest priority for deduplication.
|
|
||||||
</Text>
|
|
||||||
{narEnabled && (
|
|
||||||
<div style={{ marginLeft: 24, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox checked={narResidentialOnly} onChange={(e) => setNarResidentialOnly(e.target.checked)}>
|
|
||||||
Residential only
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
style={{ borderColor: rgEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
|
|
||||||
onClick={() => setRgEnabled(!rgEnabled)}
|
|
||||||
>
|
|
||||||
<Checkbox checked={rgEnabled} onChange={(e) => { e.stopPropagation(); setRgEnabled(e.target.checked); }}>
|
|
||||||
<Space>
|
|
||||||
<CompassOutlined />
|
|
||||||
<Text strong>Reverse Geocode Grid</Text>
|
|
||||||
</Space>
|
|
||||||
</Checkbox>
|
|
||||||
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
|
|
||||||
Lays a grid of points and reverse geocodes each one. Slow but fills gaps not covered by other sources. Low confidence (40).
|
|
||||||
</Text>
|
|
||||||
{rgEnabled && (
|
|
||||||
<div style={{ marginLeft: 24, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
<div>
|
|
||||||
<Text style={{ fontSize: 12 }}>Grid spacing (meters):</Text>
|
|
||||||
<Slider min={20} max={500} step={10} value={rgSpacing} onChange={setRgSpacing} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text style={{ fontSize: 12 }}>Max points: </Text>
|
|
||||||
<InputNumber min={10} max={2000} value={rgMaxPoints} onChange={(v) => v && setRgMaxPoints(v)} size="small" />
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{!canProceedStep1 && (
|
|
||||||
<Alert message="Select at least one source to continue." type="info" showIcon />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Preview',
|
|
||||||
content: (
|
|
||||||
<div>
|
|
||||||
{previewLoading && (
|
|
||||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
|
||||||
<Spin size="large" />
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Text type="secondary">Estimating import size...</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{previewError && (
|
|
||||||
<Alert message="Preview Error" description={previewError} type="error" showIcon style={{ marginBottom: 16 }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{preview && !previewLoading && (
|
|
||||||
<>
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Card size="small">
|
|
||||||
<Statistic title="Area" value={preview.areaSqKm.toFixed(2)} suffix="km2" valueStyle={{ fontSize: 16 }} />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Card size="small">
|
|
||||||
<Statistic title="Existing Locations" value={preview.existingLocations} valueStyle={{ fontSize: 16 }} />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Card size="small">
|
|
||||||
<Statistic
|
|
||||||
title="Est. Total"
|
|
||||||
value={
|
|
||||||
(preview.estimates.osm >= 0 ? preview.estimates.osm : 0) +
|
|
||||||
preview.estimates.nar +
|
|
||||||
preview.estimates.reverseGeocode
|
|
||||||
}
|
|
||||||
valueStyle={{ fontSize: 16 }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Card size="small" title="Estimated Candidates by Source" style={{ marginBottom: 16 }}>
|
|
||||||
<Row gutter={16}>
|
|
||||||
{osmEnabled && (
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic
|
|
||||||
title={<Space><GlobalOutlined /> OSM</Space>}
|
|
||||||
value={preview.estimates.osm >= 0 ? preview.estimates.osm : '?'}
|
|
||||||
valueStyle={{ fontSize: 16 }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
{narEnabled && (
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic
|
|
||||||
title={<Space><DatabaseOutlined /> NAR</Space>}
|
|
||||||
value={preview.estimates.nar}
|
|
||||||
suffix={preview.narProvincesDetected.length > 0 ? '' : undefined}
|
|
||||||
valueStyle={{ fontSize: 16 }}
|
|
||||||
/>
|
|
||||||
{preview.narProvincesDetected.length > 0 && (
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
Provinces: {preview.narProvincesDetected.join(', ')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{preview.narProvincesDetected.length === 0 && (
|
|
||||||
<Text type="warning" style={{ fontSize: 11 }}>No NAR data for this area</Text>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
{rgEnabled && (
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic
|
|
||||||
title={<Space><CompassOutlined /> Rev. Geocode</Space>}
|
|
||||||
value={preview.estimates.reverseGeocode}
|
|
||||||
suffix="points"
|
|
||||||
valueStyle={{ fontSize: 16 }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{(preview.estimates.osm + preview.estimates.nar + preview.estimates.reverseGeocode) > 10000 && (
|
|
||||||
<Alert
|
|
||||||
message="Large Import"
|
|
||||||
description="Estimated candidates exceed 10,000. This may take a while. Consider narrowing the area or disabling reverse geocode."
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{preview.areaSqKm > 100 && osmEnabled && (
|
|
||||||
<Alert
|
|
||||||
message="Large OSM Query"
|
|
||||||
description={`Area is ${preview.areaSqKm.toFixed(0)} km2. Large Overpass queries may be slow or fail. Consider using a private Overpass instance.`}
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
block
|
|
||||||
onClick={startImport}
|
|
||||||
loading={importing}
|
|
||||||
>
|
|
||||||
Start Import
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Progress',
|
|
||||||
content: (
|
|
||||||
<div>
|
|
||||||
{progress ? (
|
|
||||||
<>
|
|
||||||
{progress.status === 'complete' ? (
|
|
||||||
<Result
|
|
||||||
status="success"
|
|
||||||
title="Import Complete"
|
|
||||||
subTitle={`${progress.locationsCreated} locations and ${progress.addressesCreated} addresses created`}
|
|
||||||
extra={[
|
|
||||||
<Button key="done" type="primary" onClick={() => onComplete?.()}>
|
|
||||||
Done
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : progress.status === 'failed' ? (
|
|
||||||
<Result
|
|
||||||
status="error"
|
|
||||||
title="Import Failed"
|
|
||||||
subTitle={progress.error || 'An unknown error occurred'}
|
|
||||||
extra={[
|
|
||||||
<Button key="back" onClick={() => { setCurrentStep(2); setImporting(false); }}>
|
|
||||||
Back to Preview
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Title level={5} style={{ marginBottom: 16 }}>
|
|
||||||
{progress.status === 'initializing' ? 'Initializing...' :
|
|
||||||
progress.status === 'creating-records' ? 'Creating records...' : 'Running sources...'}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Card size="small" title="Source Progress" style={{ marginBottom: 16 }}>
|
|
||||||
{(['osm', 'nar', 'reverseGeocode'] as const).map((source) => {
|
|
||||||
const sp = progress.sources[source];
|
|
||||||
const labels = { osm: 'OpenStreetMap', nar: 'NAR', reverseGeocode: 'Reverse Geocode' };
|
|
||||||
return (
|
|
||||||
<div key={source} style={{ marginBottom: 8 }}>
|
|
||||||
<Space>
|
|
||||||
{SOURCE_STATUS_ICONS[sp.status]}
|
|
||||||
<Text strong>{labels[source]}</Text>
|
|
||||||
<Tag>{sp.status}</Tag>
|
|
||||||
{sp.candidatesFound > 0 && (
|
|
||||||
<Text type="secondary">{sp.candidatesFound} found</Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
{sp.message && (
|
|
||||||
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
|
|
||||||
{sp.message}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{sp.error && (
|
|
||||||
<Text type="danger" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
|
|
||||||
{sp.error}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="Locations Created" value={progress.locationsCreated} valueStyle={{ fontSize: 16, color: '#52c41a' }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="Addresses Created" value={progress.addressesCreated} valueStyle={{ fontSize: 16, color: '#52c41a' }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="Duplicates Skipped" value={progress.skippedDuplicate} valueStyle={{ fontSize: 16, color: '#faad14' }} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{progress.status === 'creating-records' && progress.totalCandidates > 0 && (
|
|
||||||
<Progress
|
|
||||||
percent={Math.round((progress.locationsCreated / progress.totalCandidates) * 100)}
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
status="active"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
|
||||||
<Spin size="large" />
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Text type="secondary">Starting import...</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
if (currentStep === 1) {
|
|
||||||
// Moving to preview step — fetch preview
|
|
||||||
setCurrentStep(2);
|
|
||||||
// Fetch preview after state update
|
|
||||||
setTimeout(() => fetchPreview(), 0);
|
|
||||||
} else {
|
|
||||||
setCurrentStep(currentStep + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Steps
|
|
||||||
current={currentStep}
|
|
||||||
size="small"
|
|
||||||
style={{ marginBottom: 24 }}
|
|
||||||
items={steps.map((s) => ({ title: s.title }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ minHeight: 200 }}>
|
|
||||||
{steps[currentStep]?.content}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentStep < 2 && (
|
|
||||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<Button disabled={currentStep === 0} onClick={() => setCurrentStep(currentStep - 1)}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={currentStep === 0 ? !canProceedStep0 : !canProceedStep1}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 2 && !previewLoading && !preview && !previewError && (
|
|
||||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<Button onClick={() => setCurrentStep(1)}>Back</Button>
|
|
||||||
<Button type="primary" onClick={fetchPreview}>Load Preview</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 2 && (preview || previewError) && !importing && (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<Button onClick={() => { setCurrentStep(1); setPreview(null); setPreviewError(null); }}>Back</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -24,22 +24,6 @@ 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);
|
||||||
@ -57,7 +41,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 - 64px)' }}>
|
<div style={{ position: 'relative', width: '100%', height: 'calc(100vh - 200px)', minHeight: 400 }}>
|
||||||
{/* Drawing toolbar */}
|
{/* Drawing toolbar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -115,7 +99,6 @@ 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,7 +1,6 @@
|
|||||||
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 },
|
||||||
@ -33,21 +32,8 @@ 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,7 +5,6 @@ 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> = {
|
||||||
@ -14,10 +13,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', style }: Props) {
|
export default function TileLayerToggle({ activeKey, onChange, position = 'bottom-right' }: Props) {
|
||||||
const posStyle = position === 'bottom-left'
|
const posStyle = position === 'bottom-left'
|
||||||
? { left: 10, bottom: 16 }
|
? { left: 10, bottom: 80 }
|
||||||
: { right: 10, bottom: 140 }; // Increased from 80 to 140 for legend clearance
|
: { right: 10, bottom: 80 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -28,7 +27,6 @@ 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) => (
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
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, Address, SupportLevel } from '@/types/api';
|
import type { Location, 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,57 +8,50 @@ export function getMarkerColor(level: SupportLevel | null): string {
|
|||||||
return SUPPORT_LEVEL_COLORS[level] ?? NO_LEVEL_COLOR;
|
return SUPPORT_LEVEL_COLORS[level] ?? NO_LEVEL_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location with addresses for map display
|
|
||||||
export interface GroupableLocation extends Location {
|
|
||||||
addresses: Address[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the dominant support level from all addresses in a location
|
|
||||||
function getDominantSupportLevel(addresses: Address[]): SupportLevel | null {
|
|
||||||
if (addresses.length === 0) return null;
|
|
||||||
|
|
||||||
const levelCounts: Record<string, number> = {};
|
|
||||||
for (const addr of addresses) {
|
|
||||||
const level = addr.supportLevel || 'NONE';
|
|
||||||
levelCounts[level] = (levelCounts[level] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dominant: SupportLevel | null = null;
|
|
||||||
let maxCount = 0;
|
|
||||||
for (const [level, count] of Object.entries(levelCounts)) {
|
|
||||||
if (count > maxCount) {
|
|
||||||
maxCount = count;
|
|
||||||
dominant = level === 'NONE' ? null : (level as SupportLevel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dominant;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocationGroup {
|
export interface LocationGroup {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
location: GroupableLocation;
|
locations: Location[];
|
||||||
isMultiUnit: boolean;
|
isMultiUnit: boolean;
|
||||||
dominantLevel: SupportLevel | null;
|
dominantLevel: SupportLevel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupLocations(locations: GroupableLocation[]): LocationGroup[] {
|
export function groupLocations(locations: Location[]): LocationGroup[] {
|
||||||
return locations
|
const groups = new Map<string, Location[]>();
|
||||||
.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
|
for (const loc of locations) {
|
||||||
const addresses = Array.isArray(loc.addresses) ? loc.addresses : [];
|
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 {
|
return Array.from(groups.entries()).map(([key, locs]) => {
|
||||||
latitude: lat,
|
const [lat, lng] = key.split(',');
|
||||||
longitude: lng,
|
const levelCounts: Record<string, number> = {};
|
||||||
location: loc,
|
for (const loc of locs) {
|
||||||
isMultiUnit: addresses.length > 1,
|
const level = loc.supportLevel || 'NONE';
|
||||||
dominantLevel: getDominantSupportLevel(addresses),
|
levelCounts[level] = (levelCounts[level] || 0) + 1;
|
||||||
};
|
}
|
||||||
});
|
let dominant: SupportLevel | null = null;
|
||||||
|
let maxCount = 0;
|
||||||
|
for (const [level, count] of Object.entries(levelCounts)) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
dominant = level === 'NONE' ? null : (level as SupportLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: parseFloat(lat!),
|
||||||
|
longitude: parseFloat(lng!),
|
||||||
|
locations: locs,
|
||||||
|
isMultiUnit: locs.length > 1,
|
||||||
|
dominantLevel: dominant,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,242 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Modal, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
|
|
||||||
import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
|
||||||
import type { PlaylistSummary } from '@/types/media';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface AddToPlaylistModalProps {
|
|
||||||
videoId: number;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlaylistWithSelected extends PlaylistSummary {
|
|
||||||
hasVideo: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddToPlaylistModal({
|
|
||||||
videoId,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
}: AddToPlaylistModalProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const [playlists, setPlaylists] = useState<PlaylistWithSelected[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [selections, setSelections] = useState<Record<number, boolean>>({});
|
|
||||||
|
|
||||||
// Inline create state
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
|
|
||||||
// Fetch user's playlists and check which ones contain the video
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const fetchPlaylists = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const { data } = await mediaApi.get('/playlists/my');
|
|
||||||
const userPlaylists: PlaylistSummary[] = data.data || [];
|
|
||||||
|
|
||||||
// For each playlist, check if it contains the video
|
|
||||||
const withSelection = await Promise.all(
|
|
||||||
userPlaylists.map(async (p) => {
|
|
||||||
try {
|
|
||||||
const { data: detail } = await mediaPublicApi.get(
|
|
||||||
`/playlists/${p.id}`
|
|
||||||
);
|
|
||||||
const hasVideo = (detail.videos || []).some(
|
|
||||||
(v: any) => v.mediaId === videoId
|
|
||||||
);
|
|
||||||
return { ...p, hasVideo };
|
|
||||||
} catch {
|
|
||||||
return { ...p, hasVideo: false };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setPlaylists(withSelection);
|
|
||||||
// Initialize selections from current state
|
|
||||||
const initial: Record<number, boolean> = {};
|
|
||||||
withSelection.forEach((p) => {
|
|
||||||
initial[p.id] = p.hasVideo;
|
|
||||||
});
|
|
||||||
setSelections(initial);
|
|
||||||
} catch {
|
|
||||||
message.error('Failed to load playlists');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchPlaylists();
|
|
||||||
}, [open, videoId]);
|
|
||||||
|
|
||||||
const handleToggle = (playlistId: number, checked: boolean) => {
|
|
||||||
setSelections((prev) => ({ ...prev, [playlistId]: checked }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
setSaving(true);
|
|
||||||
|
|
||||||
const promises: Promise<any>[] = [];
|
|
||||||
|
|
||||||
for (const playlist of playlists) {
|
|
||||||
const wasInPlaylist = playlist.hasVideo;
|
|
||||||
const shouldBeInPlaylist = selections[playlist.id];
|
|
||||||
|
|
||||||
if (shouldBeInPlaylist && !wasInPlaylist) {
|
|
||||||
// Add to playlist
|
|
||||||
promises.push(
|
|
||||||
mediaApi.post(`/playlists/${playlist.id}/videos`, {
|
|
||||||
mediaId: videoId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (!shouldBeInPlaylist && wasInPlaylist) {
|
|
||||||
// Remove from playlist
|
|
||||||
promises.push(
|
|
||||||
mediaApi.delete(`/playlists/${playlist.id}/videos/${videoId}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
message.success('Playlists updated');
|
|
||||||
onClose();
|
|
||||||
} catch {
|
|
||||||
message.error('Failed to update playlists');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateNew = async () => {
|
|
||||||
if (!newName.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setCreating(true);
|
|
||||||
const { data } = await mediaApi.post('/playlists/', {
|
|
||||||
name: newName.trim(),
|
|
||||||
isPublic: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add video to the new playlist
|
|
||||||
await mediaApi.post(`/playlists/${data.id}/videos`, { mediaId: videoId });
|
|
||||||
|
|
||||||
message.success(`Created "${data.name}" and added video`);
|
|
||||||
setNewName('');
|
|
||||||
setShowCreate(false);
|
|
||||||
|
|
||||||
// Refresh the list
|
|
||||||
setPlaylists((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
|
|
||||||
]);
|
|
||||||
setSelections((prev) => ({ ...prev, [data.id]: true }));
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 409) {
|
|
||||||
message.error('You already have a playlist with this name');
|
|
||||||
} else {
|
|
||||||
message.error('Failed to create playlist');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="Add to Playlist"
|
|
||||||
open={open}
|
|
||||||
onOk={handleSave}
|
|
||||||
onCancel={onClose}
|
|
||||||
confirmLoading={saving}
|
|
||||||
okText="Save"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: 32 }}>
|
|
||||||
<Spin />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{playlists.length === 0 && !showCreate ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
|
||||||
<UnorderedListOutlined
|
|
||||||
style={{ fontSize: 36, color: token.colorTextSecondary, marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
style={{ display: 'block', marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
You don't have any playlists yet
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
|
||||||
{playlists.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
style={{
|
|
||||||
padding: '8px 0',
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selections[p.id] ?? false}
|
|
||||||
onChange={(e) => handleToggle(p.id, e.target.checked)}
|
|
||||||
>
|
|
||||||
<Space>
|
|
||||||
<Text>{p.name}</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
({p.videoCount} videos)
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
|
||||||
|
|
||||||
{showCreate ? (
|
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
|
||||||
<Input
|
|
||||||
placeholder="New playlist name"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onPressEnter={handleCreateNew}
|
|
||||||
maxLength={100}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={handleCreateNew}
|
|
||||||
loading={creating}
|
|
||||||
disabled={!newName.trim()}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Create New Playlist
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,288 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
|
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
import type { PlaylistSummary } from '@/types/media';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface BulkAddToPlaylistModalProps {
|
|
||||||
videoIds: number[];
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BulkAddToPlaylistModal({
|
|
||||||
videoIds,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}: BulkAddToPlaylistModalProps) {
|
|
||||||
const [playlists, setPlaylists] = useState<PlaylistSummary[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Inline create state
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const fetchPlaylists = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const { data } = await mediaApi.get('/playlists/my');
|
|
||||||
setPlaylists(data.data || []);
|
|
||||||
} catch {
|
|
||||||
message.error('Failed to load playlists');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchPlaylists();
|
|
||||||
setSelectedPlaylistId(null);
|
|
||||||
setShowCreate(false);
|
|
||||||
setNewName('');
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
if (!selectedPlaylistId || videoIds.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSaving(true);
|
|
||||||
let added = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
|
|
||||||
for (const mediaId of videoIds) {
|
|
||||||
try {
|
|
||||||
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
|
|
||||||
added++;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 409) {
|
|
||||||
skipped++;
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (added > 0) parts.push(`${added} video${added > 1 ? 's' : ''} added`);
|
|
||||||
if (skipped > 0) parts.push(`${skipped} already in playlist`);
|
|
||||||
message.success(parts.join(', '));
|
|
||||||
onSuccess?.();
|
|
||||||
} catch {
|
|
||||||
message.error('Failed to add videos to playlist');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateNew = async () => {
|
|
||||||
if (!newName.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setCreating(true);
|
|
||||||
const { data } = await mediaApi.post('/playlists/', {
|
|
||||||
name: newName.trim(),
|
|
||||||
isPublic: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
setPlaylists((prev) => [...prev, data]);
|
|
||||||
setSelectedPlaylistId(data.id);
|
|
||||||
setNewName('');
|
|
||||||
setShowCreate(false);
|
|
||||||
message.success(`Created "${data.name}"`);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 409) {
|
|
||||||
message.error('You already have a playlist with this name');
|
|
||||||
} else {
|
|
||||||
message.error('Failed to create playlist');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`}
|
|
||||||
open={open}
|
|
||||||
onOk={handleAdd}
|
|
||||||
onCancel={onClose}
|
|
||||||
confirmLoading={saving}
|
|
||||||
okText="Add"
|
|
||||||
okButtonProps={{ disabled: !selectedPlaylistId }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: 32 }}>
|
|
||||||
<Spin />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
placeholder="Select a playlist"
|
|
||||||
value={selectedPlaylistId}
|
|
||||||
onChange={setSelectedPlaylistId}
|
|
||||||
style={{ width: '100%', marginBottom: 12 }}
|
|
||||||
options={playlists.map((p) => ({
|
|
||||||
value: p.id,
|
|
||||||
label: `${p.name} (${p.videoCount} videos)`,
|
|
||||||
}))}
|
|
||||||
showSearch
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedPlaylist && (
|
|
||||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
|
||||||
{selectedPlaylist.isPublic ? 'Public' : 'Private'} playlist
|
|
||||||
{selectedPlaylist.videoCount > 0 && ` with ${selectedPlaylist.videoCount} videos`}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
|
||||||
|
|
||||||
{showCreate ? (
|
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
|
||||||
<Input
|
|
||||||
placeholder="New playlist name"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onPressEnter={handleCreateNew}
|
|
||||||
maxLength={100}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={handleCreateNew}
|
|
||||||
loading={creating}
|
|
||||||
disabled={!newName.trim()}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Create New Playlist
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { notification, Button, Space, Typography } from 'antd';
|
|
||||||
import { MessageOutlined } from '@ant-design/icons';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import type { ChatNotification } from '@/hooks/useChatNotifications';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface ChatNotificationToastProps {
|
|
||||||
notifications: ChatNotification[];
|
|
||||||
clearNotification: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatNotificationToast({
|
|
||||||
notifications,
|
|
||||||
clearNotification,
|
|
||||||
}: ChatNotificationToastProps) {
|
|
||||||
const [api, contextHolder] = notification.useNotification();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const shownRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (const notif of notifications) {
|
|
||||||
if (shownRef.current.has(notif.id)) continue;
|
|
||||||
shownRef.current.add(notif.id);
|
|
||||||
|
|
||||||
api.info({
|
|
||||||
key: notif.id,
|
|
||||||
message: (
|
|
||||||
<Space size={4}>
|
|
||||||
<MessageOutlined />
|
|
||||||
<Text strong>{notif.commenterName}</Text>
|
|
||||||
<Text type="secondary">replied</Text>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
description: (
|
|
||||||
<div>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
on {notif.videoTitle}
|
|
||||||
</Text>
|
|
||||||
<div style={{ marginTop: 4 }}>
|
|
||||||
<Text style={{ fontSize: 13 }}>{notif.contentPreview}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
placement: 'bottomRight',
|
|
||||||
duration: 8,
|
|
||||||
btn: (
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/gallery/watch/${notif.videoId}`);
|
|
||||||
api.destroy(notif.id);
|
|
||||||
clearNotification(notif.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
onClose: () => {
|
|
||||||
clearNotification(notif.id);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [notifications, api, clearNotification, navigate]);
|
|
||||||
|
|
||||||
// Cleanup shown IDs when notifications are cleared
|
|
||||||
useEffect(() => {
|
|
||||||
const currentIds = new Set(notifications.map((n) => n.id));
|
|
||||||
for (const id of shownRef.current) {
|
|
||||||
if (!currentIds.has(id)) {
|
|
||||||
shownRef.current.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [notifications]);
|
|
||||||
|
|
||||||
return <>{contextHolder}</>;
|
|
||||||
}
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
List,
|
|
||||||
Input,
|
|
||||||
Button,
|
|
||||||
Space,
|
|
||||||
Typography,
|
|
||||||
message,
|
|
||||||
Empty,
|
|
||||||
Spin,
|
|
||||||
Avatar,
|
|
||||||
theme,
|
|
||||||
} from 'antd';
|
|
||||||
import { UserOutlined, SendOutlined } from '@ant-design/icons';
|
|
||||||
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
|
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
|
||||||
|
|
||||||
const { TextArea } = Input;
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface Comment {
|
|
||||||
id: number;
|
|
||||||
content: string;
|
|
||||||
userId: number | null;
|
|
||||||
sessionId: string;
|
|
||||||
createdAt: string;
|
|
||||||
safetyStatus: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommentSectionProps {
|
|
||||||
videoId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CommentSection({ videoId }: CommentSectionProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [commentText, setCommentText] = useState('');
|
|
||||||
const [hasMore, setHasMore] = useState(false);
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
const limit = 20;
|
|
||||||
|
|
||||||
const fetchComments = async (append = false) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const currentOffset = append ? offset : 0;
|
|
||||||
|
|
||||||
const response = await mediaPublicApi.get(`/public/${videoId}/comments`, {
|
|
||||||
params: { limit, offset: currentOffset },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (append) {
|
|
||||||
setComments((prev) => [...prev, ...response.data.comments]);
|
|
||||||
} else {
|
|
||||||
setComments(response.data.comments);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasMore(response.data.pagination.hasMore);
|
|
||||||
setOffset(currentOffset + limit);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch comments:', error);
|
|
||||||
message.error('Failed to load comments');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchComments();
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!commentText.trim()) {
|
|
||||||
message.warning('Please enter a comment');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commentText.length > 1000) {
|
|
||||||
message.error('Comment is too long (max 1000 characters)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is logged in
|
|
||||||
if (!useAuthStore.getState().isAuthenticated) {
|
|
||||||
message.warning('Please log in to comment');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
const sessionId = getOrCreateSessionId();
|
|
||||||
|
|
||||||
const response = await mediaPublicApi.post(`/public/${videoId}/comments`, {
|
|
||||||
sessionId,
|
|
||||||
content: commentText.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add new comment to top of list
|
|
||||||
setComments((prev) => [response.data.comment, ...prev]);
|
|
||||||
setCommentText('');
|
|
||||||
message.success('Comment posted!');
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
message.error('Please log in to comment');
|
|
||||||
} else {
|
|
||||||
message.error('Failed to post comment');
|
|
||||||
}
|
|
||||||
console.error('Failed to post comment:', error);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
fetchComments(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Comment form */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
value={commentText}
|
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
|
||||||
placeholder="Add a comment..."
|
|
||||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
|
||||||
maxLength={1000}
|
|
||||||
showCount
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SendOutlined />}
|
|
||||||
loading={submitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!commentText.trim()}
|
|
||||||
>
|
|
||||||
Post Comment
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comments list */}
|
|
||||||
<div>
|
|
||||||
<Text strong style={{ fontSize: 16, marginBottom: 16, display: 'block' }}>
|
|
||||||
Comments ({comments.length})
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{loading && comments.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
|
||||||
<Spin />
|
|
||||||
</div>
|
|
||||||
) : comments.length === 0 ? (
|
|
||||||
<Empty
|
|
||||||
description="No comments yet. Be the first to comment!"
|
|
||||||
style={{ padding: 40 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<List
|
|
||||||
dataSource={comments}
|
|
||||||
renderItem={(comment) => (
|
|
||||||
<List.Item
|
|
||||||
style={{
|
|
||||||
padding: '16px 0',
|
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
|
||||||
avatar={
|
|
||||||
<Avatar
|
|
||||||
icon={<UserOutlined />}
|
|
||||||
style={{
|
|
||||||
background: token.colorPrimary,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<Space size={8}>
|
|
||||||
<Text strong>
|
|
||||||
{comment.userId ? `User #${comment.userId}` : 'Anonymous'}
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{dayjs(comment.createdAt).fromNow()}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<Text style={{ fontSize: 14, whiteSpace: 'pre-wrap' }}>
|
|
||||||
{comment.content}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Load more button */}
|
|
||||||
{hasMore && (
|
|
||||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
|
||||||
<Button onClick={handleLoadMore} loading={loading}>
|
|
||||||
Load More Comments
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
|
|
||||||
interface CreatePlaylistModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onCreated?: (playlist: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CreatePlaylistModal({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onCreated,
|
|
||||||
}: CreatePlaylistModalProps) {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const { data } = await mediaApi.post('/playlists/', {
|
|
||||||
name: values.name,
|
|
||||||
description: values.description || undefined,
|
|
||||||
isPublic: values.isPublic ?? false,
|
|
||||||
});
|
|
||||||
|
|
||||||
message.success('Playlist created');
|
|
||||||
form.resetFields();
|
|
||||||
onCreated?.(data);
|
|
||||||
onClose();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 409) {
|
|
||||||
message.error('You already have a playlist with this name');
|
|
||||||
} else if (!error.errorFields) {
|
|
||||||
message.error('Failed to create playlist');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title="Create Playlist"
|
|
||||||
open={open}
|
|
||||||
onClose={() => {
|
|
||||||
form.resetFields();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
placement="right"
|
|
||||||
width={420}
|
|
||||||
style={{ top: 64 }}
|
|
||||||
styles={{ body: { paddingTop: 24 } }}
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={() => { form.resetFields(); onClose(); }}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label="Name"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: 'Please enter a playlist name' },
|
|
||||||
{ max: 100, message: 'Name must be 100 characters or less' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input placeholder="My Playlist" maxLength={100} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="description" label="Description">
|
|
||||||
<Input.TextArea
|
|
||||||
placeholder="Optional description..."
|
|
||||||
rows={3}
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="isPublic"
|
|
||||||
label="Public"
|
|
||||||
valuePropName="checked"
|
|
||||||
initialValue={false}
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="Public" unCheckedChildren="Private" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,260 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme } from 'antd';
|
|
||||||
import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
|
||||||
import type { PlaylistVideoItem } from '@/types/media';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface EditPlaylistModalProps {
|
|
||||||
playlistId: number | null;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onUpdated?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds: number | null): string {
|
|
||||||
if (!seconds) return '0:00';
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditPlaylistModal({
|
|
||||||
playlistId,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onUpdated,
|
|
||||||
}: EditPlaylistModalProps) {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [videos, setVideos] = useState<PlaylistVideoItem[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !playlistId) return;
|
|
||||||
|
|
||||||
const fetchPlaylist = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const { data } = await mediaPublicApi.get(`/playlists/${playlistId}`);
|
|
||||||
setVideos(data.videos || []);
|
|
||||||
form.setFieldsValue({
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
isPublic: data.isPublic,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
message.error('Failed to load playlist');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchPlaylist();
|
|
||||||
}, [open, playlistId]);
|
|
||||||
|
|
||||||
const handleSaveDetails = async () => {
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
setSaving(true);
|
|
||||||
|
|
||||||
await mediaApi.put(`/playlists/${playlistId}`, {
|
|
||||||
name: values.name,
|
|
||||||
description: values.description || undefined,
|
|
||||||
isPublic: values.isPublic,
|
|
||||||
});
|
|
||||||
|
|
||||||
message.success('Playlist updated');
|
|
||||||
onUpdated?.();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 409) {
|
|
||||||
message.error('You already have a playlist with this name');
|
|
||||||
} else if (!error.errorFields) {
|
|
||||||
message.error('Failed to update playlist');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveVideo = async (mediaId: number) => {
|
|
||||||
try {
|
|
||||||
await mediaApi.delete(`/playlists/${playlistId}/videos/${mediaId}`);
|
|
||||||
setVideos((prev) => prev.filter((v) => v.mediaId !== mediaId));
|
|
||||||
message.success('Video removed');
|
|
||||||
onUpdated?.();
|
|
||||||
} catch {
|
|
||||||
message.error('Failed to remove video');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMoveVideo = async (index: number, direction: 'up' | 'down') => {
|
|
||||||
const newVideos = [...videos];
|
|
||||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
|
||||||
if (targetIndex < 0 || targetIndex >= newVideos.length) return;
|
|
||||||
|
|
||||||
const temp = newVideos[index]!;
|
|
||||||
newVideos[index] = newVideos[targetIndex]!;
|
|
||||||
newVideos[targetIndex] = temp;
|
|
||||||
|
|
||||||
// Update positions
|
|
||||||
const reordered = newVideos.map((v, i) => ({
|
|
||||||
...v,
|
|
||||||
position: i,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setVideos(reordered);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mediaApi.put(`/playlists/${playlistId}/videos/reorder`, {
|
|
||||||
items: reordered.map((v) => ({ mediaId: v.mediaId, position: v.position })),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
message.error('Failed to reorder');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title="Edit Playlist"
|
|
||||||
open={open}
|
|
||||||
onClose={() => {
|
|
||||||
form.resetFields();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
placement="right"
|
|
||||||
width={520}
|
|
||||||
style={{ top: 64 }}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'details',
|
|
||||||
label: 'Details',
|
|
||||||
children: (
|
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label="Name"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: 'Please enter a playlist name' },
|
|
||||||
{ max: 100, message: 'Name must be 100 characters or less' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input maxLength={100} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="description" label="Description">
|
|
||||||
<Input.TextArea rows={3} maxLength={500} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="isPublic"
|
|
||||||
label="Public"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="Public" unCheckedChildren="Private" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Button type="primary" onClick={handleSaveDetails} loading={saving}>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'videos',
|
|
||||||
label: `Videos (${videos.length})`,
|
|
||||||
children: (
|
|
||||||
<List
|
|
||||||
dataSource={videos}
|
|
||||||
locale={{ emptyText: 'No videos in this playlist' }}
|
|
||||||
renderItem={(item, index) => {
|
|
||||||
const title = item.video.title || item.video.filename.replace(/\.[^/.]+$/, '');
|
|
||||||
return (
|
|
||||||
<List.Item
|
|
||||||
style={{ padding: '8px 0' }}
|
|
||||||
actions={[
|
|
||||||
<Button
|
|
||||||
key="up"
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<ArrowUpOutlined />}
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => handleMoveVideo(index, 'up')}
|
|
||||||
/>,
|
|
||||||
<Button
|
|
||||||
key="down"
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<ArrowDownOutlined />}
|
|
||||||
disabled={index === videos.length - 1}
|
|
||||||
onClick={() => handleMoveVideo(index, 'down')}
|
|
||||||
/>,
|
|
||||||
<Button
|
|
||||||
key="remove"
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={() => handleRemoveVideo(item.mediaId)}
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Space>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: token.colorBgTextHover,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
{item.video.thumbnailUrl && (
|
|
||||||
<img
|
|
||||||
src={`/media${item.video.thumbnailUrl}`}
|
|
||||||
alt=""
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 28,
|
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: 4,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<Text
|
|
||||||
ellipsis
|
|
||||||
style={{ fontSize: 13, display: 'block', maxWidth: 280 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
{formatDuration(item.video.durationSeconds)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</List.Item>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
import { Drawer, Form, Input, Select, Switch, Button, Space, message, Spin } from 'antd';
|
|
||||||
import { EditOutlined } from '@ant-design/icons';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
import type { Video } from '@/types/media';
|
|
||||||
|
|
||||||
interface EditVideoDrawerProps {
|
|
||||||
video: Video | null;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_OPTIONS = [
|
|
||||||
{ value: 'videos', label: 'Videos' },
|
|
||||||
{ value: 'curated', label: 'Curated' },
|
|
||||||
{ value: 'compilations', label: 'Compilations' },
|
|
||||||
{ value: 'playback', label: 'Playback' },
|
|
||||||
{ value: 'highlights', label: 'Highlights' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EditVideoModal({ video, open, onClose, onSuccess }: EditVideoDrawerProps) {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [fetching, setFetching] = useState(false);
|
|
||||||
|
|
||||||
// Fetch full video details when drawer opens (list query omits some fields)
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && video) {
|
|
||||||
setFetching(true);
|
|
||||||
mediaApi
|
|
||||||
.get<{ video: Video }>(`/videos/${video.id}`)
|
|
||||||
.then(({ data }) => {
|
|
||||||
const v = data.video;
|
|
||||||
form.setFieldsValue({
|
|
||||||
title: v.title || '',
|
|
||||||
producer: v.producer || '',
|
|
||||||
creator: v.creator || '',
|
|
||||||
category: v.category || undefined,
|
|
||||||
tags: Array.isArray(v.tags) ? v.tags : [],
|
|
||||||
quality: v.quality || '',
|
|
||||||
isShort: v.isShort ?? false,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Fallback to data we already have from the list
|
|
||||||
form.setFieldsValue({
|
|
||||||
title: video.title || '',
|
|
||||||
producer: video.producer || '',
|
|
||||||
creator: video.creator || '',
|
|
||||||
category: video.category || undefined,
|
|
||||||
tags: Array.isArray(video.tags) ? video.tags : [],
|
|
||||||
quality: video.quality || '',
|
|
||||||
isShort: video.isShort ?? false,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => setFetching(false));
|
|
||||||
}
|
|
||||||
}, [open, video, form]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {};
|
|
||||||
if (values.title) payload.title = values.title;
|
|
||||||
// Allow clearing optional fields by sending null
|
|
||||||
payload.producer = values.producer || null;
|
|
||||||
payload.creator = values.creator || null;
|
|
||||||
payload.category = values.category || null;
|
|
||||||
payload.tags = values.tags && values.tags.length > 0 ? values.tags : null;
|
|
||||||
if (values.isShort !== undefined) payload.isShort = values.isShort;
|
|
||||||
|
|
||||||
await mediaApi.patch(`/videos/${video.id}`, payload);
|
|
||||||
message.success('Video updated successfully');
|
|
||||||
onSuccess?.();
|
|
||||||
onClose();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.data?.message) {
|
|
||||||
message.error(error.response.data.message);
|
|
||||||
}
|
|
||||||
// form validation errors are shown inline
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
<EditOutlined style={{ marginRight: 8 }} />
|
|
||||||
Edit Video
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
width={480}
|
|
||||||
destroyOnClose
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
|
||||||
<Button type="primary" onClick={handleSave} loading={loading}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{fetching ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
|
||||||
<Spin size="large" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="title"
|
|
||||||
label="Title"
|
|
||||||
rules={[{ required: true, message: 'Title is required' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="Video title" maxLength={500} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="producer" label="Producer">
|
|
||||||
<Input placeholder="Producer name" maxLength={200} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="creator" label="Creator">
|
|
||||||
<Input placeholder="Creator name" maxLength={200} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="category" label="Category">
|
|
||||||
<Select
|
|
||||||
placeholder="Select category"
|
|
||||||
options={CATEGORY_OPTIONS}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="isShort" label="Short Video" valuePropName="checked">
|
|
||||||
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="tags" label="Tags">
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
placeholder="Type to add tags"
|
|
||||||
tokenSeparators={[',']}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="quality" label="Quality">
|
|
||||||
<Input disabled placeholder="Auto-detected" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,344 +0,0 @@
|
|||||||
import { useRef, useState, useEffect } from 'react';
|
|
||||||
import { Button, Space, Tag, Grid, theme } from 'antd';
|
|
||||||
import {
|
|
||||||
CloseOutlined,
|
|
||||||
LikeOutlined,
|
|
||||||
LikeFilled,
|
|
||||||
EyeOutlined,
|
|
||||||
CommentOutlined,
|
|
||||||
OrderedListOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useExpandedVideo, type VideoData } from '@/contexts/ExpandedVideoContext';
|
|
||||||
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
|
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
import VideoPlayer, { VideoPlayerRef } from './VideoPlayer';
|
|
||||||
import LiveChat from './LiveChat';
|
|
||||||
import ProgressBarMarkers from './ProgressBarMarkers';
|
|
||||||
import ReactionButtons from './ReactionButtons';
|
|
||||||
import AddToPlaylistModal from './AddToPlaylistModal';
|
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
|
|
||||||
interface ExpandedVideoCardProps {
|
|
||||||
video: VideoData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const screens = useBreakpoint();
|
|
||||||
const isMobile = !screens.md;
|
|
||||||
const { collapseVideo } = useExpandedVideo();
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const playerRef = useRef<VideoPlayerRef | null>(null);
|
|
||||||
const [hasUpvoted, setHasUpvoted] = useState(false);
|
|
||||||
const [upvoteCount, setUpvoteCount] = useState(video.upvoteCount);
|
|
||||||
const [upvoting, setUpvoting] = useState(false);
|
|
||||||
const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
|
|
||||||
const [addToPlaylistOpen, setAddToPlaylistOpen] = useState(false);
|
|
||||||
const [videoHeight, setVideoHeight] = useState<number>(0);
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [isExpanding, setIsExpanding] = useState(true);
|
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
||||||
|
|
||||||
// Parent padding to break out of (matches MediaPublicLayout)
|
|
||||||
const pad = isMobile ? 8 : 12;
|
|
||||||
|
|
||||||
// Extract title from filename
|
|
||||||
const title = video.filename.replace(/\.[^/.]+$/, '');
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
useKeyboardShortcuts({
|
|
||||||
playerRef: playerRef as React.RefObject<VideoPlayerRef>,
|
|
||||||
onClose: collapseVideo,
|
|
||||||
enabled: !isExpanding,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger expand animation after mount
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setIsExpanding(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Scroll the expanded card into view smoothly
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
containerRef.current.scrollIntoView({
|
|
||||||
behavior: isMobile ? 'auto' : 'smooth',
|
|
||||||
block: 'nearest',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 350);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
// Track video container height for chat sizing
|
|
||||||
useEffect(() => {
|
|
||||||
const videoContainer = videoContainerRef.current;
|
|
||||||
if (!videoContainer) return;
|
|
||||||
|
|
||||||
const updateHeight = () => {
|
|
||||||
const height = videoContainer.offsetHeight;
|
|
||||||
if (height > 0) setVideoHeight(height);
|
|
||||||
};
|
|
||||||
|
|
||||||
const timer = setTimeout(updateHeight, 350);
|
|
||||||
const resizeObserver = new ResizeObserver(updateHeight);
|
|
||||||
resizeObserver.observe(videoContainer);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, [isExpanding]);
|
|
||||||
|
|
||||||
// Poll currentTime for ReactionButtons
|
|
||||||
useEffect(() => {
|
|
||||||
if (isExpanding) return;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const el = playerRef.current?.getVideoElement();
|
|
||||||
if (el) setCurrentTime(el.currentTime);
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isExpanding]);
|
|
||||||
|
|
||||||
const handleUpvote = async () => {
|
|
||||||
if (upvoting || hasUpvoted) return;
|
|
||||||
try {
|
|
||||||
setUpvoting(true);
|
|
||||||
await mediaPublicApi.post(`/public/${video.id}/upvote`);
|
|
||||||
setHasUpvoted(true);
|
|
||||||
setUpvoteCount(prev => prev + 1);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Upvote failed:', error);
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
alert('Please log in to upvote videos');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUpvoting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null) => {
|
|
||||||
if (!seconds) return '';
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCount = (count: number) => {
|
|
||||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
||||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
|
||||||
return count.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
style={{
|
|
||||||
gridColumn: '1 / -1',
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
|
|
||||||
maxHeight: isExpanding ? 0 : 3000,
|
|
||||||
opacity: isExpanding ? 0 : 1,
|
|
||||||
// Break out of parent padding to fill full content area
|
|
||||||
marginLeft: -pad,
|
|
||||||
marginRight: -pad,
|
|
||||||
width: `calc(100% + ${pad * 2}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Main content: video (left) + chat (right) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: isMobile ? 'column' : 'row',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Video section */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{/* Video player */}
|
|
||||||
<div
|
|
||||||
ref={videoContainerRef}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
|
||||||
aspectRatio: video.orientation === 'V' ? '9/16' : '16/9',
|
|
||||||
// Cap height so player controls stay above the info bar
|
|
||||||
maxHeight: isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 50px)',
|
|
||||||
background: '#000',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VideoPlayer
|
|
||||||
ref={playerRef}
|
|
||||||
videoId={video.id}
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
autoplay={!isExpanding}
|
|
||||||
controls={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Progress Bar Reaction Markers */}
|
|
||||||
{video.durationSeconds && playerRef.current?.getVideoElement() && (
|
|
||||||
<ProgressBarMarkers
|
|
||||||
videoId={video.id}
|
|
||||||
durationSeconds={video.durationSeconds}
|
|
||||||
playerRef={{ current: playerRef.current.getVideoElement()! }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat panel (desktop only, beside video) */}
|
|
||||||
{!isMobile && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 280,
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
borderLeft: `1px solid ${token.colorBorder}`,
|
|
||||||
background: token.colorBgElevated,
|
|
||||||
height: videoHeight > 0 ? videoHeight : 'auto',
|
|
||||||
maxHeight: videoHeight > 0 ? videoHeight : 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MediaAuthProvider>
|
|
||||||
<LiveChat
|
|
||||||
videoId={video.id}
|
|
||||||
isOpen={true}
|
|
||||||
onRequestLogin={() => {}}
|
|
||||||
/>
|
|
||||||
</MediaAuthProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom info bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
padding: isMobile ? '6px 12px' : '6px 16px',
|
|
||||||
borderTop: `1px solid ${token.colorBorder}`,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Close button */}
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
onClick={collapseVideo}
|
|
||||||
size="small"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: isMobile ? 12 : 14,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: token.colorText,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags + stats */}
|
|
||||||
<Space size={8} style={{ flexShrink: 0 }}>
|
|
||||||
{video.quality && (
|
|
||||||
<Tag color="purple" style={{ margin: 0, fontSize: 10 }}>{video.quality}</Tag>
|
|
||||||
)}
|
|
||||||
{video.durationSeconds && (
|
|
||||||
<Tag style={{ margin: 0, fontSize: 10 }}>{formatDuration(video.durationSeconds)}</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Space size={12} style={{ color: token.colorTextSecondary, fontSize: 12, flexShrink: 0 }}>
|
|
||||||
<span><EyeOutlined /> {formatCount(video.viewCount)}</span>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
{/* Reaction emoji buttons */}
|
|
||||||
<ReactionButtons videoId={video.id} currentTime={currentTime} />
|
|
||||||
|
|
||||||
{/* Upvote */}
|
|
||||||
<Button
|
|
||||||
type={hasUpvoted ? 'primary' : 'text'}
|
|
||||||
icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
|
|
||||||
onClick={handleUpvote}
|
|
||||||
loading={upvoting}
|
|
||||||
disabled={hasUpvoted}
|
|
||||||
size="small"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{formatCount(upvoteCount)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Add to Playlist */}
|
|
||||||
{isAuthenticated && (
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<OrderedListOutlined />}
|
|
||||||
onClick={() => setAddToPlaylistOpen(true)}
|
|
||||||
size="small"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
title="Add to playlist"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile chat toggle */}
|
|
||||||
{isMobile && (
|
|
||||||
<Button
|
|
||||||
type={isMobileChatOpen ? 'primary' : 'text'}
|
|
||||||
icon={<CommentOutlined />}
|
|
||||||
onClick={() => setIsMobileChatOpen(!isMobileChatOpen)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{video.commentCount}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile chat (collapsible, below info bar) */}
|
|
||||||
{isMobileChatOpen && isMobile && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: token.colorBgElevated,
|
|
||||||
borderTop: `1px solid ${token.colorBorder}`,
|
|
||||||
height: 250,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MediaAuthProvider>
|
|
||||||
<LiveChat
|
|
||||||
videoId={video.id}
|
|
||||||
isOpen={true}
|
|
||||||
onRequestLogin={() => {}}
|
|
||||||
/>
|
|
||||||
</MediaAuthProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add to Playlist Modal */}
|
|
||||||
<AddToPlaylistModal
|
|
||||||
videoId={video.id}
|
|
||||||
open={addToPlaylistOpen}
|
|
||||||
onClose={() => setAddToPlaylistOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { Typography, Spin, theme, Grid } from 'antd';
|
|
||||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
|
||||||
import PlaylistCard from './PlaylistCard';
|
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
|
||||||
import type { PlaylistSummary } from '@/types/media';
|
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
|
|
||||||
export default function FeaturedPlaylistCarousel() {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const screens = useBreakpoint();
|
|
||||||
const isMobile = !screens.md;
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [playlists, setPlaylists] = useState<PlaylistSummary[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchFeatured = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await mediaPublicApi.get('/playlists/featured', {
|
|
||||||
params: { limit: 12 },
|
|
||||||
});
|
|
||||||
setPlaylists(data.data || []);
|
|
||||||
} catch {
|
|
||||||
// Silent fail — not critical
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchFeatured();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scroll = (direction: 'left' | 'right') => {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
const scrollAmount = isMobile ? 260 : 300;
|
|
||||||
scrollRef.current.scrollBy({
|
|
||||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
|
||||||
<Spin />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playlists.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
||||||
Featured Playlists
|
|
||||||
</Typography.Title>
|
|
||||||
|
|
||||||
{playlists.length > 3 && (
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<div
|
|
||||||
onClick={() => scroll('left')}
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: token.colorTextSecondary,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = token.colorPrimary;
|
|
||||||
e.currentTarget.style.color = token.colorPrimary;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = token.colorBorderSecondary;
|
|
||||||
e.currentTarget.style.color = token.colorTextSecondary;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LeftOutlined style={{ fontSize: 12 }} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={() => scroll('right')}
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: token.colorTextSecondary,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = token.colorPrimary;
|
|
||||||
e.currentTarget.style.color = token.colorPrimary;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = token.colorBorderSecondary;
|
|
||||||
e.currentTarget.style.color = token.colorTextSecondary;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RightOutlined style={{ fontSize: 12 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 16,
|
|
||||||
overflowX: 'auto',
|
|
||||||
scrollSnapType: 'x mandatory',
|
|
||||||
scrollbarWidth: 'none',
|
|
||||||
msOverflowStyle: 'none',
|
|
||||||
paddingBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{playlists.map((playlist) => (
|
|
||||||
<div
|
|
||||||
key={playlist.id}
|
|
||||||
style={{
|
|
||||||
minWidth: isMobile ? 240 : 280,
|
|
||||||
maxWidth: isMobile ? 240 : 280,
|
|
||||||
scrollSnapAlign: 'start',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlaylistCard playlist={playlist} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,540 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
Input,
|
|
||||||
Button,
|
|
||||||
Space,
|
|
||||||
Card,
|
|
||||||
Tag,
|
|
||||||
Progress,
|
|
||||||
Typography,
|
|
||||||
message,
|
|
||||||
Empty,
|
|
||||||
Collapse,
|
|
||||||
List,
|
|
||||||
Tooltip,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
CloudDownloadOutlined,
|
|
||||||
StopOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
CloseCircleOutlined,
|
|
||||||
LoadingOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
ExpandOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
|
|
||||||
const { TextArea } = Input;
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface FetchJob {
|
|
||||||
id: string;
|
|
||||||
urls: string[];
|
|
||||||
urlCount: number;
|
|
||||||
state: string;
|
|
||||||
progress: number;
|
|
||||||
returnvalue: {
|
|
||||||
results: Array<{
|
|
||||||
url: string;
|
|
||||||
success: boolean;
|
|
||||||
videoId?: number;
|
|
||||||
title?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
totalUrls: number;
|
|
||||||
successCount: number;
|
|
||||||
failCount: number;
|
|
||||||
} | null;
|
|
||||||
failedReason: string | null;
|
|
||||||
timestamp: number;
|
|
||||||
finishedOn: number | null;
|
|
||||||
processedOn: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FetchVideosDrawerProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATE_COLORS: Record<string, string> = {
|
|
||||||
active: 'processing',
|
|
||||||
waiting: 'default',
|
|
||||||
delayed: 'warning',
|
|
||||||
completed: 'success',
|
|
||||||
failed: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATE_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
active: <LoadingOutlined />,
|
|
||||||
waiting: <ClockCircleOutlined />,
|
|
||||||
completed: <CheckCircleOutlined />,
|
|
||||||
failed: <CloseCircleOutlined />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVideosDrawerProps) {
|
|
||||||
const [urls, setUrls] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [jobs, setJobs] = useState<FetchJob[]>([]);
|
|
||||||
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
|
||||||
const [logLines, setLogLines] = useState<string[]>([]);
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
|
||||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const prevCompletedRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Poll for job updates
|
|
||||||
const fetchJobs = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await mediaApi.get<{ jobs: FetchJob[] }>('/videos/fetch/jobs');
|
|
||||||
setJobs(data.jobs);
|
|
||||||
|
|
||||||
// Check for newly completed jobs to trigger refresh
|
|
||||||
const currentCompleted = new Set(
|
|
||||||
data.jobs.filter(j => j.state === 'completed').map(j => j.id)
|
|
||||||
);
|
|
||||||
const prev = prevCompletedRef.current;
|
|
||||||
for (const id of currentCompleted) {
|
|
||||||
if (!prev.has(id)) {
|
|
||||||
// A job just completed
|
|
||||||
onSuccess?.();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevCompletedRef.current = currentCompleted;
|
|
||||||
} catch (err) {
|
|
||||||
// Silently ignore poll errors
|
|
||||||
}
|
|
||||||
}, [onSuccess]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
fetchJobs();
|
|
||||||
pollRef.current = setInterval(fetchJobs, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (pollRef.current) {
|
|
||||||
clearInterval(pollRef.current);
|
|
||||||
pollRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [open, fetchJobs]);
|
|
||||||
|
|
||||||
// SSE log connection
|
|
||||||
useEffect(() => {
|
|
||||||
if (!expandedJobId) {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
setLogLines([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct SSE URL through the media API proxy
|
|
||||||
const baseUrl = '/media/api/videos/fetch/jobs/' + expandedJobId + '/log';
|
|
||||||
|
|
||||||
// We need the auth token for the SSE connection
|
|
||||||
// Use fetch with EventSource-like manual parsing since ES doesn't support auth headers
|
|
||||||
const controller = new AbortController();
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const connectSSE = async () => {
|
|
||||||
try {
|
|
||||||
// Get auth token from localStorage
|
|
||||||
const stored = localStorage.getItem('auth-storage');
|
|
||||||
let token = '';
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(stored);
|
|
||||||
token = parsed?.state?.accessToken || '';
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(baseUrl, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
Accept: 'text/event-stream',
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
while (!cancelled) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
buffer = lines.pop() || '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const payload = line.slice(6);
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(payload);
|
|
||||||
if (parsed.type === 'log' && parsed.data) {
|
|
||||||
setLogLines(prev => [...prev, parsed.data]);
|
|
||||||
} else if (parsed.type === 'status') {
|
|
||||||
// Job completed or failed
|
|
||||||
fetchJobs();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not JSON, might be raw text
|
|
||||||
}
|
|
||||||
} else if (line.startsWith('event: progress')) {
|
|
||||||
// Progress events are handled by polling
|
|
||||||
} else if (line.startsWith('event: done')) {
|
|
||||||
fetchJobs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
// Connection error, will retry on next expand
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setLogLines([]);
|
|
||||||
connectSSE();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [expandedJobId, fetchJobs]);
|
|
||||||
|
|
||||||
// Auto-scroll log to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
if (logContainerRef.current) {
|
|
||||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [logLines]);
|
|
||||||
|
|
||||||
// Clean up on close
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setExpandedJobId(null);
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const urlList = urls
|
|
||||||
.split('\n')
|
|
||||||
.map(u => u.trim())
|
|
||||||
.filter(u => u.length > 0);
|
|
||||||
|
|
||||||
if (urlList.length === 0) {
|
|
||||||
message.warning('Please enter at least one URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urlList.length > 20) {
|
|
||||||
message.error('Maximum 20 URLs per submission');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const { data } = await mediaApi.post('/videos/fetch', { urls: urlList });
|
|
||||||
message.success(`Fetch job submitted with ${data.urlCount} URL(s)`);
|
|
||||||
setUrls('');
|
|
||||||
// Immediately expand the new job
|
|
||||||
setExpandedJobId(data.jobId);
|
|
||||||
fetchJobs();
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(err.response?.data?.message || 'Failed to submit fetch job');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = async (jobId: string) => {
|
|
||||||
try {
|
|
||||||
await mediaApi.delete(`/videos/fetch/jobs/${jobId}`);
|
|
||||||
message.success('Job cancelled');
|
|
||||||
fetchJobs();
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(err.response?.data?.message || 'Failed to cancel job');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (ts: number | null) => {
|
|
||||||
if (!ts) return '-';
|
|
||||||
return new Date(ts).toLocaleTimeString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeJobs = jobs.filter(j => j.state === 'active' || j.state === 'waiting');
|
|
||||||
const recentJobs = jobs.filter(j => j.state === 'completed' || j.state === 'failed');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<CloudDownloadOutlined />
|
|
||||||
Fetch Videos
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
width={560}
|
|
||||||
destroyOnClose
|
|
||||||
>
|
|
||||||
{/* URL Input Section */}
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title="Download from URL"
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
rows={4}
|
|
||||||
placeholder={'Paste video URLs here, one per line.\n\nSupports YouTube, Twitter/X, Reddit, Vimeo, and 1000+ sites via yt-dlp.'}
|
|
||||||
value={urls}
|
|
||||||
onChange={(e) => setUrls(e.target.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
style={{ marginBottom: 12, fontFamily: 'monospace', fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<CloudDownloadOutlined />}
|
|
||||||
loading={submitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!urls.trim()}
|
|
||||||
>
|
|
||||||
Fetch
|
|
||||||
</Button>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
Max 20 URLs per submission
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Active Jobs */}
|
|
||||||
{activeJobs.length > 0 && (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={`Active Jobs (${activeJobs.length})`}
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
{activeJobs.map(job => (
|
|
||||||
<div key={job.id} style={{ marginBottom: 12 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
|
||||||
<Space size="small">
|
|
||||||
<Tag color={STATE_COLORS[job.state]} icon={STATE_ICONS[job.state]}>
|
|
||||||
{job.state}
|
|
||||||
</Tag>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{job.urlCount} URL{job.urlCount !== 1 ? 's' : ''}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
<Space size="small">
|
|
||||||
<Tooltip title={expandedJobId === job.id ? 'Collapse log' : 'Expand log'}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
icon={<ExpandOutlined />}
|
|
||||||
onClick={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<StopOutlined />}
|
|
||||||
onClick={() => handleCancel(job.id)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
{job.state === 'active' && (
|
|
||||||
<Progress
|
|
||||||
percent={job.progress || 0}
|
|
||||||
size="small"
|
|
||||||
status="active"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Log viewer */}
|
|
||||||
{expandedJobId === job.id && (
|
|
||||||
<div
|
|
||||||
ref={logContainerRef}
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
maxHeight: 300,
|
|
||||||
overflow: 'auto',
|
|
||||||
background: '#1a1a2e',
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: 8,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: '#e0e0e0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{logLines.length === 0 ? (
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>Waiting for output...</Text>
|
|
||||||
) : (
|
|
||||||
logLines.map((line, i) => (
|
|
||||||
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
||||||
{line.startsWith('[stderr]') ? (
|
|
||||||
<span style={{ color: '#ff6b6b' }}>{line}</span>
|
|
||||||
) : line.startsWith('FAILED:') ? (
|
|
||||||
<span style={{ color: '#ff6b6b' }}>{line}</span>
|
|
||||||
) : line.startsWith('---') ? (
|
|
||||||
<span style={{ color: '#74b9ff' }}>{line}</span>
|
|
||||||
) : line.includes('Imported as video') ? (
|
|
||||||
<span style={{ color: '#55efc4' }}>{line}</span>
|
|
||||||
) : (
|
|
||||||
line
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* URL list */}
|
|
||||||
<div style={{ marginTop: 4 }}>
|
|
||||||
{job.urls.slice(0, 3).map((url, i) => (
|
|
||||||
<Text key={i} type="secondary" ellipsis style={{ display: 'block', fontSize: 11 }}>
|
|
||||||
{url}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
{job.urls.length > 3 && (
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
...and {job.urls.length - 3} more
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent Jobs */}
|
|
||||||
{recentJobs.length > 0 && (
|
|
||||||
<Card size="small" title="Recent Jobs">
|
|
||||||
<Collapse
|
|
||||||
accordion
|
|
||||||
ghost
|
|
||||||
activeKey={expandedJobId && recentJobs.some(j => j.id === expandedJobId) ? expandedJobId : undefined}
|
|
||||||
onChange={(key) => setExpandedJobId(typeof key === 'string' ? key : key?.[0] || null)}
|
|
||||||
items={recentJobs.map(job => ({
|
|
||||||
key: job.id,
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<Tag color={STATE_COLORS[job.state]} icon={STATE_ICONS[job.state]}>
|
|
||||||
{job.state}
|
|
||||||
</Tag>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{job.urlCount} URL{job.urlCount !== 1 ? 's' : ''}
|
|
||||||
{job.returnvalue && (
|
|
||||||
<> — {job.returnvalue.successCount} ok, {job.returnvalue.failCount} failed</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
{formatTime(job.finishedOn || job.timestamp)}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<div>
|
|
||||||
{/* Results list */}
|
|
||||||
{job.returnvalue?.results && (
|
|
||||||
<List
|
|
||||||
size="small"
|
|
||||||
dataSource={job.returnvalue.results}
|
|
||||||
renderItem={(result) => (
|
|
||||||
<List.Item>
|
|
||||||
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
|
||||||
<Text ellipsis style={{ fontSize: 12, maxWidth: 450 }}>
|
|
||||||
{result.url}
|
|
||||||
</Text>
|
|
||||||
{result.success ? (
|
|
||||||
<Tag color="success" style={{ fontSize: 11 }}>
|
|
||||||
Imported: {result.title || `Video #${result.videoId}`}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="error" style={{ fontSize: 11 }}>
|
|
||||||
{result.error || 'Unknown error'}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{job.failedReason && (
|
|
||||||
<Text type="danger" style={{ fontSize: 12 }}>
|
|
||||||
{job.failedReason}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{/* Log viewer for recent jobs */}
|
|
||||||
{expandedJobId === job.id && logLines.length > 0 && (
|
|
||||||
<div
|
|
||||||
ref={logContainerRef}
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
maxHeight: 200,
|
|
||||||
overflow: 'auto',
|
|
||||||
background: '#1a1a2e',
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: 8,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: '#e0e0e0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{logLines.map((line, i) => (
|
|
||||||
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
||||||
{line}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{jobs.length === 0 && !submitting && (
|
|
||||||
<Empty
|
|
||||||
description="No fetch jobs yet"
|
|
||||||
style={{ marginTop: 32 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Refresh button */}
|
|
||||||
{jobs.length > 0 && (
|
|
||||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={fetchJobs}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,582 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Input,
|
|
||||||
Button,
|
|
||||||
Space,
|
|
||||||
Typography,
|
|
||||||
Tag,
|
|
||||||
Alert,
|
|
||||||
Spin,
|
|
||||||
theme,
|
|
||||||
Avatar,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
SendOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
WarningOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
ArrowDownOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useMediaAuth } from '@/contexts/MediaAuthContext';
|
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
const { TextArea } = Input;
|
|
||||||
|
|
||||||
interface Comment {
|
|
||||||
id: number;
|
|
||||||
type: 'comment';
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
safetyStatus?: string | null;
|
|
||||||
safetyCategories?: any;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Reaction {
|
|
||||||
id: number;
|
|
||||||
type: 'reaction';
|
|
||||||
reactionType: string;
|
|
||||||
emoji: string;
|
|
||||||
videoTimestamp: number;
|
|
||||||
formattedTime: string;
|
|
||||||
createdAt: string;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimelineItem = Comment | Reaction;
|
|
||||||
|
|
||||||
interface LiveChatProps {
|
|
||||||
videoId: number;
|
|
||||||
isOpen: boolean;
|
|
||||||
onRequestLogin?: () => void;
|
|
||||||
flexWidth?: boolean; // For vertical video side-by-side layout (future use)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LiveChat({
|
|
||||||
videoId,
|
|
||||||
isOpen,
|
|
||||||
onRequestLogin,
|
|
||||||
}: LiveChatProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const { isAuthenticated, isApproved } = useMediaAuth();
|
|
||||||
|
|
||||||
// Timeline state
|
|
||||||
const [timeline, setTimeline] = useState<TimelineItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Input state
|
|
||||||
const [commentInput, setCommentInput] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
// SSE state
|
|
||||||
const [sseConnected, setSSEConnected] = useState(false);
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
|
||||||
|
|
||||||
// Scroll state
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
|
||||||
const [showNewMessagesButton, setShowNewMessagesButton] = useState(false);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
const scrollToBottom = useCallback((smooth = true) => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
scrollContainerRef.current.scrollTo({
|
|
||||||
top: scrollContainerRef.current.scrollHeight,
|
|
||||||
behavior: smooth ? 'smooth' : 'auto',
|
|
||||||
});
|
|
||||||
setShowNewMessagesButton(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Check if user is near bottom (within 100px)
|
|
||||||
const checkScrollPosition = useCallback(() => {
|
|
||||||
if (!scrollContainerRef.current) return;
|
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
|
||||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
||||||
const nearBottom = distanceFromBottom < 100;
|
|
||||||
|
|
||||||
setIsNearBottom(nearBottom);
|
|
||||||
|
|
||||||
if (!nearBottom && !showNewMessagesButton) {
|
|
||||||
setShowNewMessagesButton(true);
|
|
||||||
}
|
|
||||||
}, [showNewMessagesButton]);
|
|
||||||
|
|
||||||
// Fetch initial comments and reactions
|
|
||||||
const fetchInitialTimeline = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Use relative URLs to go through nginx proxy instead of direct media API access
|
|
||||||
// This avoids SSL certificate issues in production and works for both admin and public gallery
|
|
||||||
const [commentsRes, reactionsRes] = await Promise.all([
|
|
||||||
fetch(`/media/public/${videoId}/comments?limit=200`),
|
|
||||||
fetch(`/media/reactions/${videoId}/chat?limit=500`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!commentsRes.ok || !reactionsRes.ok) {
|
|
||||||
throw new Error('Failed to fetch timeline');
|
|
||||||
}
|
|
||||||
|
|
||||||
const commentsData = await commentsRes.json();
|
|
||||||
const reactionsData = await reactionsRes.json();
|
|
||||||
|
|
||||||
const comments: Comment[] = commentsData.comments.map((c: any) => ({
|
|
||||||
...c,
|
|
||||||
type: 'comment' as const,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const reactions: Reaction[] = reactionsData.reactions.map((r: any) => ({
|
|
||||||
...r,
|
|
||||||
type: 'reaction' as const,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Merge and sort by createdAt
|
|
||||||
const merged = [...comments, ...reactions].sort(
|
|
||||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
setTimeline(merged);
|
|
||||||
|
|
||||||
// Scroll to bottom after loading
|
|
||||||
setTimeout(() => scrollToBottom(false), 100);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch timeline:', err);
|
|
||||||
setError('Failed to load chat. Please refresh the page.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Setup SSE connection
|
|
||||||
const setupSSE = useCallback(() => {
|
|
||||||
if (!isOpen || eventSourceRef.current) return;
|
|
||||||
|
|
||||||
// Use relative URL to go through nginx proxy
|
|
||||||
const sseUrl = `/media/public/${videoId}/chat-stream`;
|
|
||||||
|
|
||||||
const eventSource = new EventSource(sseUrl);
|
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
|
||||||
console.log('SSE connected');
|
|
||||||
setSSEConnected(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.type === 'connected') {
|
|
||||||
console.log('SSE connection confirmed for video', data.videoId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'new_comment') {
|
|
||||||
const newComment: Comment = {
|
|
||||||
...data.comment,
|
|
||||||
type: 'comment',
|
|
||||||
};
|
|
||||||
|
|
||||||
setTimeline((prev) => {
|
|
||||||
// Check for duplicates
|
|
||||||
if (prev.some((item) => item.type === 'comment' && item.id === newComment.id)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
// Add to timeline (respecting max limit)
|
|
||||||
const updated = [...prev, newComment];
|
|
||||||
return updated.slice(-200); // Keep last 200 comments
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-scroll if near bottom
|
|
||||||
if (isNearBottom) {
|
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
|
||||||
} else {
|
|
||||||
setShowNewMessagesButton(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === 'new_reaction') {
|
|
||||||
const newReaction: Reaction = {
|
|
||||||
...data.reaction,
|
|
||||||
type: 'reaction',
|
|
||||||
};
|
|
||||||
|
|
||||||
setTimeline((prev) => {
|
|
||||||
// Check for duplicates
|
|
||||||
if (prev.some((item) => item.type === 'reaction' && item.id === newReaction.id)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
// Add to timeline (respecting max limit)
|
|
||||||
const updated = [...prev, newReaction];
|
|
||||||
return updated.slice(-500); // Keep last 500 reactions
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-scroll if near bottom
|
|
||||||
if (isNearBottom) {
|
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
|
||||||
} else {
|
|
||||||
setShowNewMessagesButton(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse SSE message:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
console.error('SSE connection error');
|
|
||||||
setSSEConnected(false);
|
|
||||||
|
|
||||||
// Auto-reconnect after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
setupSSE();
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSourceRef.current = eventSource;
|
|
||||||
}, [isOpen, videoId, isNearBottom, scrollToBottom]);
|
|
||||||
|
|
||||||
// Cleanup SSE on unmount or when closed
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Mark thread as read when chat opens
|
|
||||||
const markAsRead = useCallback(async () => {
|
|
||||||
if (!isAuthenticated) return;
|
|
||||||
try {
|
|
||||||
await mediaApi.post(`/media/chat/threads/${videoId}/read`);
|
|
||||||
} catch {
|
|
||||||
// Non-critical
|
|
||||||
}
|
|
||||||
}, [videoId, isAuthenticated]);
|
|
||||||
|
|
||||||
// Fetch timeline and setup SSE when component opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
fetchInitialTimeline();
|
|
||||||
setupSSE();
|
|
||||||
markAsRead();
|
|
||||||
}
|
|
||||||
}, [isOpen, videoId, setupSSE, markAsRead]);
|
|
||||||
|
|
||||||
// Handle comment submission
|
|
||||||
const handleSubmitComment = async () => {
|
|
||||||
if (!commentInput.trim() || submitting) return;
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
if (onRequestLogin) {
|
|
||||||
onRequestLogin();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
await mediaPublicApi.post(`/public/${videoId}/comments`, {
|
|
||||||
content: commentInput.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear input
|
|
||||||
setCommentInput('');
|
|
||||||
|
|
||||||
// Note: New comment will appear via SSE broadcast
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to submit comment:', err);
|
|
||||||
|
|
||||||
if (err.response?.status === 429) {
|
|
||||||
alert('Rate limit exceeded. Please wait a minute before commenting again.');
|
|
||||||
} else if (err.response?.status === 401) {
|
|
||||||
alert('Please log in to comment.');
|
|
||||||
if (onRequestLogin) {
|
|
||||||
onRequestLogin();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert('Failed to submit comment. Please try again.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format relative time (e.g., "2m ago")
|
|
||||||
const formatRelativeTime = (isoString: string) => {
|
|
||||||
const now = new Date().getTime();
|
|
||||||
const then = new Date(isoString).getTime();
|
|
||||||
const diffSeconds = Math.floor((now - then) / 1000);
|
|
||||||
|
|
||||||
if (diffSeconds < 60) return 'just now';
|
|
||||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
|
||||||
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
|
|
||||||
return `${Math.floor(diffSeconds / 86400)}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render timeline item
|
|
||||||
const renderTimelineItem = (item: TimelineItem) => {
|
|
||||||
if (item.type === 'comment') {
|
|
||||||
const isFlagged = item.safetyStatus === 'flagged';
|
|
||||||
const isApproved = item.safetyStatus === 'approved';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`comment-${item.id}`}
|
|
||||||
style={{
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
opacity: isFlagged ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
||||||
{/* Header: User + Time + Badges */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<Avatar size="small" icon={<UserOutlined />} />
|
|
||||||
<Text strong style={{ fontSize: 13 }}>
|
|
||||||
{item.user?.name || 'Anonymous'}
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{formatRelativeTime(item.createdAt)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Safety/Moderation Badges */}
|
|
||||||
{isFlagged && (
|
|
||||||
<Tag
|
|
||||||
color="warning"
|
|
||||||
icon={<WarningOutlined />}
|
|
||||||
style={{ fontSize: 11, marginLeft: 'auto' }}
|
|
||||||
>
|
|
||||||
Flagged
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
{isApproved && (
|
|
||||||
<Tag
|
|
||||||
color="success"
|
|
||||||
icon={<CheckCircleOutlined />}
|
|
||||||
style={{ fontSize: 11, marginLeft: isFlagged ? 0 : 'auto' }}
|
|
||||||
>
|
|
||||||
Verified
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comment Content */}
|
|
||||||
<Text style={{ fontSize: 14, whiteSpace: 'pre-wrap' }}>
|
|
||||||
{item.content}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Safety Categories */}
|
|
||||||
{isFlagged && item.safetyCategories && (
|
|
||||||
<div style={{ marginTop: 4 }}>
|
|
||||||
<Text type="warning" style={{ fontSize: 11 }}>
|
|
||||||
⚠️ {JSON.stringify(item.safetyCategories)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === 'reaction') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`reaction-${item.id}`}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
background: token.colorBgLayout,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space size={8}>
|
|
||||||
<Text style={{ fontSize: 20 }}>{item.emoji}</Text>
|
|
||||||
<Text style={{ fontSize: 12 }}>
|
|
||||||
<Text strong>{item.user?.name || 'Anonymous'}</Text>
|
|
||||||
{' reacted at '}
|
|
||||||
<Text type="secondary">{item.formattedTime}</Text>
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%',
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 16,
|
|
||||||
borderBottom: `1px solid ${token.colorBorder}`,
|
|
||||||
background: token.colorBgLayout,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space>
|
|
||||||
<Text strong>Live Chat</Text>
|
|
||||||
{sseConnected && (
|
|
||||||
<Tag color="success" style={{ fontSize: 11 }}>
|
|
||||||
Live
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
{!sseConnected && !loading && (
|
|
||||||
<Tag color="default" style={{ fontSize: 11 }}>
|
|
||||||
Connecting...
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<div
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
onScroll={checkScrollPosition}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading && (
|
|
||||||
<div style={{ padding: 60, textAlign: 'center' }}>
|
|
||||||
<Spin size="large" tip="Loading chat..." />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div style={{ padding: 16 }}>
|
|
||||||
<Alert message={error} type="error" showIcon />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && timeline.length === 0 && (
|
|
||||||
<div style={{ padding: 60, textAlign: 'center' }}>
|
|
||||||
<Text type="secondary">No messages yet. Be the first to comment!</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && timeline.map(renderTimelineItem)}
|
|
||||||
|
|
||||||
{/* New Messages Button */}
|
|
||||||
{showNewMessagesButton && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 16,
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<ArrowDownOutlined />}
|
|
||||||
onClick={() => scrollToBottom()}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
New messages ↓
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comment Input */}
|
|
||||||
{!isApproved && isAuthenticated && (
|
|
||||||
<div style={{ padding: 16, borderTop: `1px solid ${token.colorBorder}` }}>
|
|
||||||
<Alert
|
|
||||||
message="Account pending approval"
|
|
||||||
description="Your account is pending approval. You'll be able to comment once approved."
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
closable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<div style={{ padding: 16, borderTop: `1px solid ${token.colorBorder}` }}>
|
|
||||||
<Alert
|
|
||||||
message="Login required"
|
|
||||||
description={
|
|
||||||
<span>
|
|
||||||
Please{' '}
|
|
||||||
<Button type="link" size="small" onClick={onRequestLogin} style={{ padding: 0 }}>
|
|
||||||
log in
|
|
||||||
</Button>{' '}
|
|
||||||
to join the conversation.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && isApproved && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 16,
|
|
||||||
borderTop: `1px solid ${token.colorBorder}`,
|
|
||||||
background: token.colorBgLayout,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
|
||||||
<TextArea
|
|
||||||
value={commentInput}
|
|
||||||
onChange={(e) => setCommentInput(e.target.value)}
|
|
||||||
onPressEnter={(e) => {
|
|
||||||
if (!e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmitComment();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Type a message... (Shift+Enter for new line)"
|
|
||||||
maxLength={1000}
|
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SendOutlined />}
|
|
||||||
onClick={handleSubmitComment}
|
|
||||||
loading={submitting}
|
|
||||||
disabled={!commentInput.trim()}
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
<Text type="secondary" style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
|
|
||||||
{commentInput.length}/1000
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom';
|
|
||||||
import { Input, Select, theme, Grid } from 'antd';
|
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
|
|
||||||
export default function MediaBottomNav() {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const screens = useBreakpoint();
|
|
||||||
const isMobile = !screens.md;
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// Initialize from URL params
|
|
||||||
const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
|
|
||||||
const sort = (searchParams.get('sort') as 'recent' | 'popular' | 'most_viewed') || 'recent';
|
|
||||||
|
|
||||||
const isShorts = location.pathname === '/gallery/shorts';
|
|
||||||
|
|
||||||
// Debounce search → URL param (skip on shorts — search navigates to gallery on Enter)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isShorts) return;
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
if (searchInput) {
|
|
||||||
params.set('search', searchInput);
|
|
||||||
} else {
|
|
||||||
params.delete('search');
|
|
||||||
}
|
|
||||||
setSearchParams(params, { replace: true });
|
|
||||||
}, 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchInput, isShorts]);
|
|
||||||
|
|
||||||
const handleSortChange = (value: 'recent' | 'popular' | 'most_viewed') => {
|
|
||||||
if (isShorts) {
|
|
||||||
// Navigate to gallery with sort param
|
|
||||||
navigate(`/gallery?sort=${value}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
if (value !== 'recent') {
|
|
||||||
params.set('sort', value);
|
|
||||||
} else {
|
|
||||||
params.delete('sort');
|
|
||||||
}
|
|
||||||
setSearchParams(params, { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
// On shorts page, Enter in search navigates to gallery with the search term
|
|
||||||
const handleSearchSubmit = () => {
|
|
||||||
if (isShorts && searchInput.trim()) {
|
|
||||||
navigate(`/gallery?search=${encodeURIComponent(searchInput.trim())}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: 48,
|
|
||||||
background: isShorts ? 'rgba(0, 0, 0, 0.75)' : token.colorBgContainer,
|
|
||||||
backdropFilter: isShorts ? 'blur(12px)' : undefined,
|
|
||||||
borderTop: isShorts ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: isMobile ? '0 8px' : '0 16px',
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Search videos..."
|
|
||||||
prefix={<SearchOutlined style={{ color: token.colorTextTertiary }} />}
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
onPressEnter={handleSearchSubmit}
|
|
||||||
allowClear
|
|
||||||
size="small"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={sort}
|
|
||||||
onChange={handleSortChange}
|
|
||||||
size="small"
|
|
||||||
style={{ width: isMobile ? 110 : 140, flexShrink: 0 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'recent', label: 'Recent' },
|
|
||||||
{ value: 'popular', label: 'Popular' },
|
|
||||||
{ value: 'most_viewed', label: 'Most Viewed' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,728 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { Typography, Space, Tooltip, Badge, theme } from 'antd';
|
|
||||||
import {
|
|
||||||
HomeOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
VideoCameraOutlined,
|
|
||||||
StarOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
LoginOutlined,
|
|
||||||
LogoutOutlined,
|
|
||||||
BarChartOutlined,
|
|
||||||
MessageOutlined,
|
|
||||||
MenuFoldOutlined,
|
|
||||||
MenuUnfoldOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
RightOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
import { hexToRgba } from '@/utils/color';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatThread {
|
|
||||||
mediaId: number;
|
|
||||||
videoTitle: string;
|
|
||||||
unreadCount: number;
|
|
||||||
lastMessage: {
|
|
||||||
content: string;
|
|
||||||
userName: string;
|
|
||||||
createdAt: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SectionState {
|
|
||||||
content: boolean;
|
|
||||||
activity: boolean;
|
|
||||||
online: boolean;
|
|
||||||
account: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MediaSidebar() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
|
|
||||||
// Only hydrate auth if tokens exist (prevents 401 errors on public pages)
|
|
||||||
const user = useAuthStore((state) => state.user);
|
|
||||||
const logout = useAuthStore((state) => state.logout);
|
|
||||||
const hydrate = useAuthStore((state) => state.hydrate);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check if auth data exists before attempting to hydrate
|
|
||||||
const authData = localStorage.getItem('cml-auth');
|
|
||||||
if (authData) {
|
|
||||||
hydrate();
|
|
||||||
}
|
|
||||||
}, [hydrate]);
|
|
||||||
|
|
||||||
// Sidebar collapse state (persisted in localStorage)
|
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('media_sidebar_collapsed');
|
|
||||||
return saved ? JSON.parse(saved) : false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Section collapse states (persisted in localStorage)
|
|
||||||
const [sections, setSections] = useState<SectionState>(() => {
|
|
||||||
const saved = localStorage.getItem('media_sidebar_sections');
|
|
||||||
return saved
|
|
||||||
? JSON.parse(saved)
|
|
||||||
: { content: true, activity: true, online: true, account: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Chat threads state
|
|
||||||
const [chatThreads, setChatThreads] = useState<ChatThread[]>([]);
|
|
||||||
|
|
||||||
const fetchChatThreads = useCallback(async () => {
|
|
||||||
if (!user) return;
|
|
||||||
try {
|
|
||||||
const { data } = await mediaApi.get('/media/chat/threads', { params: { limit: '5' } });
|
|
||||||
setChatThreads(data.threads || []);
|
|
||||||
} catch {
|
|
||||||
// Silent fail for sidebar data
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// Fetch chat threads periodically when user is logged in
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
fetchChatThreads();
|
|
||||||
const interval = setInterval(fetchChatThreads, 30000); // Refresh every 30s
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
} else {
|
|
||||||
setChatThreads([]);
|
|
||||||
}
|
|
||||||
}, [user, fetchChatThreads]);
|
|
||||||
|
|
||||||
// Save collapse state to localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('media_sidebar_collapsed', JSON.stringify(collapsed));
|
|
||||||
}, [collapsed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('media_sidebar_sections', JSON.stringify(sections));
|
|
||||||
}, [sections]);
|
|
||||||
|
|
||||||
// Derived hover colors
|
|
||||||
const hoverBg = hexToRgba(token.colorPrimary, 0.1);
|
|
||||||
const userInfoBg = hexToRgba(token.colorPrimary, 0.05);
|
|
||||||
|
|
||||||
// Navigation items
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
|
|
||||||
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
|
|
||||||
{ key: 'videos', label: 'Videos', icon: <VideoCameraOutlined />, path: '/gallery/videos' },
|
|
||||||
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
|
|
||||||
{ key: 'playback', label: 'Playback', icon: <PlayCircleOutlined />, path: '/gallery/playback' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Determine active nav item from current path (longest match wins)
|
|
||||||
const getActiveKey = () => {
|
|
||||||
const path = location.pathname;
|
|
||||||
if (path === '/gallery') return 'all';
|
|
||||||
const match = [...navItems]
|
|
||||||
.sort((a, b) => b.path.length - a.path.length)
|
|
||||||
.find((item) => path.startsWith(item.path));
|
|
||||||
return match ? match.key : 'all';
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeKey = getActiveKey();
|
|
||||||
|
|
||||||
// Toggle section collapse
|
|
||||||
const toggleSection = (section: keyof SectionState) => {
|
|
||||||
setSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle navigation
|
|
||||||
const handleNavigate = (path: string) => {
|
|
||||||
navigate(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle logout
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout();
|
|
||||||
navigate('/gallery');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sidebar width
|
|
||||||
const sidebarWidth = collapsed ? 64 : 256;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: sidebarWidth,
|
|
||||||
height: '100vh',
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
transition: 'width 0.3s ease',
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'fixed',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: collapsed ? '16px 8px' : '16px',
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
|
||||||
textAlign: collapsed ? 'center' : 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!collapsed && (
|
|
||||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: 18,
|
|
||||||
color: token.colorPrimary,
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Media Gallery
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'rgba(255,255,255,0.45)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Video Platform
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
{collapsed && (
|
|
||||||
<PlayCircleOutlined
|
|
||||||
style={{
|
|
||||||
fontSize: 24,
|
|
||||||
color: token.colorPrimary,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Content Navigation Section */}
|
|
||||||
<div style={{ padding: collapsed ? '12px 0' : '12px' }}>
|
|
||||||
{/* Section header */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div
|
|
||||||
onClick={() => toggleSection('content')}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '8px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255,255,255,0.45)',
|
|
||||||
letterSpacing: '1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
CONTENT
|
|
||||||
</Text>
|
|
||||||
{sections.content ? (
|
|
||||||
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
|
||||||
) : (
|
|
||||||
<RightOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Nav items */}
|
|
||||||
{sections.content && (
|
|
||||||
<div style={{ marginTop: collapsed ? 0 : 8 }}>
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = activeKey === item.key;
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
key={item.key}
|
|
||||||
title={collapsed ? item.label : ''}
|
|
||||||
placement="right"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={() => handleNavigate(item.path)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
|
||||||
margin: collapsed ? '4px 0' : '2px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isActive ? token.colorPrimary : 'transparent',
|
|
||||||
borderRadius: collapsed ? 0 : 8,
|
|
||||||
color: isActive
|
|
||||||
? '#fff'
|
|
||||||
: 'rgba(255,255,255,0.85)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.background = hoverBg;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 18 }}>{item.icon}</span>
|
|
||||||
{!collapsed && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'inherit',
|
|
||||||
fontWeight: isActive ? 500 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Activity Section */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div style={{ padding: '12px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
|
||||||
<div
|
|
||||||
onClick={() => toggleSection('activity')}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '8px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255,255,255,0.45)',
|
|
||||||
letterSpacing: '1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
MY CHATS
|
|
||||||
</Text>
|
|
||||||
{sections.activity ? (
|
|
||||||
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
|
||||||
) : (
|
|
||||||
<RightOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sections.activity && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
maxHeight: 200,
|
|
||||||
overflowY: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{chatThreads.length === 0 ? (
|
|
||||||
<div style={{ padding: '12px 16px' }}>
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'rgba(255,255,255,0.35)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user ? 'No chat threads yet' : 'Sign in to see chats'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
chatThreads.map((thread) => (
|
|
||||||
<div
|
|
||||||
key={thread.mediaId}
|
|
||||||
onClick={() => navigate(`/gallery/watch/${thread.mediaId}`)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'rgba(255,255,255,0.65)',
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = hoverBg;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
|
||||||
<MessageOutlined style={{ fontSize: 12, color: token.colorPrimary }} />
|
|
||||||
<Text
|
|
||||||
ellipsis
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'rgba(255,255,255,0.85)',
|
|
||||||
fontWeight: thread.unreadCount > 0 ? 600 : 400,
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{thread.videoTitle}
|
|
||||||
</Text>
|
|
||||||
{thread.unreadCount > 0 && (
|
|
||||||
<Badge count={thread.unreadCount} size="small" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{thread.lastMessage && (
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
ellipsis
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255,255,255,0.35)',
|
|
||||||
display: 'block',
|
|
||||||
paddingLeft: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{thread.lastMessage.userName}: {thread.lastMessage.content}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Online Section */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div style={{ padding: '12px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
|
||||||
<div
|
|
||||||
onClick={() => toggleSection('online')}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '8px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255,255,255,0.45)',
|
|
||||||
letterSpacing: '1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ONLINE
|
|
||||||
</Text>
|
|
||||||
{sections.online ? (
|
|
||||||
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
|
||||||
) : (
|
|
||||||
<RightOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sections.online && (
|
|
||||||
<div style={{ padding: '12px 16px' }}>
|
|
||||||
<Space>
|
|
||||||
<TeamOutlined style={{ color: token.colorPrimary }} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: 'rgba(255,255,255,0.85)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Anonymous viewers
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Account Section */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: collapsed ? '12px 0' : '12px',
|
|
||||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!collapsed && (
|
|
||||||
<div
|
|
||||||
onClick={() => toggleSection('account')}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '8px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255,255,255,0.45)',
|
|
||||||
letterSpacing: '1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ACCOUNT
|
|
||||||
</Text>
|
|
||||||
{sections.account ? (
|
|
||||||
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
|
||||||
) : (
|
|
||||||
<RightOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sections.account && (
|
|
||||||
<div style={{ marginTop: collapsed ? 0 : 8 }}>
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
{/* User info */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '12px 16px',
|
|
||||||
marginBottom: 8,
|
|
||||||
background: userInfoBg,
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space>
|
|
||||||
<UserOutlined style={{ color: token.colorPrimary }} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: 'rgba(255,255,255,0.85)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.email}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* My Stats */}
|
|
||||||
{(() => {
|
|
||||||
const isActive = location.pathname === '/gallery/my-stats';
|
|
||||||
return (
|
|
||||||
<Tooltip title={collapsed ? 'My Stats' : ''} placement="right">
|
|
||||||
<div
|
|
||||||
onClick={() => navigate('/gallery/my-stats')}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
|
||||||
margin: collapsed ? '4px 0' : '2px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isActive ? token.colorPrimary : 'transparent',
|
|
||||||
borderRadius: collapsed ? 0 : 8,
|
|
||||||
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isActive) e.currentTarget.style.background = hoverBg;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BarChartOutlined style={{ fontSize: 18 }} />
|
|
||||||
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit', fontWeight: isActive ? 500 : 400 }}>My Stats</Text>}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Settings */}
|
|
||||||
{(() => {
|
|
||||||
const isActive = location.pathname === '/gallery/my-settings';
|
|
||||||
return (
|
|
||||||
<Tooltip title={collapsed ? 'Settings' : ''} placement="right">
|
|
||||||
<div
|
|
||||||
onClick={() => navigate('/gallery/my-settings')}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
|
||||||
margin: collapsed ? '4px 0' : '2px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isActive ? token.colorPrimary : 'transparent',
|
|
||||||
borderRadius: collapsed ? 0 : 8,
|
|
||||||
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isActive) e.currentTarget.style.background = hoverBg;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SettingOutlined style={{ fontSize: 18 }} />
|
|
||||||
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit', fontWeight: isActive ? 500 : 400 }}>Settings</Text>}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Sign Out */}
|
|
||||||
<Tooltip title={collapsed ? 'Sign Out' : ''} placement="right">
|
|
||||||
<div
|
|
||||||
onClick={handleLogout}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
|
||||||
margin: collapsed ? '4px 0' : '2px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: collapsed ? 0 : 8,
|
|
||||||
color: 'rgba(255,255,255,0.85)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = hoverBg;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogoutOutlined style={{ fontSize: 18 }} />
|
|
||||||
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>Sign Out</Text>}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Sign In button
|
|
||||||
<Tooltip title={collapsed ? 'Sign In' : ''} placement="right">
|
|
||||||
<div
|
|
||||||
onClick={() => navigate('/auth/login')}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
|
||||||
margin: collapsed ? '4px 0' : '2px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: collapsed ? 0 : 8,
|
|
||||||
color: token.colorPrimary,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = hoverBg;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LoginOutlined style={{ fontSize: 18 }} />
|
|
||||||
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>Sign In</Text>}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer with collapse toggle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
|
||||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!collapsed && (
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255,255,255,0.35)',
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
v2.0.0
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'rgba(255,255,255,0.65)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = hoverBg;
|
|
||||||
e.currentTarget.style.color = token.colorPrimary;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
e.currentTarget.style.color = 'rgba(255,255,255,0.65)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{collapsed ? (
|
|
||||||
<MenuUnfoldOutlined style={{ fontSize: 18 }} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MenuFoldOutlined style={{ fontSize: 16, marginRight: 8 }} />
|
|
||||||
<Text style={{ fontSize: 13, color: 'inherit' }}>Collapse</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
import { Card, Typography, Space, theme } from 'antd';
|
|
||||||
import {
|
|
||||||
PlayCircleOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
UnorderedListOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import type { PlaylistSummary } from '@/types/media';
|
|
||||||
|
|
||||||
interface PlaylistCardProps {
|
|
||||||
playlist: PlaylistSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(totalSeconds: number): string {
|
|
||||||
if (!totalSeconds) return '0:00';
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
||||||
return `${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCount(count: number): string {
|
|
||||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
||||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
|
||||||
return count.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlaylistCard({ playlist }: PlaylistCardProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
hoverable
|
|
||||||
style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
cursor: 'pointer',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
styles={{ body: { padding: 12 } }}
|
|
||||||
onClick={() => navigate(`/gallery/curated/${playlist.id}`)}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
}}
|
|
||||||
cover={
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
paddingTop: '56.25%',
|
|
||||||
background: '#000',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{playlist.thumbnailUrl ? (
|
|
||||||
<img
|
|
||||||
src={`/media${playlist.thumbnailUrl}`}
|
|
||||||
alt={playlist.name}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: `linear-gradient(135deg, ${token.colorPrimary}22, ${token.colorPrimary}44)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UnorderedListOutlined style={{ fontSize: 48, color: token.colorPrimary }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playlist overlay badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
background: 'rgba(0,0,0,0.85)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '4px 10px',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
borderTopLeftRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlayCircleOutlined />
|
|
||||||
{playlist.videoCount} videos
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
||||||
<Typography.Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
display: 'block',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
color: token.colorText,
|
|
||||||
}}
|
|
||||||
title={playlist.name}
|
|
||||||
>
|
|
||||||
{playlist.name}
|
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
<Typography.Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: token.colorTextSecondary,
|
|
||||||
display: 'block',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{playlist.creator.name || playlist.creator.email}
|
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
{playlist.description && (
|
|
||||||
<Typography.Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: token.colorTextTertiary,
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical' as const,
|
|
||||||
overflow: 'hidden',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{playlist.description}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Space size={12} style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
|
||||||
<Space size={4}>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
<span>{formatDuration(playlist.totalDurationSeconds)}</span>
|
|
||||||
</Space>
|
|
||||||
<Space size={4}>
|
|
||||||
<EyeOutlined />
|
|
||||||
<span>{formatCount(playlist.viewCount)}</span>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { Typography, Space, theme } from 'antd';
|
|
||||||
import { PlayCircleOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
|
||||||
import type { PlaylistVideoItem } from '@/types/media';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface PlaylistSidebarPanelProps {
|
|
||||||
playlistName: string;
|
|
||||||
description?: string | null;
|
|
||||||
videos: PlaylistVideoItem[];
|
|
||||||
currentVideoId: number | null;
|
|
||||||
onVideoSelect: (mediaId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds: number | null): string {
|
|
||||||
if (!seconds) return '0:00';
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlaylistSidebarPanel({
|
|
||||||
playlistName,
|
|
||||||
description,
|
|
||||||
videos,
|
|
||||||
currentVideoId,
|
|
||||||
onVideoSelect,
|
|
||||||
}: PlaylistSidebarPanelProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const currentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Auto-scroll to the current playing video
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentRef.current) {
|
|
||||||
currentRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}, [currentVideoId]);
|
|
||||||
|
|
||||||
const currentIndex = videos.findIndex((v) => v.mediaId === currentVideoId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%',
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderLeft: `1px solid rgba(255,255,255,0.06)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '16px',
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text strong style={{ fontSize: 15, display: 'block', marginBottom: 4 }}>
|
|
||||||
{playlistName}
|
|
||||||
</Text>
|
|
||||||
{description && (
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical' as const,
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{currentIndex + 1} / {videos.length} videos
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video list */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{videos.map((item, index) => {
|
|
||||||
const isCurrent = item.mediaId === currentVideoId;
|
|
||||||
const isNext = index === currentIndex + 1;
|
|
||||||
const title =
|
|
||||||
item.video.title || item.video.filename.replace(/\.[^/.]+$/, '');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
ref={isCurrent ? currentRef : undefined}
|
|
||||||
onClick={() => onVideoSelect(item.mediaId)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 10,
|
|
||||||
padding: '10px 16px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isCurrent
|
|
||||||
? `${token.colorPrimary}22`
|
|
||||||
: 'transparent',
|
|
||||||
borderLeft: isCurrent
|
|
||||||
? `3px solid ${token.colorPrimary}`
|
|
||||||
: '3px solid transparent',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isCurrent) {
|
|
||||||
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isCurrent) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Position number */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isCurrent ? (
|
|
||||||
<PlayCircleOutlined
|
|
||||||
style={{ color: token.colorPrimary, fontSize: 16 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
style={{ fontSize: 12, fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 64,
|
|
||||||
height: 36,
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: '#000',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.video.thumbnailUrl ? (
|
|
||||||
<img
|
|
||||||
src={`/media${item.video.thumbnailUrl}`}
|
|
||||||
alt=""
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: '#444',
|
|
||||||
fontSize: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlayCircleOutlined />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video info */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{isNext && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
color: token.colorPrimary,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Up Next
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
ellipsis
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
display: 'block',
|
|
||||||
fontWeight: isCurrent ? 500 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Space size={8} style={{ fontSize: 11 }}>
|
|
||||||
<Text type="secondary">
|
|
||||||
<ClockCircleOutlined style={{ marginRight: 3 }} />
|
|
||||||
{formatDuration(item.video.durationSeconds)}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,348 +0,0 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import { Card, Tag, Space, Typography, theme, Modal } from 'antd';
|
|
||||||
import { PlayCircleOutlined, LikeOutlined, EyeOutlined, CommentOutlined, LockOutlined } from '@ant-design/icons';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
|
||||||
import { hexToRgba } from '@/utils/color';
|
|
||||||
|
|
||||||
interface PublicVideoCardProps {
|
|
||||||
video: {
|
|
||||||
id: number;
|
|
||||||
filename: string;
|
|
||||||
category: string | null;
|
|
||||||
durationSeconds: number | null;
|
|
||||||
quality: string | null;
|
|
||||||
orientation: string | null;
|
|
||||||
thumbnailPath: string | null;
|
|
||||||
viewCount: number;
|
|
||||||
upvoteCount: number;
|
|
||||||
commentCount: number;
|
|
||||||
isLocked: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { expandVideo } = useExpandedVideo();
|
|
||||||
|
|
||||||
// Hover video preview state
|
|
||||||
const [hovering, setHovering] = useState(false);
|
|
||||||
const [thumbnailError, setThumbnailError] = useState(false);
|
|
||||||
const hoverTimeout = useRef<number | null>(null);
|
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null) => {
|
|
||||||
if (!seconds) return '\u2014';
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCount = (count: number | undefined | null) => {
|
|
||||||
if (!count && count !== 0) return '0';
|
|
||||||
if (count >= 1000000) {
|
|
||||||
return `${(count / 1000000).toFixed(1)}M`;
|
|
||||||
}
|
|
||||||
if (count >= 1000) {
|
|
||||||
return `${(count / 1000).toFixed(1)}K`;
|
|
||||||
}
|
|
||||||
return count.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract title from filename (remove extension)
|
|
||||||
const title = video.filename.replace(/\.[^/.]+$/, '');
|
|
||||||
|
|
||||||
// Cleanup hover timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (hoverTimeout.current) {
|
|
||||||
clearTimeout(hoverTimeout.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle mouse enter with debounce to prevent connection saturation
|
|
||||||
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
// Apply card hover effects
|
|
||||||
const card = e.currentTarget;
|
|
||||||
card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
|
|
||||||
card.style.transform = 'translateY(-2px)';
|
|
||||||
|
|
||||||
// Debounce: only load video if user hovers for 200ms
|
|
||||||
// Prevents connection saturation when quickly scanning the grid
|
|
||||||
hoverTimeout.current = setTimeout(() => {
|
|
||||||
setHovering(true);
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle mouse leave - cancel pending video load
|
|
||||||
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
// Remove card hover effects
|
|
||||||
const card = e.currentTarget;
|
|
||||||
card.style.boxShadow = 'none';
|
|
||||||
card.style.transform = 'translateY(0)';
|
|
||||||
|
|
||||||
// Cancel pending hover timer
|
|
||||||
if (hoverTimeout.current) {
|
|
||||||
clearTimeout(hoverTimeout.current);
|
|
||||||
hoverTimeout.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Abort any in-flight video load to free the connection slot
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (video) {
|
|
||||||
video.pause();
|
|
||||||
video.removeAttribute('src');
|
|
||||||
video.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
setHovering(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCardClick = () => {
|
|
||||||
if (video.isLocked) {
|
|
||||||
// Show login modal for locked content
|
|
||||||
Modal.confirm({
|
|
||||||
title: 'Login Required',
|
|
||||||
content: 'This video is locked. Please log in to watch.',
|
|
||||||
okText: 'Go to Login',
|
|
||||||
cancelText: 'Cancel',
|
|
||||||
onOk: () => {
|
|
||||||
navigate('/login', { state: { from: `/gallery/watch/${video.id}` } });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
expandVideo(video.id, video);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
hoverable
|
|
||||||
style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
styles={{ body: { padding: 12 } }}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
onClick={handleCardClick}
|
|
||||||
cover={
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
paddingTop: video.orientation === 'V' ? '177.78%' : '56.25%', // 9:16 or 16:9
|
|
||||||
background: '#000',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Thumbnail or Video Preview */}
|
|
||||||
{hovering && !video.isLocked ? (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={`/media/public/${video.id}/stream`}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : video.thumbnailPath && !thumbnailError ? (
|
|
||||||
<img
|
|
||||||
src={`/media/public/${video.id}/thumbnail`}
|
|
||||||
alt={title}
|
|
||||||
onError={() => setThumbnailError(true)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 48,
|
|
||||||
color: '#666',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{video.orientation === 'V' ? '\uD83D\uDCF1' : '\uD83C\uDFAC'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lock overlay */}
|
|
||||||
{video.isLocked && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0, 0, 0, 0.7)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backdropFilter: 'blur(2px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LockOutlined style={{ fontSize: 48, color: token.colorPrimary }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Play button overlay */}
|
|
||||||
{!video.isLocked && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'rgba(0, 0, 0, 0.3)',
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.2s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.opacity = '1';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.opacity = '0';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: hexToRgba(token.colorPrimary, 0.9),
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transition: 'transform 0.2s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlayCircleOutlined style={{ fontSize: 32, color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Duration badge */}
|
|
||||||
{video.durationSeconds && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 8,
|
|
||||||
right: 8,
|
|
||||||
background: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDuration(video.durationSeconds)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quality badge */}
|
|
||||||
{video.quality && (
|
|
||||||
<Tag
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
left: 8,
|
|
||||||
margin: 0,
|
|
||||||
background: token.colorPrimary,
|
|
||||||
border: 'none',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{video.quality}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Category badge */}
|
|
||||||
{video.category && (
|
|
||||||
<Tag
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
margin: 0,
|
|
||||||
background: 'rgba(255, 255, 255, 0.15)',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{video.category}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Card content */}
|
|
||||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
||||||
{/* Title */}
|
|
||||||
<Typography.Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
display: 'block',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
color: token.colorText,
|
|
||||||
}}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
{/* Engagement metrics */}
|
|
||||||
<Space size={16} style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
|
||||||
<Space size={4}>
|
|
||||||
<LikeOutlined />
|
|
||||||
<span>{formatCount(video.upvoteCount)}</span>
|
|
||||||
</Space>
|
|
||||||
<Space size={4}>
|
|
||||||
<EyeOutlined />
|
|
||||||
<span>{formatCount(video.viewCount)}</span>
|
|
||||||
</Space>
|
|
||||||
<Space size={4}>
|
|
||||||
<CommentOutlined />
|
|
||||||
<span>{formatCount(video.commentCount)}</span>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Space, Button, message, theme } from 'antd';
|
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
import { hexToRgba } from '@/utils/color';
|
|
||||||
|
|
||||||
interface ReactionButtonsProps {
|
|
||||||
videoId: number;
|
|
||||||
currentTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard emoji reactions
|
|
||||||
const REACTIONS = [
|
|
||||||
{ emoji: '👍', name: 'like', label: 'Like' },
|
|
||||||
{ emoji: '❤️', name: 'love', label: 'Love' },
|
|
||||||
{ emoji: '😂', name: 'laugh', label: 'Laugh' },
|
|
||||||
{ emoji: '😮', name: 'wow', label: 'Wow' },
|
|
||||||
{ emoji: '😢', name: 'sad', label: 'Sad' },
|
|
||||||
{ emoji: '😡', name: 'angry', label: 'Angry' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface FloatingEmoji {
|
|
||||||
id: number;
|
|
||||||
emoji: string;
|
|
||||||
x: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReactionButtons({ videoId, currentTime }: ReactionButtonsProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
||||||
const [floatingEmojis, setFloatingEmojis] = useState<FloatingEmoji[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const hoverBg = hexToRgba(token.colorPrimary, 0.1);
|
|
||||||
|
|
||||||
const handleReaction = async (reactionType: string, emoji: string) => {
|
|
||||||
// Check if user is logged in
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
message.warning('Please log in to add reactions');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
await mediaPublicApi.post('/reactions', {
|
|
||||||
mediaId: videoId,
|
|
||||||
reactionType,
|
|
||||||
videoTimestamp: Math.floor(currentTime),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add floating emoji animation
|
|
||||||
const newEmoji: FloatingEmoji = {
|
|
||||||
id: Date.now(),
|
|
||||||
emoji,
|
|
||||||
x: Math.random() * 80 + 10, // Random x position (10-90%)
|
|
||||||
};
|
|
||||||
|
|
||||||
setFloatingEmojis((prev) => [...prev, newEmoji]);
|
|
||||||
|
|
||||||
// Remove emoji after animation completes (2 seconds)
|
|
||||||
setTimeout(() => {
|
|
||||||
setFloatingEmojis((prev) => prev.filter((e) => e.id !== newEmoji.id));
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
message.success(`${emoji} reaction added!`);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
message.error('Please log in to add reactions');
|
|
||||||
} else {
|
|
||||||
message.error('Failed to add reaction');
|
|
||||||
}
|
|
||||||
console.error('Failed to add reaction:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
{/* Reaction buttons */}
|
|
||||||
<Space size={12} wrap>
|
|
||||||
{REACTIONS.map((reaction) => (
|
|
||||||
<Button
|
|
||||||
key={reaction.name}
|
|
||||||
type="text"
|
|
||||||
size="large"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => handleReaction(reaction.name, reaction.emoji)}
|
|
||||||
style={{
|
|
||||||
fontSize: 24,
|
|
||||||
padding: '4px 12px',
|
|
||||||
height: 'auto',
|
|
||||||
borderRadius: 8,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1.2)';
|
|
||||||
e.currentTarget.style.background = hoverBg;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
title={reaction.label}
|
|
||||||
>
|
|
||||||
{reaction.emoji}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
{/* Floating emojis */}
|
|
||||||
{floatingEmojis.map((floatingEmoji) => (
|
|
||||||
<div
|
|
||||||
key={floatingEmoji.id}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: `${floatingEmoji.x}%`,
|
|
||||||
bottom: '20%',
|
|
||||||
fontSize: 48,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
animation: 'float-up 2s ease-out forwards',
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{floatingEmoji.emoji}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* CSS Animation */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes float-up {
|
|
||||||
0% {
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-100px) scale(1.2);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(-200px) scale(0.8);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,250 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,244 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,304 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,263 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,335 +0,0 @@
|
|||||||
import { Button, Dropdown, message, Modal } from 'antd';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
EditOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
BarChartOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
SwapOutlined,
|
|
||||||
DownloadOutlined,
|
|
||||||
PictureOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
MoreOutlined,
|
|
||||||
LinkOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
OrderedListOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { Video } from '@/types/media';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
|
|
||||||
interface VideoActionsProps {
|
|
||||||
video: Video;
|
|
||||||
onEdit?: (video: Video) => void;
|
|
||||||
onPreview?: (video: Video) => void;
|
|
||||||
onAnalytics?: (video: Video) => void;
|
|
||||||
onSchedule?: (video: Video) => void;
|
|
||||||
onDelete?: (video: Video) => void;
|
|
||||||
onAddToPlaylist?: (video: Video) => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VideoActions({
|
|
||||||
video,
|
|
||||||
onEdit,
|
|
||||||
onPreview,
|
|
||||||
onAnalytics,
|
|
||||||
onSchedule,
|
|
||||||
onDelete,
|
|
||||||
onAddToPlaylist,
|
|
||||||
onRefresh,
|
|
||||||
}: VideoActionsProps) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
// Don't trigger if user is typing in an input
|
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
|
||||||
case 'e':
|
|
||||||
if (!e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
onEdit?.(video);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'p':
|
|
||||||
if (!e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
onPreview?.(video);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'a':
|
|
||||||
if (!e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
onAnalytics?.(video);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 's':
|
|
||||||
if (!e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSchedule?.(video);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [video, onEdit, onPreview, onAnalytics, onSchedule]);
|
|
||||||
|
|
||||||
const handleDuplicate = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await mediaApi.post(`/videos/${video.id}/duplicate`);
|
|
||||||
message.success('Video duplicated successfully');
|
|
||||||
onRefresh?.();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('Failed to duplicate video');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGeneratePreviewLink = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await mediaApi.get(`/videos/${video.id}/preview-link`);
|
|
||||||
const { previewUrl, expiryHours } = response.data;
|
|
||||||
|
|
||||||
// Copy to clipboard
|
|
||||||
await navigator.clipboard.writeText(previewUrl);
|
|
||||||
|
|
||||||
Modal.success({
|
|
||||||
title: 'Preview Link Generated',
|
|
||||||
content: (
|
|
||||||
<div>
|
|
||||||
<p>Preview link copied to clipboard!</p>
|
|
||||||
<p style={{ fontSize: 12, color: '#666' }}>Expires in {expiryHours} hours</p>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
padding: 8,
|
|
||||||
background: '#f5f5f5',
|
|
||||||
borderRadius: 4,
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{previewUrl}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
message.error('Failed to generate preview link');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetAnalytics = () => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: 'Reset Analytics?',
|
|
||||||
content: 'This will permanently delete all view data, watch time, and analytics for this video. This action cannot be undone.',
|
|
||||||
okText: 'Reset',
|
|
||||||
okType: 'danger',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await mediaApi.post(`/videos/${video.id}/reset-analytics`);
|
|
||||||
message.success('Analytics reset successfully');
|
|
||||||
onRefresh?.();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('Failed to reset analytics');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
// TODO: Implement download functionality
|
|
||||||
message.info('Download functionality coming soon');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerateThumbnail = () => {
|
|
||||||
// TODO: Implement thumbnail generation
|
|
||||||
message.info('Thumbnail generation coming soon');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Overflow menu items
|
|
||||||
const menuItems = [
|
|
||||||
{
|
|
||||||
key: 'add-to-playlist',
|
|
||||||
label: 'Add to Playlist',
|
|
||||||
icon: <OrderedListOutlined />,
|
|
||||||
onClick: () => onAddToPlaylist?.(video),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'duplicate',
|
|
||||||
label: 'Duplicate',
|
|
||||||
icon: <CopyOutlined />,
|
|
||||||
onClick: handleDuplicate,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'preview-link',
|
|
||||||
label: 'Generate Preview Link',
|
|
||||||
icon: <LinkOutlined />,
|
|
||||||
onClick: handleGeneratePreviewLink,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'divider-1',
|
|
||||||
type: 'divider' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'download',
|
|
||||||
label: 'Download',
|
|
||||||
icon: <DownloadOutlined />,
|
|
||||||
onClick: handleDownload,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'thumbnail',
|
|
||||||
label: 'Generate Thumbnail',
|
|
||||||
icon: <PictureOutlined />,
|
|
||||||
onClick: handleGenerateThumbnail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'divider-2',
|
|
||||||
type: 'divider' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reset-analytics',
|
|
||||||
label: 'Reset Analytics',
|
|
||||||
icon: <ReloadOutlined />,
|
|
||||||
onClick: handleResetAnalytics,
|
|
||||||
danger: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'delete',
|
|
||||||
label: 'Delete',
|
|
||||||
icon: <DeleteOutlined />,
|
|
||||||
onClick: () => onDelete?.(video),
|
|
||||||
danger: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
background: 'linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 8,
|
|
||||||
padding: '8px 12px',
|
|
||||||
transition: 'opacity 0.2s ease',
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="Video actions"
|
|
||||||
>
|
|
||||||
{/* Primary actions */}
|
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit?.(video);
|
|
||||||
}}
|
|
||||||
title="Edit (E)"
|
|
||||||
aria-label="Edit video"
|
|
||||||
style={{ color: '#fff' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<PlayCircleOutlined />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onPreview?.(video);
|
|
||||||
}}
|
|
||||||
title="Preview (P)"
|
|
||||||
aria-label="Preview video"
|
|
||||||
style={{ color: '#fff' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<BarChartOutlined />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAnalytics?.(video);
|
|
||||||
}}
|
|
||||||
title="Analytics (A)"
|
|
||||||
aria-label="View analytics"
|
|
||||||
style={{ color: '#fff' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<ClockCircleOutlined />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSchedule?.(video);
|
|
||||||
}}
|
|
||||||
title="Schedule (S)"
|
|
||||||
aria-label="Schedule publishing"
|
|
||||||
style={{ color: '#fff' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side: View count + Overflow menu */}
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
{/* View count badge */}
|
|
||||||
{video.viewCount !== undefined && video.viewCount > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'rgba(0, 0, 0, 0.6)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: 11,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BarChartOutlined style={{ fontSize: 11 }} />
|
|
||||||
{video.viewCount.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Overflow menu */}
|
|
||||||
<Dropdown
|
|
||||||
menu={{ items: menuItems }}
|
|
||||||
trigger={['click']}
|
|
||||||
placement="topRight"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<MoreOutlined />}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
loading={loading}
|
|
||||||
aria-label="More actions menu"
|
|
||||||
style={{ color: '#fff' }}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,302 +0,0 @@
|
|||||||
import { Card, Checkbox, Tag, Spin } from 'antd';
|
|
||||||
import { ClockCircleOutlined, PlayCircleOutlined, CheckCircleOutlined, ThunderboltFilled } from '@ant-design/icons';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { Video } from '@/types/media';
|
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
|
||||||
import VideoActions from './VideoActions';
|
|
||||||
import ScheduleBadge from './ScheduleBadge';
|
|
||||||
|
|
||||||
/** Append JWT access token as query param for <img>/<video> src URLs */
|
|
||||||
function getAuthenticatedUrl(url: string): string {
|
|
||||||
const { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${separator}token=${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoCardProps {
|
|
||||||
video: Video;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
onClick?: (video: Video) => void;
|
|
||||||
onEdit?: (video: Video) => void;
|
|
||||||
onPreview?: (video: Video) => void;
|
|
||||||
onAnalytics?: (video: Video) => void;
|
|
||||||
onSchedule?: (video: Video) => void;
|
|
||||||
onDelete?: (video: Video) => void;
|
|
||||||
onAddToPlaylist?: (video: Video) => void;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
onTogglePublish?: (video: Video) => void;
|
|
||||||
showActions?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VideoCard({
|
|
||||||
video,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
onClick,
|
|
||||||
onEdit,
|
|
||||||
onPreview,
|
|
||||||
onAnalytics,
|
|
||||||
onSchedule,
|
|
||||||
onDelete,
|
|
||||||
onAddToPlaylist,
|
|
||||||
onRefresh,
|
|
||||||
onTogglePublish,
|
|
||||||
showActions = true,
|
|
||||||
}: VideoCardProps) {
|
|
||||||
const [thumbnailLoading, setThumbnailLoading] = useState(true);
|
|
||||||
const [thumbnailError, setThumbnailError] = useState(false);
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
|
||||||
const mb = bytes / (1024 * 1024);
|
|
||||||
if (mb >= 1024) {
|
|
||||||
return `${(mb / 1024).toFixed(1)} GB`;
|
|
||||||
}
|
|
||||||
return `${mb.toFixed(0)} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
hoverable
|
|
||||||
cover={
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
paddingTop: video.orientation === 'V' ? '177.78%' : '56.25%',
|
|
||||||
background: '#1f1f1f',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Thumbnail image or fallback */}
|
|
||||||
{video.thumbnailUrl && !thumbnailError ? (
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src={getAuthenticatedUrl(video.thumbnailUrl)}
|
|
||||||
alt={video.title}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
display: thumbnailLoading ? 'none' : 'block',
|
|
||||||
}}
|
|
||||||
onLoad={() => setThumbnailLoading(false)}
|
|
||||||
onError={() => {
|
|
||||||
setThumbnailError(true);
|
|
||||||
setThumbnailLoading(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{thumbnailLoading && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spin />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: '#666',
|
|
||||||
fontSize: 48,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{video.orientation === 'V' ? '📱' : '🎬'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Schedule badge */}
|
|
||||||
<ScheduleBadge
|
|
||||||
scheduledPublishAt={video.scheduledPublishAt}
|
|
||||||
scheduledUnpublishAt={video.scheduledUnpublishAt}
|
|
||||||
isPublished={video.isPublished}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Publish toggle pill */}
|
|
||||||
{onTogglePublish && (
|
|
||||||
<div
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onTogglePublish(video);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: video.scheduledPublishAt || video.scheduledUnpublishAt ? 120 : 8,
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: video.isPublished
|
|
||||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
|
||||||
: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '6px 14px',
|
|
||||||
borderRadius: 20,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
zIndex: 11,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1.05)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.25)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
|
|
||||||
}}
|
|
||||||
title={video.isPublished ? 'Click to unpublish' : 'Click to publish'}
|
|
||||||
role="button"
|
|
||||||
aria-label={video.isPublished ? 'Unpublish video' : 'Publish video'}
|
|
||||||
>
|
|
||||||
{video.isPublished ? (
|
|
||||||
<>
|
|
||||||
<CheckCircleOutlined style={{ fontSize: 14 }} />
|
|
||||||
<span>Published</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ClockCircleOutlined style={{ fontSize: 14 }} />
|
|
||||||
<span>Draft</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action buttons overlay */}
|
|
||||||
{showActions && (
|
|
||||||
<VideoActions
|
|
||||||
video={video}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onPreview={onPreview}
|
|
||||||
onAnalytics={onAnalytics}
|
|
||||||
onSchedule={onSchedule}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onAddToPlaylist={onAddToPlaylist}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Duration badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 8,
|
|
||||||
right: 8,
|
|
||||||
background: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: 12,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
{formatDuration(video.duration)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Short video badge */}
|
|
||||||
{video.isShort && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 8,
|
|
||||||
left: 8,
|
|
||||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThunderboltFilled style={{ fontSize: 12 }} />
|
|
||||||
Short
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Select checkbox */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
left: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect(video.id);
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
aria-label={`Select ${video.title || `video ${video.id}`}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
onClick={() => onClick?.(video)}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
role="article"
|
|
||||||
aria-label={`Video card: ${video.title || `Video ${video.id}`}`}
|
|
||||||
>
|
|
||||||
<Card.Meta
|
|
||||||
title={
|
|
||||||
<div style={{ fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{video.title}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<div style={{ fontSize: 12 }}>
|
|
||||||
<div>{video.width} × {video.height}</div>
|
|
||||||
<div>{formatFileSize(video.fileSize)}</div>
|
|
||||||
{video.producer && (
|
|
||||||
<Tag style={{ marginTop: 4 }} color="blue">
|
|
||||||
{video.producer}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
{video.creator && (
|
|
||||||
<Tag style={{ marginTop: 4 }} color="green">
|
|
||||||
{video.creator}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,405 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
|
||||||
import { Alert, Spin } from 'antd';
|
|
||||||
import { PlayCircleOutlined } from '@ant-design/icons';
|
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
|
||||||
|
|
||||||
export interface VideoMetadata {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
durationSeconds: number | null;
|
|
||||||
width: number | null;
|
|
||||||
height: number | null;
|
|
||||||
orientation: string | null;
|
|
||||||
hasAudio: boolean | null;
|
|
||||||
quality: string | null;
|
|
||||||
streamUrl: string;
|
|
||||||
thumbnailUrl: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoPlayerProps {
|
|
||||||
videoId: number;
|
|
||||||
width?: string | number;
|
|
||||||
height?: string | number;
|
|
||||||
autoplay?: boolean;
|
|
||||||
controls?: boolean;
|
|
||||||
loop?: boolean;
|
|
||||||
muted?: boolean;
|
|
||||||
poster?: string;
|
|
||||||
className?: string;
|
|
||||||
/** When true, sends auth token for metadata fetch and appends token to stream/thumbnail URLs */
|
|
||||||
isAdmin?: boolean;
|
|
||||||
onLoadedMetadata?: (metadata: VideoMetadata) => void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoPlayerRef {
|
|
||||||
play: () => void;
|
|
||||||
pause: () => void;
|
|
||||||
togglePlay: () => void;
|
|
||||||
seekForward: (seconds: number) => void;
|
|
||||||
seekBackward: (seconds: number) => void;
|
|
||||||
toggleMute: () => void;
|
|
||||||
toggleFullscreen: () => void;
|
|
||||||
getVideoElement: () => HTMLVideoElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard HTML5 video player component
|
|
||||||
* Fetches metadata from Media API and renders video with streaming support
|
|
||||||
*/
|
|
||||||
export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|
||||||
videoId,
|
|
||||||
width = '100%',
|
|
||||||
height = 'auto',
|
|
||||||
autoplay = false,
|
|
||||||
controls = true,
|
|
||||||
loop = false,
|
|
||||||
muted = false,
|
|
||||||
poster,
|
|
||||||
className = '',
|
|
||||||
isAdmin = false,
|
|
||||||
onLoadedMetadata,
|
|
||||||
onError,
|
|
||||||
}, ref) => {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Expose control methods via ref
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
play: () => {
|
|
||||||
videoRef.current?.play();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
},
|
|
||||||
togglePlay: () => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
if (videoRef.current.paused) {
|
|
||||||
videoRef.current.play();
|
|
||||||
} else {
|
|
||||||
videoRef.current.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
seekForward: (seconds: number) => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.currentTime = Math.min(
|
|
||||||
videoRef.current.currentTime + seconds,
|
|
||||||
videoRef.current.duration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
seekBackward: (seconds: number) => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.currentTime = Math.max(
|
|
||||||
videoRef.current.currentTime - seconds,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleMute: () => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.muted = !videoRef.current.muted;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleFullscreen: () => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
if (document.fullscreenElement) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
videoRef.current.requestFullscreen?.() ||
|
|
||||||
(videoRef.current as any).webkitRequestFullscreen?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getVideoElement: () => videoRef.current,
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMetadata();
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
const appendToken = (url: string): string => {
|
|
||||||
if (!isAdmin) return url;
|
|
||||||
const { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${sep}token=${accessToken}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMetadata = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use relative URL to go through nginx proxy
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (isAdmin) {
|
|
||||||
const { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
if (accessToken) {
|
|
||||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/media/videos/${videoId}/metadata`, { headers });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 404) {
|
|
||||||
throw new Error('Video not found');
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to load video: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// For admin, append token to stream/thumbnail URLs so <video>/<img> can access them
|
|
||||||
if (isAdmin) {
|
|
||||||
if (data.streamUrl) data.streamUrl = appendToken(data.streamUrl);
|
|
||||||
if (data.thumbnailUrl) data.thumbnailUrl = appendToken(data.thumbnailUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMetadata(data);
|
|
||||||
|
|
||||||
if (onLoadedMetadata) {
|
|
||||||
onLoadedMetadata(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load video';
|
|
||||||
setError(errorMessage);
|
|
||||||
|
|
||||||
if (onError) {
|
|
||||||
onError(err instanceof Error ? err : new Error(errorMessage));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height: height === 'auto' ? 200 : height,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: '#f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<Spin size="large" tip="Loading video..." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !metadata) {
|
|
||||||
return (
|
|
||||||
<div style={{ width }} className={className}>
|
|
||||||
<Alert
|
|
||||||
message="Video Error"
|
|
||||||
description={error || 'Failed to load video metadata'}
|
|
||||||
type="error"
|
|
||||||
icon={<PlayCircleOutlined />}
|
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use aspect ratio padding trick for responsive sizing
|
|
||||||
const aspectRatio =
|
|
||||||
metadata.width && metadata.height
|
|
||||||
? (metadata.height / metadata.width) * 100
|
|
||||||
: 56.25; // Default to 16:9
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
position: 'relative',
|
|
||||||
paddingBottom: height === 'auto' ? `${aspectRatio}%` : undefined,
|
|
||||||
height: height !== 'auto' ? height : undefined,
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={metadata.streamUrl}
|
|
||||||
poster={poster || metadata.thumbnailUrl || undefined}
|
|
||||||
autoPlay={autoplay}
|
|
||||||
controls={controls}
|
|
||||||
loop={loop}
|
|
||||||
muted={muted}
|
|
||||||
playsInline
|
|
||||||
style={{
|
|
||||||
position: height === 'auto' ? 'absolute' : 'relative',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'contain',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: '#000',
|
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
const videoError = new Error('Video playback failed');
|
|
||||||
setError('Video playback failed. The file may be corrupted or in an unsupported format.');
|
|
||||||
if (onError) {
|
|
||||||
onError(videoError);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Your browser does not support HTML5 video playback.
|
|
||||||
<br />
|
|
||||||
<a href={metadata.streamUrl} download>
|
|
||||||
Download video
|
|
||||||
</a>
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
VideoPlayer.displayName = 'VideoPlayer';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format duration from seconds to HH:MM:SS or MM:SS
|
|
||||||
*/
|
|
||||||
export function formatDuration(seconds: number | null): string {
|
|
||||||
if (seconds === null || seconds === undefined) {
|
|
||||||
return '--:--';
|
|
||||||
}
|
|
||||||
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VideoPlayer;
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
import { Modal } from 'antd';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import type { Video } from '@/types/media';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
import { getAuthCallbacks } from '@/lib/api';
|
|
||||||
|
|
||||||
/** Append JWT access token as query param for <video> src URLs */
|
|
||||||
function getAuthenticatedUrl(url: string): string {
|
|
||||||
const { getTokens } = getAuthCallbacks();
|
|
||||||
const { accessToken } = getTokens();
|
|
||||||
if (!accessToken) return url;
|
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${separator}token=${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoViewerModalProps {
|
|
||||||
video: Video | null;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VideoViewerModal({ video, open, onClose }: VideoViewerModalProps) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const [viewId, setViewId] = useState<number | null>(null);
|
|
||||||
const heartbeatInterval = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const lastWatchTime = useRef<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && video) {
|
|
||||||
// Record view when video opens
|
|
||||||
recordView();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleanup on unmount
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
}, [open, video]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const videoElement = videoRef.current;
|
|
||||||
if (!videoElement || !video) return;
|
|
||||||
|
|
||||||
const handlePlay = () => {
|
|
||||||
recordEvent('play', videoElement.currentTime);
|
|
||||||
startHeartbeat();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePause = () => {
|
|
||||||
recordEvent('pause', videoElement.currentTime);
|
|
||||||
stopHeartbeat();
|
|
||||||
updateWatchTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeeked = () => {
|
|
||||||
recordEvent('seek', videoElement.currentTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnded = () => {
|
|
||||||
recordEvent('complete', videoElement.currentTime);
|
|
||||||
stopHeartbeat();
|
|
||||||
updateWatchTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
videoElement.addEventListener('play', handlePlay);
|
|
||||||
videoElement.addEventListener('pause', handlePause);
|
|
||||||
videoElement.addEventListener('seeked', handleSeeked);
|
|
||||||
videoElement.addEventListener('ended', handleEnded);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleanup event listeners
|
|
||||||
videoElement.removeEventListener('play', handlePlay);
|
|
||||||
videoElement.removeEventListener('pause', handlePause);
|
|
||||||
videoElement.removeEventListener('seeked', handleSeeked);
|
|
||||||
videoElement.removeEventListener('ended', handleEnded);
|
|
||||||
};
|
|
||||||
}, [video, viewId]);
|
|
||||||
|
|
||||||
const recordView = async () => {
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await mediaApi.post('/track/view', {
|
|
||||||
videoId: video.id,
|
|
||||||
referer: document.referrer || undefined,
|
|
||||||
});
|
|
||||||
setViewId(response.data.viewId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to record view:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const recordEvent = async (eventType: 'play' | 'pause' | 'seek' | 'complete', timestamp: number) => {
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mediaApi.post('/track/event', {
|
|
||||||
videoId: video.id,
|
|
||||||
viewId: viewId || undefined,
|
|
||||||
eventType,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to record event:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateWatchTime = async () => {
|
|
||||||
if (!viewId || !videoRef.current) return;
|
|
||||||
|
|
||||||
const currentTime = Math.floor(videoRef.current.currentTime);
|
|
||||||
if (currentTime === lastWatchTime.current) return;
|
|
||||||
|
|
||||||
lastWatchTime.current = currentTime;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use sendBeacon for reliable tracking even on page close
|
|
||||||
const data = JSON.stringify({
|
|
||||||
viewId,
|
|
||||||
watchTimeSeconds: currentTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use relative URL via window.location.origin to go through nginx proxy
|
|
||||||
const sent = navigator.sendBeacon(
|
|
||||||
`${window.location.origin}/media/track/heartbeat`,
|
|
||||||
new Blob([data], { type: 'application/json' })
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!sent) {
|
|
||||||
// Fallback to regular API call
|
|
||||||
await mediaApi.post('/track/heartbeat', {
|
|
||||||
viewId,
|
|
||||||
watchTimeSeconds: currentTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update watch time:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startHeartbeat = () => {
|
|
||||||
stopHeartbeat(); // Clear any existing interval
|
|
||||||
heartbeatInterval.current = setInterval(() => {
|
|
||||||
updateWatchTime();
|
|
||||||
}, 10000); // Every 10 seconds
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopHeartbeat = () => {
|
|
||||||
if (heartbeatInterval.current) {
|
|
||||||
clearInterval(heartbeatInterval.current);
|
|
||||||
heartbeatInterval.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
stopHeartbeat();
|
|
||||||
updateWatchTime(); // Final update before closing
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!video) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={video.title}
|
|
||||||
open={open}
|
|
||||||
onCancel={() => {
|
|
||||||
cleanup();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
footer={null}
|
|
||||||
width={video.orientation === 'V' ? 600 : 1200}
|
|
||||||
centered
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
maxHeight: '70vh',
|
|
||||||
background: '#000',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ marginTop: 16, fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>
|
|
||||||
<div><strong>Duration:</strong> {formatDuration(video.duration)}</div>
|
|
||||||
<div><strong>Resolution:</strong> {video.width} × {video.height}</div>
|
|
||||||
<div><strong>Size:</strong> {formatFileSize(video.fileSize)}</div>
|
|
||||||
{video.producer && <div><strong>Producer:</strong> {video.producer}</div>}
|
|
||||||
{video.creator && <div><strong>Creator:</strong> {video.creator}</div>}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds: number) {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes: number) {
|
|
||||||
const mb = bytes / (1024 * 1024);
|
|
||||||
if (mb >= 1024) {
|
|
||||||
return `${(mb / 1024).toFixed(1)} GB`;
|
|
||||||
}
|
|
||||||
return `${mb.toFixed(0)} MB`;
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { Grid } from 'antd';
|
|
||||||
import { useChatBar } from './ChatBarContext';
|
|
||||||
import MiniChatWindow from './MiniChatWindow';
|
|
||||||
import MinimizedChat from './MinimizedChat';
|
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
|
|
||||||
export default function ChatBar() {
|
|
||||||
const screens = useBreakpoint();
|
|
||||||
const isMobile = !screens.md;
|
|
||||||
const { windows, closeChat, toggleExpanded } = useChatBar();
|
|
||||||
|
|
||||||
if (windows.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: isMobile ? 56 : 0, // Above mobile bottom nav
|
|
||||||
right: 16,
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
zIndex: 1000,
|
|
||||||
pointerEvents: 'none', // Allow clicks to pass through gaps
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{windows.map((w) =>
|
|
||||||
w.isExpanded ? (
|
|
||||||
<div key={w.videoId} style={{ pointerEvents: 'auto' }}>
|
|
||||||
<MiniChatWindow
|
|
||||||
window={w}
|
|
||||||
onToggle={() => toggleExpanded(w.videoId)}
|
|
||||||
onClose={() => closeChat(w.videoId)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={w.videoId} style={{ pointerEvents: 'auto' }}>
|
|
||||||
<MinimizedChat
|
|
||||||
window={w}
|
|
||||||
onExpand={() => toggleExpanded(w.videoId)}
|
|
||||||
onClose={() => closeChat(w.videoId)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
|
||||||
import { Grid } from 'antd';
|
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
|
|
||||||
export interface ChatWindow {
|
|
||||||
videoId: number;
|
|
||||||
videoTitle: string;
|
|
||||||
thumbnailPath?: string | null;
|
|
||||||
isExpanded: boolean;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatBarContextValue {
|
|
||||||
windows: ChatWindow[];
|
|
||||||
openChat: (videoId: number, videoTitle: string, thumbnailPath?: string | null) => void;
|
|
||||||
closeChat: (videoId: number) => void;
|
|
||||||
toggleExpanded: (videoId: number) => void;
|
|
||||||
minimizeAll: () => void;
|
|
||||||
clearUnread: (videoId: number) => void;
|
|
||||||
incrementUnread: (videoId: number) => void;
|
|
||||||
maxWindows: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatBarContext = createContext<ChatBarContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
export function useChatBar() {
|
|
||||||
const context = useContext(ChatBarContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useChatBar must be used within ChatBarProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatBarProvider({ children }: { children: ReactNode }) {
|
|
||||||
const screens = useBreakpoint();
|
|
||||||
const isMobile = !screens.md;
|
|
||||||
const maxWindows = isMobile ? 1 : 3;
|
|
||||||
|
|
||||||
const [windows, setWindows] = useState<ChatWindow[]>([]);
|
|
||||||
|
|
||||||
const openChat = useCallback(
|
|
||||||
(videoId: number, videoTitle: string, thumbnailPath?: string | null) => {
|
|
||||||
setWindows((prev) => {
|
|
||||||
// Already open? Just expand it
|
|
||||||
const existing = prev.find((w) => w.videoId === videoId);
|
|
||||||
if (existing) {
|
|
||||||
return prev.map((w) =>
|
|
||||||
w.videoId === videoId ? { ...w, isExpanded: true, unreadCount: 0 } : w
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWindow: ChatWindow = {
|
|
||||||
videoId,
|
|
||||||
videoTitle,
|
|
||||||
thumbnailPath,
|
|
||||||
isExpanded: true,
|
|
||||||
unreadCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// If at max, close the oldest minimized window (or oldest)
|
|
||||||
if (prev.length >= maxWindows) {
|
|
||||||
const minimized = prev.filter((w) => !w.isExpanded);
|
|
||||||
if (minimized.length > 0) {
|
|
||||||
// Remove oldest minimized
|
|
||||||
const toRemove = minimized[0]!;
|
|
||||||
return [...prev.filter((w) => w.videoId !== toRemove.videoId), newWindow];
|
|
||||||
}
|
|
||||||
// Remove first window
|
|
||||||
return [...prev.slice(1), newWindow];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...prev, newWindow];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[maxWindows]
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeChat = useCallback((videoId: number) => {
|
|
||||||
setWindows((prev) => prev.filter((w) => w.videoId !== videoId));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleExpanded = useCallback((videoId: number) => {
|
|
||||||
setWindows((prev) =>
|
|
||||||
prev.map((w) =>
|
|
||||||
w.videoId === videoId ? { ...w, isExpanded: !w.isExpanded, unreadCount: 0 } : w
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const minimizeAll = useCallback(() => {
|
|
||||||
setWindows((prev) => prev.map((w) => ({ ...w, isExpanded: false })));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearUnread = useCallback((videoId: number) => {
|
|
||||||
setWindows((prev) =>
|
|
||||||
prev.map((w) => (w.videoId === videoId ? { ...w, unreadCount: 0 } : w))
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const incrementUnread = useCallback((videoId: number) => {
|
|
||||||
setWindows((prev) =>
|
|
||||||
prev.map((w) =>
|
|
||||||
w.videoId === videoId && !w.isExpanded
|
|
||||||
? { ...w, unreadCount: w.unreadCount + 1 }
|
|
||||||
: w
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatBarContext.Provider
|
|
||||||
value={{
|
|
||||||
windows,
|
|
||||||
openChat,
|
|
||||||
closeChat,
|
|
||||||
toggleExpanded,
|
|
||||||
minimizeAll,
|
|
||||||
clearUnread,
|
|
||||||
incrementUnread,
|
|
||||||
maxWindows,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ChatBarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import { Typography, theme } from 'antd';
|
|
||||||
import {
|
|
||||||
CloseOutlined,
|
|
||||||
MinusOutlined,
|
|
||||||
ExpandOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { ChatWindow } from './ChatBarContext';
|
|
||||||
import MiniLiveChat from './MiniLiveChat';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface MiniChatWindowProps {
|
|
||||||
window: ChatWindow;
|
|
||||||
onToggle: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MiniChatWindow({
|
|
||||||
window: chatWindow,
|
|
||||||
onToggle,
|
|
||||||
onClose,
|
|
||||||
}: MiniChatWindowProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 320,
|
|
||||||
height: chatWindow.isExpanded ? 400 : 40,
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: '12px 12px 0 0',
|
|
||||||
border: `1px solid ${token.colorBorder}`,
|
|
||||||
borderBottom: 'none',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'height 0.2s ease',
|
|
||||||
boxShadow: '0 -4px 20px rgba(0,0,0,0.3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Title bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: token.colorBgElevated,
|
|
||||||
borderBottom: chatWindow.isExpanded ? `1px solid ${token.colorBorder}` : 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
minHeight: 40,
|
|
||||||
}}
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
ellipsis
|
|
||||||
strong
|
|
||||||
style={{ flex: 1, fontSize: 13 }}
|
|
||||||
>
|
|
||||||
{chatWindow.videoTitle}
|
|
||||||
</Text>
|
|
||||||
<div
|
|
||||||
style={{ display: 'flex', gap: 8 }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{chatWindow.isExpanded ? (
|
|
||||||
<MinusOutlined
|
|
||||||
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
|
|
||||||
onClick={onToggle}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ExpandOutlined
|
|
||||||
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
|
|
||||||
onClick={onToggle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CloseOutlined
|
|
||||||
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat content */}
|
|
||||||
{chatWindow.isExpanded && (
|
|
||||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
|
||||||
<MiniLiveChat videoId={chatWindow.videoId} isExpanded={chatWindow.isExpanded} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { Input, Button, Typography, Tag, Spin, theme } from 'antd';
|
|
||||||
import { SendOutlined, UserOutlined } from '@ant-design/icons';
|
|
||||||
import { useMediaAuth } from '@/contexts/MediaAuthContext';
|
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
|
||||||
import { mediaApi } from '@/lib/media-api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
const { TextArea } = Input;
|
|
||||||
|
|
||||||
interface Comment {
|
|
||||||
id: number;
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
user: { id: string; name: string } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MiniLiveChatProps {
|
|
||||||
videoId: number;
|
|
||||||
isExpanded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MiniLiveChat({ videoId, isExpanded }: MiniLiveChatProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const { isAuthenticated, isApproved } = useMediaAuth();
|
|
||||||
|
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [sseConnected, setSSEConnected] = useState(false);
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch initial comments
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isExpanded) return;
|
|
||||||
|
|
||||||
const fetchComments = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/media/public/${videoId}/comments?limit=50`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setComments(data.comments || []);
|
|
||||||
setTimeout(scrollToBottom, 100);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silent
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchComments();
|
|
||||||
|
|
||||||
// Mark as read
|
|
||||||
if (isAuthenticated) {
|
|
||||||
mediaApi.post(`/media/chat/threads/${videoId}/read`).catch(() => {});
|
|
||||||
}
|
|
||||||
}, [videoId, isExpanded, scrollToBottom, isAuthenticated]);
|
|
||||||
|
|
||||||
// SSE connection — only when expanded
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isExpanded) {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
setSSEConnected(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const es = new EventSource(`/media/public/${videoId}/chat-stream`);
|
|
||||||
|
|
||||||
es.onopen = () => setSSEConnected(true);
|
|
||||||
|
|
||||||
es.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.type === 'connected') return;
|
|
||||||
|
|
||||||
if (data.type === 'new_comment') {
|
|
||||||
setComments((prev) => {
|
|
||||||
if (prev.some((c) => c.id === data.comment.id)) return prev;
|
|
||||||
const updated = [...prev, data.comment];
|
|
||||||
return updated.slice(-50);
|
|
||||||
});
|
|
||||||
setTimeout(scrollToBottom, 100);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
es.onerror = () => setSSEConnected(false);
|
|
||||||
|
|
||||||
eventSourceRef.current = es;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
es.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
};
|
|
||||||
}, [videoId, isExpanded, scrollToBottom]);
|
|
||||||
|
|
||||||
// Submit comment
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!input.trim() || submitting || !isAuthenticated) return;
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await mediaPublicApi.post(`/public/${videoId}/comments`, {
|
|
||||||
content: input.trim(),
|
|
||||||
});
|
|
||||||
setInput('');
|
|
||||||
} catch {
|
|
||||||
// Silent
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format time
|
|
||||||
const formatTime = (iso: string) => {
|
|
||||||
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
||||||
if (diff < 60) return 'now';
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
|
||||||
return `${Math.floor(diff / 86400)}d`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderBottom: `1px solid ${token.colorBorder}`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text strong style={{ fontSize: 12 }}>Chat</Text>
|
|
||||||
{sseConnected && <Tag color="success" style={{ fontSize: 10, lineHeight: '14px', padding: '0 4px' }}>Live</Tag>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
padding: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading && (
|
|
||||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
|
||||||
<Spin size="small" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && comments.length === 0 && (
|
|
||||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>No messages yet</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{comments.map((c) => (
|
|
||||||
<div key={c.id} style={{ padding: '4px 6px', fontSize: 12 }}>
|
|
||||||
<span style={{ display: 'flex', gap: 4, alignItems: 'baseline' }}>
|
|
||||||
<UserOutlined style={{ fontSize: 10, color: token.colorPrimary }} />
|
|
||||||
<Text strong style={{ fontSize: 11 }}>{c.user?.name || 'Anon'}</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 10 }}>{formatTime(c.createdAt)}</Text>
|
|
||||||
</span>
|
|
||||||
<div style={{ paddingLeft: 14, fontSize: 12, wordBreak: 'break-word' }}>
|
|
||||||
{c.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
{isAuthenticated && isApproved && (
|
|
||||||
<div style={{ padding: 6, borderTop: `1px solid ${token.colorBorder}` }}>
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<TextArea
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onPressEnter={(e) => {
|
|
||||||
if (!e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Message..."
|
|
||||||
maxLength={1000}
|
|
||||||
autoSize={{ minRows: 1, maxRows: 2 }}
|
|
||||||
disabled={submitting}
|
|
||||||
style={{ fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
icon={<SendOutlined />}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={submitting}
|
|
||||||
disabled={!input.trim()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<div style={{ padding: '8px', textAlign: 'center', borderTop: `1px solid ${token.colorBorder}` }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>Sign in to chat</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import { Badge, Typography, theme } from 'antd';
|
|
||||||
import { CloseOutlined, MessageOutlined } from '@ant-design/icons';
|
|
||||||
import type { ChatWindow } from './ChatBarContext';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface MinimizedChatProps {
|
|
||||||
window: ChatWindow;
|
|
||||||
onExpand: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MinimizedChat({ window: chatWindow, onExpand, onClose }: MinimizedChatProps) {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: token.colorBgElevated,
|
|
||||||
borderRadius: '12px 12px 0 0',
|
|
||||||
border: `1px solid ${token.colorBorder}`,
|
|
||||||
borderBottom: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
minWidth: 180,
|
|
||||||
maxWidth: 240,
|
|
||||||
}}
|
|
||||||
onClick={onExpand}
|
|
||||||
>
|
|
||||||
<Badge count={chatWindow.unreadCount} size="small" offset={[-2, 2]}>
|
|
||||||
<MessageOutlined style={{ fontSize: 16, color: token.colorPrimary }} />
|
|
||||||
</Badge>
|
|
||||||
<Text
|
|
||||||
ellipsis
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: chatWindow.unreadCount > 0 ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{chatWindow.videoTitle}
|
|
||||||
</Text>
|
|
||||||
<CloseOutlined
|
|
||||||
style={{ fontSize: 12, color: token.colorTextSecondary }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
export interface VideoData {
|
|
||||||
id: number;
|
|
||||||
filename: string;
|
|
||||||
category: string | null;
|
|
||||||
durationSeconds: number | null;
|
|
||||||
quality: string | null;
|
|
||||||
orientation: string | null;
|
|
||||||
thumbnailPath: string | null;
|
|
||||||
viewCount: number;
|
|
||||||
upvoteCount: number;
|
|
||||||
commentCount: number;
|
|
||||||
isLocked: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExpandedVideoState {
|
|
||||||
videoId: number | null;
|
|
||||||
video: VideoData | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExpandedVideoContextValue {
|
|
||||||
state: ExpandedVideoState;
|
|
||||||
expandVideo: (id: number, video: VideoData) => void;
|
|
||||||
collapseVideo: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExpandedVideoContext = createContext<ExpandedVideoContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
export function useExpandedVideo() {
|
|
||||||
const context = useContext(ExpandedVideoContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useExpandedVideo must be used within ExpandedVideoProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExpandedVideoProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [state, setState] = useState<ExpandedVideoState>({
|
|
||||||
videoId: null,
|
|
||||||
video: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const expandVideo = useCallback((id: number, video: VideoData) => {
|
|
||||||
setState({ videoId: id, video });
|
|
||||||
|
|
||||||
// Update URL with ?expanded=id (read current params at call time)
|
|
||||||
const newParams = new URLSearchParams(window.location.search);
|
|
||||||
newParams.set('expanded', id.toString());
|
|
||||||
navigate({ search: newParams.toString() }, { replace: true });
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const collapseVideo = useCallback(() => {
|
|
||||||
setState({ videoId: null, video: null });
|
|
||||||
|
|
||||||
// Remove URL param (read current params at call time)
|
|
||||||
const newParams = new URLSearchParams(window.location.search);
|
|
||||||
newParams.delete('expanded');
|
|
||||||
navigate({ search: newParams.toString() }, { replace: true });
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const value: ExpandedVideoContextValue = {
|
|
||||||
state,
|
|
||||||
expandVideo,
|
|
||||||
collapseVideo,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ExpandedVideoContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</ExpandedVideoContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { createContext, useContext, ReactNode } from 'react';
|
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
import { isAdmin } from '@/utils/roles';
|
|
||||||
|
|
||||||
interface MediaAuthState {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isApproved: boolean; // True if NOT a USER or TEMP role
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
} | null;
|
|
||||||
token: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaAuthContextValue extends MediaAuthState {
|
|
||||||
checkAuth: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MediaAuthContext = createContext<MediaAuthContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
export function useMediaAuth() {
|
|
||||||
const context = useContext(MediaAuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useMediaAuth must be used within MediaAuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaAuthProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaAuthProvider({ children }: MediaAuthProviderProps) {
|
|
||||||
// Read auth state directly from the Zustand auth store (single source of truth)
|
|
||||||
const authUser = useAuthStore((s) => s.user);
|
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
||||||
const hydrate = useAuthStore((s) => s.hydrate);
|
|
||||||
|
|
||||||
// Approved means user has an admin role (admins can chat)
|
|
||||||
const isApproved = isAuthenticated && !!authUser && isAdmin(authUser);
|
|
||||||
|
|
||||||
const user = authUser
|
|
||||||
? { id: authUser.id, email: authUser.email, role: authUser.role }
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const checkAuth = () => {
|
|
||||||
// Re-hydrate from persisted storage
|
|
||||||
hydrate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: MediaAuthContextValue = {
|
|
||||||
isAuthenticated,
|
|
||||||
isApproved,
|
|
||||||
user,
|
|
||||||
token: accessToken,
|
|
||||||
checkAuth,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MediaAuthContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</MediaAuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
|
|
||||||
export interface ChatNotification {
|
|
||||||
id: string; // generated client-side
|
|
||||||
type: 'chat_reply';
|
|
||||||
videoId: number;
|
|
||||||
videoTitle: string;
|
|
||||||
commentId: number;
|
|
||||||
commenterName: string;
|
|
||||||
contentPreview: string;
|
|
||||||
receivedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatNotifications() {
|
|
||||||
const { accessToken, isAuthenticated } = useAuthStore();
|
|
||||||
const [notifications, setNotifications] = useState<ChatNotification[]>([]);
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
|
||||||
const notifCounterRef = useRef(0);
|
|
||||||
|
|
||||||
const clearNotification = useCallback((id: string) => {
|
|
||||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearAll = useCallback(() => {
|
|
||||||
setNotifications([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated || !accessToken) {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use relative URL through nginx proxy
|
|
||||||
const url = `/media/media/notifications/stream?token=${encodeURIComponent(accessToken)}`;
|
|
||||||
const es = new EventSource(url);
|
|
||||||
|
|
||||||
es.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.type === 'connected') return;
|
|
||||||
|
|
||||||
if (data.type === 'chat_reply') {
|
|
||||||
const notif: ChatNotification = {
|
|
||||||
...data,
|
|
||||||
id: `notif-${++notifCounterRef.current}-${Date.now()}`,
|
|
||||||
receivedAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setNotifications((prev) => {
|
|
||||||
// Keep max 10 notifications
|
|
||||||
const updated = [...prev, notif];
|
|
||||||
return updated.slice(-10);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
es.onerror = () => {
|
|
||||||
// Auto-reconnect is handled by EventSource
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSourceRef.current = es;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
es.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
};
|
|
||||||
}, [isAuthenticated, accessToken]);
|
|
||||||
|
|
||||||
return { notifications, clearNotification, clearAll };
|
|
||||||
}
|
|
||||||
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