Okay Wish I could say I know exactly. Will do better next time promise lol

This commit is contained in:
bunker-admin 2026-02-26 17:47:04 -07:00
parent 2fa50b001c
commit 9e51aac570
727 changed files with 217309 additions and 5801 deletions

View File

@ -159,6 +159,20 @@ GITEA_DB_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
GITEA_ROOT_URL=https://git.cmlite.org
GITEA_DOMAIN=git.cmlite.org
# --- Gitea Docs Comments ---
# Enable comments on MkDocs pages (backed by Gitea Issues)
GITEA_COMMENTS_ENABLED=false
# Personal access token with repo write scope (create in Gitea → Settings → Applications)
GITEA_API_TOKEN=
# Repository owner (Gitea username that will own the docs-comments repo)
GITEA_COMMENTS_REPO_OWNER=
# Repository name (auto-created via admin setup button)
GITEA_COMMENTS_REPO_NAME=docs-comments
# OAuth2 Application credentials (create in Gitea → Settings → Applications → OAuth2)
# Redirect URIs: https://{DOMAIN}/comments/callback/ and http://localhost:4003/comments/callback/
GITEA_OAUTH_CLIENT_ID=
GITEA_OAUTH_CLIENT_SECRET=
# --- n8n ---
N8N_URL=http://n8n-changemaker:5678
N8N_PORT=5678
@ -289,6 +303,40 @@ GANCIO_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# Enable automatic shift → Gancio event sync
GANCIO_SYNC_ENABLED=false
# --- Jitsi Meet (Video Conferencing) ---
# Self-hosted Jitsi with JWT auth — integrates with Rocket.Chat for channel video calls
# ENABLE_MEET is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_MEET=false
# JWT authentication (shared between Jitsi Prosody, Rocket.Chat, and the API)
# Generate with: openssl rand -hex 32
JITSI_APP_ID=changemaker
JITSI_APP_SECRET=GENERATE_WITH_openssl_rand_hex_32
# Internal XMPP passwords (used between Jitsi containers, not exposed externally)
# Generate each with: openssl rand -hex 16
JITSI_JICOFO_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
JITSI_JVB_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
# Embed port for admin iframe
JITSI_EMBED_PORT=8893
JITSI_URL=http://jitsi-web-changemaker:80
# JVB public IP (required for NAT traversal — set to server's public IP in production)
JVB_ADVERTISE_IP=
# JVB UDP port for media traffic (must be open in firewall)
JVB_PORT=10000
# --- Monitoring Embed Ports (iframe embedding) ---
GRAFANA_EMBED_PORT=8894
ALERTMANAGER_EMBED_PORT=8895
# --- SMS Campaigns (Termux Android Bridge) ---
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_SMS=false
TERMUX_API_URL=http://10.0.0.193:5001
TERMUX_API_KEY=
SMS_DELAY_BETWEEN_MS=3000
SMS_MAX_RETRIES=3
SMS_RESPONSE_SYNC_INTERVAL_MS=30000
SMS_DEVICE_MONITOR_INTERVAL_MS=30000
# --- Monitoring (only used with --profile monitoring) ---
PROMETHEUS_PORT=9090
GRAFANA_PORT=3005

14
.mcp.json Normal file
View File

@ -0,0 +1,14 @@
{
"mcpServers": {
"changemaker": {
"command": "npx",
"args": ["tsx", "mcp-server/src/index.ts"],
"cwd": "/home/bunker-admin/changemaker.lite",
"env": {
"CML_API_URL": "http://localhost:4002",
"CML_SERVICE_EMAIL": "admin@bnkops.ca",
"CML_SERVICE_PASSWORD": "ChangeMe2025!"
}
}
}
}

View File

@ -18,6 +18,7 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
- ✅ Observability Dashboard
- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026)
- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026)
- ✅ **Migration Drift Fixed** (Baseline catch-up migration, 14 migrations cover full schema, Feb 2026)
- 🚧 Phase 15 (Testing + Polish) - Next
---
@ -598,7 +599,22 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- **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
### Prisma Migration Workflow
- **Always use `prisma migrate dev`** for schema changes (not `prisma db push`) — `db push` applies changes directly but doesn't create migration files, causing drift
- **Migration history:** 14 migrations in `api/prisma/migrations/` fully cover the schema (baseline catch-up applied Feb 2026)
- **Fixing drift:** If `db push` was used and migrations are out of sync:
1. Drop any stray indexes/objects in DB not in schema: `DROP INDEX IF EXISTS <name>;`
2. Create a temp shadow DB: `docker compose exec -T v2-postgres createdb -U changemaker prisma_shadow_diff`
3. Generate catch-up SQL: `docker compose exec -T api npx prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --shadow-database-url "postgresql://..." --script`
4. Save to `api/prisma/migrations/<timestamp>_<name>/migration.sql`
5. Mark as applied: `docker compose exec -T api npx prisma migrate resolve --applied <migration_name>`
6. Verify: `docker compose exec -T api npx prisma migrate status` → "Database schema is up to date!"
7. Clean up: `docker compose exec -T v2-postgres dropdb -U changemaker prisma_shadow_diff`
- **Gotcha:** `--from-migrations` replays all migration files on a shadow DB. If a migration references tables created by `db push` (no migration file), it will fail. Fix: temporarily move the dependent migration aside, generate the catch-up (which includes the missing tables), then remove the old migration
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`) — it applies pending migrations without creating a shadow DB
### V2-Specific Gotchas
- **Prisma migrations:** Never use `db push` on the v2 branch — always use `migrate dev` to keep migration history in sync. The baseline catch-up migration (`20260224100000_baseline_catchup`) covers all schema changes from Feb 1824 that were previously applied via `db push`
- 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)
@ -744,6 +760,7 @@ V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two indep
### Database
- `api/prisma/schema.prisma` — Main schema (30+ Prisma models)
- `api/prisma/migrations/` — 14 migration files (fully cover schema as of Feb 2026)
- `api/drizzle.config.ts` — Drizzle config for media tables
- `api/prisma/seed.ts` — Database seeding

156
SOCIAL_SYSTEM_PLAN.md Normal file
View File

@ -0,0 +1,156 @@
# Social Connections System — Implementation Plan
See the full plan in the conversation transcript. This file tracks implementation progress.
## Phase Status
| Phase | Description | Status |
|-------|-------------|--------|
| 1 | Feature Flag + Social Module Skeleton | COMPLETE |
| 2 | Friendship API (Send, Accept, Decline, Cancel, Unfriend) | COMPLETE |
| 3 | Block/Unblock API + Privacy Settings API | COMPLETE |
| 4 | User Social Profile + Volunteer Portal UI Foundation | COMPLETE |
| 5 | In-App Notification System (Bell Icon + Dropdown) | COMPLETE |
| 6 | Social Activity Feed (Friends' Activities) | COMPLETE |
| 7 | CRM Bridge (Auto-Connect + Friend Suggestions) | COMPLETE |
| 8 | Poke System + Video Recommendations | COMPLETE |
| 9 | Close Friends + Friends Management Page | COMPLETE (merged into Phase 4 UI) |
| 10 | Email Digest Notifications | COMPLETE |
| 11 | Social Integration with Existing Features | COMPLETE |
| 12 | Rocket.Chat DM Integration | COMPLETE |
| 13 | Group/Team Features (Shift Teams, Campaign Teams) | COMPLETE |
| 14 | Gamification (Achievements, Streaks, Leaderboards) | COMPLETE |
| 15 | Real-Time Features (SSE for Live Notifications, Online Status) | COMPLETE |
## Files Created/Modified
### Backend (API)
- `api/prisma/schema.prisma` — added `enableSocial` to SiteSettings
- `api/prisma/migrations/20260224215259_add_enable_social/` — migration
- `api/src/modules/social/` — new module directory
- `social.routes.ts` — main router mounting sub-routers
- `social.schemas.ts` — Zod schemas (friendship, privacy, notification)
- `social.middleware.ts``checkSocialEnabled` feature gate
- `social.rate-limits.ts` — rate limiters (friend request, social action)
- `friendship.service.ts` — full friendship CRUD + notifications
- `friendship.routes.ts` — 10 friendship endpoints
- `block.service.ts` — block/unblock with auto-unfriend
- `block.routes.ts` — 3 block endpoints
- `privacy.service.ts` — privacy settings get/update (auto-create defaults)
- `privacy.routes.ts` — 2 privacy endpoints
- `notification.service.ts` — notification CRUD + preferences (respects opt-outs)
- `notification.routes.ts` — 7 notification endpoints
- `profile.routes.ts` — user profile view (own + other, privacy-filtered)
- `api/src/modules/settings/settings.schemas.ts` — added `enableSocial`
- `api/src/server.ts` — mounted socialRouter at `/api/social`
### Frontend (Admin)
- `admin/src/types/social.ts` — TypeScript interfaces
- `admin/src/stores/social.store.ts` — Zustand social store
- `admin/src/components/social/` — new directory
- `UserAvatar.tsx` — initials avatar with userId-based color
- `FriendButton.tsx` — context-aware friend action button
- `NotificationBell.tsx` — bell icon + dropdown (30s polling)
- `admin/src/pages/volunteer/` — new pages
- `SocialProfilePage.tsx` — own + other user profile
- `FriendsPage.tsx` — friends management (tabs: friends, requests, sent, blocked)
- `NotificationsPage.tsx` — full notification list + preferences
- `admin/src/components/VolunteerLayout.tsx` — added NotificationBell
- `admin/src/components/VolunteerFooterNav.tsx` — added "Friends" nav item
- `admin/src/components/FeatureGate.tsx` — added `enableSocial`
- `admin/src/types/api.ts` — added `enableSocial` to SiteSettings
- `admin/src/pages/SettingsPage.tsx` — added toggle
- `admin/src/App.tsx` — added 6 new volunteer routes
### Phase 6 — Social Activity Feed
- `api/src/modules/social/feed.service.ts` — aggregates 4 activity types + Redis cache (2-min TTL)
- `api/src/modules/social/feed.routes.ts` — GET `/` (friend feed), GET `/my` (own activity)
- `admin/src/components/social/FeedCard.tsx` — activity card with type-based icon/color
- `admin/src/pages/volunteer/SocialFeedPage.tsx` — feed page with suggestions widget
### Phase 7 — CRM Bridge + Suggestions
- `api/src/modules/social/suggestions.service.ts` — ranked suggestions (household/mutual/shifts/campaigns)
- `api/src/modules/social/suggestions.routes.ts` — GET `/`, POST `/:userId/dismiss`
- `admin/src/components/social/FriendSuggestions.tsx` — horizontal scroll suggestions widget
- `admin/src/pages/volunteer/DiscoverPage.tsx` — search + suggestions page
### Phase 8 — Poke System + Video Recommendations
- `api/src/modules/social/poke.service.ts` — poke CRUD + 24h Redis cooldown per pair
- `api/src/modules/social/poke.routes.ts` — POST `/`, GET `/`, GET `/count`, POST `/:id/read`, GET `/cooldown/:userId`
- `api/src/modules/social/recommendation.service.ts` — video recommendation CRUD + duplicate detection
- `api/src/modules/social/recommendation.routes.ts` — POST `/`, GET `/`, GET `/sent`, GET `/count`, POST `/:id/read`
- `admin/src/components/social/PokeButton.tsx` — poke button with cooldown indicator
- `admin/src/components/social/RecommendVideoModal.tsx` — friend + video picker modal
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added PokeButton
### Phase 10 — Email Digest Notifications
- `api/prisma/migrations/20260224232546_add_digest_frequency/` — adds digestFrequency + lastDigestSentAt
- `api/src/services/social-digest.service.ts` — daily scan, generates digest emails
- `api/src/templates/email/social-digest.html` + `.txt` — digest email templates
- `api/src/server.ts` — added daily social digest scan interval
- `admin/src/pages/volunteer/NotificationsPage.tsx` — added digest frequency selector
### Phase 11 — Social Integration with Existing Features
- `api/src/modules/social/integration.service.ts` — friends on shifts, campaigns, and active map sessions (privacy-filtered)
- `api/src/modules/social/integration.routes.ts` — 3 endpoints: shifts/:id/friends, campaigns/:id/friends, map/friends
- `admin/src/components/social/FriendsAttendingBadge.tsx` — "N friends attending" badge with stacked avatars
- `admin/src/components/social/FriendsCampaignBadge.tsx` — "N friends participated" badge with stacked avatars
- `admin/src/components/social/FriendsOnMap.tsx` — floating panel showing friends currently canvassing (60s poll)
- `admin/src/pages/public/ShiftsPage.tsx` — added FriendsAttendingBadge per shift card
- `admin/src/pages/public/CampaignPage.tsx` — added FriendsCampaignBadge in hero section
- `admin/src/pages/volunteer/VolunteerShiftsPage.tsx` — added FriendsAttendingBadge per shift card
- `admin/src/pages/volunteer/VolunteerMapPage.tsx` — added FriendsOnMap overlay
- `admin/src/types/social.ts` — added FriendOnShift, FriendOnCampaign, FriendOnMap types
### Phase 12 — Rocket.Chat DM Integration
- `api/src/modules/social/messaging.service.ts` — openDM: provisions both users, creates DM room, returns roomId
- `api/src/modules/social/profile.routes.ts` — added POST `/:userId/dm` endpoint
- `api/src/services/rocketchat.client.ts` — added `createDM(usernames)` method
- `admin/src/components/social/MessageButton.tsx` — DM button (opens chat widget, RC-gated)
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added MessageButton for accepted friends
### Phase 13 — Group/Team Features
- `api/prisma/schema.prisma` — added SocialGroup, SocialGroupMember models + SocialGroupType enum + User.socialGroupMemberships relation
- `api/prisma/migrations/20260225000017_add_social_groups/` — migration creating social_groups + social_group_members tables
- `api/src/modules/social/group.service.ts` — getOrCreate, syncShiftTeam, syncCampaignTeam, listMyGroups, getGroupDetail
- `api/src/modules/social/group.routes.ts` — GET `/` (my groups), GET `/:id` (group detail)
- `api/src/modules/social/social.routes.ts` — mounted groupRouter at `/groups`
- `api/src/modules/map/shifts/shifts.service.ts` — added fire-and-forget groupService.syncShiftTeam() on all signup/cancel events
- `api/src/modules/influence/campaign-emails/campaign-emails.service.ts` — added fire-and-forget groupService.syncCampaignTeam() on email creation
- `admin/src/types/social.ts` — added SocialGroupSummary, SocialGroupDetail interfaces
- `admin/src/components/social/GroupCard.tsx` — group card with type-based icon/color
- `admin/src/pages/volunteer/GroupDetailPage.tsx` — group detail with member list + FriendButton per member
- `admin/src/pages/volunteer/FriendsPage.tsx` — added "Groups" tab
- `admin/src/App.tsx` — added `/volunteer/groups/:id` route
### Phase 14 — Gamification (Achievements, Streaks, Leaderboards)
- `api/src/modules/social/achievements.service.ts` — 11 achievements (4 shift, 4 canvass, 2 campaign, 2 social), checkAndUnlock, getLeaderboard (raw SQL), getVolunteerStats (on-the-fly computed)
- `api/src/modules/social/achievements.routes.ts` — 6 endpoints: achievements, definitions, stats, stats/:userId, user/:userId, leaderboard
- `api/src/modules/social/social.routes.ts` — mounted achievementsRouter at `/achievements`
- `api/src/modules/map/canvass/canvass.service.ts` — added achievements.checkAndUnlock after recordVisit
- `api/src/modules/map/shifts/shifts.service.ts` — added achievements.checkAndUnlock after signup events (admin, public, volunteer)
- `api/src/modules/influence/campaign-emails/campaign-emails.service.ts` — added achievements.checkAndUnlock after email creation
- `api/src/modules/social/friendship.service.ts` — added achievements.checkAndUnlock on friend accept (both users)
- `admin/src/types/social.ts` — added AchievementDef, AchievementWithProgress, VolunteerStats, LeaderboardEntry interfaces
- `admin/src/pages/volunteer/AchievementsPage.tsx` — badge gallery (locked/unlocked + progress bars), volunteer stats summary, leaderboard (canvass/shifts/campaigns tabs)
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added achievement badges section to own + other user profiles
- `admin/src/pages/volunteer/SocialFeedPage.tsx` — added top canvassers leaderboard widget
- `admin/src/App.tsx` — added `/volunteer/achievements` route
### Phase 15 — Real-Time Features (SSE for Live Notifications, Online Status)
- `api/src/modules/social/sse.service.ts` — in-memory SSE connection manager (addClient, removeClient, sendToUser, sendToUsers, heartbeat, closeAll)
- `api/src/modules/social/presence.service.ts` — online/offline tracking with privacy filtering, broadcastPresence to friends, stale cleanup (5min timeout), markAllOffline (startup)
- `api/src/modules/social/sse.routes.ts` — GET `/` (SSE stream), GET `/online-friends`, GET `/status`
- `api/src/modules/social/social.routes.ts` — mounted sseRouter at `/sse`, added query-param token injection for EventSource auth
- `api/src/modules/social/notification.service.ts` — SSE push after notification creation (real-time delivery)
- `api/src/modules/social/friendship.service.ts` — SSE push on friend_request + friend_accepted events
- `api/src/modules/social/poke.service.ts` — SSE push on poke events
- `api/src/server.ts` — SSE heartbeat start, presenceService.markAllOffline on startup, 1-min stale cleanup interval, sseService.closeAll on graceful shutdown
- `admin/src/hooks/useSSE.ts` — EventSource hook with auto-reconnect (exponential backoff), handles notification/presence/friend_request/friend_accepted/poke events
- `admin/src/components/social/OnlineIndicator.tsx` — green dot showing online status for friends
- `admin/src/components/social/UserAvatar.tsx` — added showOnline prop with OnlineIndicator overlay
- `admin/src/stores/social.store.ts` — added onlineFriends state + fetchOnlineFriends action
- `admin/src/components/VolunteerLayout.tsx` — initialized useSSE() on mount
- `admin/src/components/social/NotificationBell.tsx` — reduced polling to 2-min fallback (SSE handles real-time)
- `admin/src/pages/volunteer/FriendsPage.tsx` — enabled showOnline on friend list avatars
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — enabled showOnline on other user profile avatars

View File

@ -3,7 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Changemaker Lite - Admin</title>
<title>Changemaker Lite</title>
<!-- Default OG meta tags (overridden by nginx bot detection → /api/og/* for specific pages) -->
<meta property="og:title" content="Changemaker Lite" />
<meta property="og:description" content="Take action. Make a difference." />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Changemaker Lite" />
<meta name="twitter:description" content="Take action. Make a difference." />
</head>
<body style="margin:0;background:#1a1025">
<div id="root"></div>

196
admin/package-lock.json generated
View File

@ -10,10 +10,14 @@
"dependencies": {
"@ant-design/icons": "^5.6.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@dagrejs/dagre": "^2.0.4",
"@monaco-editor/react": "^4.7.0",
"@types/d3-force": "^3.0.10",
"@types/dompurify": "^3.2.0",
"@xyflow/react": "^12.10.1",
"antd": "^5.23.0",
"axios": "^1.7.9",
"d3-force": "^3.0.0",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"grapesjs": "^0.22.14",
@ -424,6 +428,19 @@
"node": ">=6.9.0"
}
},
"node_modules/@dagrejs/dagre": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz",
"integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==",
"dependencies": {
"@dagrejs/graphlib": "3.0.4"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz",
"integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg=="
},
"node_modules/@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
@ -1499,11 +1516,24 @@
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dependencies": {
"@types/d3-selection": "*"
}
},
"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-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@ -1525,6 +1555,11 @@
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@ -1543,6 +1578,23 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/dompurify": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
@ -1646,6 +1698,63 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xyflow/react": {
"version": "12.10.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
"integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
"dependencies": {
"@xyflow/system": "0.0.75",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.75",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz",
"integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/antd": {
"version": "5.29.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
@ -1817,6 +1926,11 @@
}
]
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@ -1906,6 +2020,26 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@ -1914,6 +2048,19 @@
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@ -1941,6 +2088,14 @@
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@ -1956,6 +2111,14 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@ -1997,6 +2160,39 @@
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",

View File

@ -11,10 +11,14 @@
"dependencies": {
"@ant-design/icons": "^5.6.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@dagrejs/dagre": "^2.0.4",
"@monaco-editor/react": "^4.7.0",
"@types/d3-force": "^3.0.10",
"@types/dompurify": "^3.2.0",
"@xyflow/react": "^12.10.1",
"antd": "^5.23.0",
"axios": "^1.7.9",
"d3-force": "^3.0.0",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"grapesjs": "^0.22.14",

View File

@ -38,23 +38,32 @@ import ExcalidrawPage from '@/pages/ExcalidrawPage';
import VaultwardenPage from '@/pages/VaultwardenPage';
import RocketChatPage from '@/pages/RocketChatPage';
import GancioPage from '@/pages/GancioPage';
import JitsiMeetPage from '@/pages/JitsiMeetPage';
import SettingsPage from '@/pages/SettingsPage';
import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
import PangolinPage from '@/pages/PangolinPage';
import ObservabilityPage from '@/pages/ObservabilityPage';
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
import DocsCommentsPage from '@/pages/DocsCommentsPage';
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
import SubscribersPage from '@/pages/payments/SubscribersPage';
import PaymentProductsPage from '@/pages/payments/ProductsPage';
import PaymentDonationsPage from '@/pages/payments/DonationsPage';
import DonationPagesPage from '@/pages/payments/DonationPagesPage';
import PlansPage from '@/pages/payments/PlansPage';
import PaymentSettingsPage from '@/pages/payments/PaymentSettingsPage';
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 GalleryAdsPage from '@/pages/media/GalleryAdsPage';
import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage';
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage';
import PublicLandingPage from '@/pages/public/LandingPage';
import PagesIndexPage from '@/pages/public/PagesIndexPage';
import EventsPage from '@/pages/public/EventsPage';
import HomePage from '@/pages/public/HomePage';
import CampaignsListPage from '@/pages/public/CampaignsListPage';
import CampaignPage from '@/pages/public/CampaignPage';
import CreateCampaignPage from '@/pages/public/CreateCampaignPage';
@ -73,21 +82,44 @@ import MySettingsPage from '@/pages/public/MySettingsPage';
import VolunteerChatPage from '@/pages/volunteer/VolunteerChatPage';
import PricingPage from '@/pages/public/PricingPage';
import ShopPage from '@/pages/public/ShopPage';
import ProductDetailPage from '@/pages/public/ProductDetailPage';
import PlanDetailPage from '@/pages/public/PlanDetailPage';
import DonatePage from '@/pages/public/DonatePage';
import DonationPagesListPage from '@/pages/public/DonationPagesListPage';
import PaymentSuccessPage from '@/pages/public/PaymentSuccessPage';
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
import FriendsPage from '@/pages/volunteer/FriendsPage';
import SocialProfilePage from '@/pages/volunteer/SocialProfilePage';
import NotificationsPage from '@/pages/volunteer/NotificationsPage';
import SocialFeedPage from '@/pages/volunteer/SocialFeedPage';
import DiscoverPage from '@/pages/volunteer/DiscoverPage';
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
import AchievementsPage from '@/pages/volunteer/AchievementsPage';
import { ADMIN_ROLES } from '@/types/api';
import { isAdmin } from '@/utils/roles';
import QuickJoinPage from '@/pages/public/QuickJoinPage';
import VerifyEmailPage from '@/pages/VerifyEmailPage';
import ResetPasswordPage from '@/pages/ResetPasswordPage';
import SmsDashboardPage from '@/pages/sms/SmsDashboardPage';
import SmsContactsPage from '@/pages/sms/SmsContactsPage';
import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage';
import SmsConversationsPage from '@/pages/sms/SmsConversationsPage';
import SmsSetupPage from '@/pages/sms/SmsSetupPage';
import PeoplePage from '@/pages/PeoplePage';
import ContactProfilePage from '@/pages/public/ContactProfilePage';
import SocialDashboardPage from '@/pages/social/SocialDashboardPage';
import SocialGraphPage from '@/pages/social/SocialGraphPage';
import SocialModerationPage from '@/pages/social/SocialModerationPage';
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage';
import CommandPalette from '@/components/command-palette/CommandPalette';
function RoleAwareRedirect() {
const { user, isAuthenticated } = useAuthStore();
if (!isAuthenticated) return <Navigate to="/campaigns" replace />;
if (!isAuthenticated) return <Navigate to="/home" replace />;
if (user && isAdmin(user)) return <Navigate to="/app" replace />;
return <Navigate to="/volunteer" replace />;
}
@ -151,7 +183,13 @@ export default function App() {
>
<AntApp>
<BrowserRouter>
<CommandPalette />
<Routes>
{/* Public homepage */}
<Route path="/home" element={<PublicLayout />}>
<Route index element={<HomePage />} />
</Route>
{/* Public pages (no auth, dark blue theme) — feature-gated */}
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route index element={<CampaignsListPage />} />
@ -182,22 +220,41 @@ export default function App() {
<Route index element={<PublicShiftsPage />} />
</Route>
<Route path="/map" element={<FeatureGate feature="enableMap"><MapPage /></FeatureGate>} />
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<EventsPage />} />
</Route>
{/* Public meeting join page — feature-gated */}
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
<Route index element={<MeetingJoinPage />} />
</Route>
<Route path="/pages" element={<FeatureGate feature="enableLandingPages"><PublicLayout /></FeatureGate>}>
<Route index element={<PagesIndexPage />} />
</Route>
<Route path="/p/:slug" element={<FeatureGate feature="enableLandingPages"><PublicLandingPage /></FeatureGate>} />
{/* Public Payment pages (PublicLayout, dark blue theme) — feature-gated */}
<Route path="/pricing" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
<Route index element={<PricingPage />} />
<Route path=":slug" element={<PlanDetailPage />} />
</Route>
<Route path="/shop" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
<Route index element={<ShopPage />} />
<Route path=":slug" element={<ProductDetailPage />} />
</Route>
<Route path="/donate" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
<Route index element={<DonatePage />} />
<Route index element={<DonationPagesListPage />} />
<Route path=":slug" element={<DonatePage />} />
</Route>
<Route path="/payments/success" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
<Route index element={<PaymentSuccessPage />} />
</Route>
{/* Self-service contact profile (no auth, token-based access) */}
<Route path="/profile/:token" element={<PublicLayout />}>
<Route index element={<ContactProfilePage />} />
</Route>
{/* Public Media Gallery (purple theme) — feature-gated */}
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<MediaGalleryPage />} />
@ -240,6 +297,14 @@ export default function App() {
<Route path="/volunteer/activity" element={<MyActivityPage />} />
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
<Route path="/volunteer/feed" element={<SocialFeedPage />} />
<Route path="/volunteer/friends" element={<FriendsPage />} />
<Route path="/volunteer/discover" element={<DiscoverPage />} />
<Route path="/volunteer/profile/:userId" element={<SocialProfilePage />} />
<Route path="/volunteer/profile" element={<SocialProfilePage />} />
<Route path="/volunteer/notifications" element={<NotificationsPage />} />
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
<Route path="/volunteer/achievements" element={<AchievementsPage />} />
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
</Route>
@ -251,6 +316,7 @@ export default function App() {
<Route path="/join" element={<QuickJoinPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/jitsi-auth/:room" element={<JitsiAuthPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route
@ -262,6 +328,14 @@ export default function App() {
}
>
<Route index element={<DashboardPage />} />
<Route
path="people"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<PeoplePage />
</ProtectedRoute>
}
/>
<Route
path="users"
element={
@ -270,6 +344,36 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="social"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableSocial">
<SocialDashboardPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route
path="social/graph"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableSocial">
<SocialGraphPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route
path="social/moderation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<FeatureGate feature="enableSocial">
<SocialModerationPage />
</FeatureGate>
</ProtectedRoute>
}
/>
<Route
path="campaigns"
element={
@ -366,6 +470,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="docs/comments"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<DocsCommentsPage />
</ProtectedRoute>
}
/>
<Route
path="navigation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<NavigationSettingsPage />
</ProtectedRoute>
}
/>
<Route
path="code"
element={
@ -446,6 +566,55 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="services/jitsi"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<JitsiMeetPage />
</ProtectedRoute>
}
/>
{/* SMS Campaign Routes */}
<Route
path="sms/setup"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<SmsSetupPage />
</ProtectedRoute>
}
/>
<Route
path="sms"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<SmsDashboardPage />
</ProtectedRoute>
}
/>
<Route
path="sms/contacts"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<SmsContactsPage />
</ProtectedRoute>
}
/>
<Route
path="sms/campaigns"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<SmsCampaignsPage />
</ProtectedRoute>
}
/>
<Route
path="sms/conversations"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<SmsConversationsPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={
@ -567,7 +736,15 @@ export default function App() {
}
/>
<Route
path="media/gallery-ads"
path="payments/ads/analytics"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<AdAnalyticsDashboardPage />
</ProtectedRoute>
}
/>
<Route
path="payments/ads"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<GalleryAdsPage />
@ -582,6 +759,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="payments/plans"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<PlansPage />
</ProtectedRoute>
}
/>
<Route
path="payments/subscribers"
element={
@ -606,6 +791,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="payments/donation-pages"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<DonationPagesPage />
</ProtectedRoute>
}
/>
<Route
path="payments/settings"
element={

View File

@ -0,0 +1,89 @@
import GalleryAdCard from '@/components/media/GalleryAdCard';
import type { GalleryAd } from '@/types/gallery-ads';
import { getOrCreateSessionId } from '@/lib/media-public-api';
interface AdBannerProps {
ads: GalleryAd[];
/** Maximum number of ads to display (default: 1) */
maxAds?: number;
/** Placement identifier for session-stable rotation */
placement?: string;
}
/** Simple deterministic hash for session-stable selection */
function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
hash = ((hash << 5) - hash + ch) | 0; // Convert to 32-bit int
}
return Math.abs(hash);
}
/**
* Select ads using weighted session-stable rotation.
* Lower position = higher weight. Same session+placement always returns the same ads.
*/
function selectAds(ads: GalleryAd[], maxAds: number, placement: string): GalleryAd[] {
if (ads.length <= maxAds) return ads;
const sessionId = getOrCreateSessionId();
const seed = hashCode(sessionId + ':' + placement);
const maxPosition = Math.max(...ads.map((a) => a.position ?? 0));
const selected: GalleryAd[] = [];
const remaining = [...ads];
for (let i = 0; i < maxAds && remaining.length > 0; i++) {
// Build weights: lower position = higher weight
const weights = remaining.map((ad) => maxPosition - (ad.position ?? 0) + 1);
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
// Use a different hash offset per slot so multi-ad displays don't repeat
const pick = (seed + i * 7919) % totalWeight;
let cumulative = 0;
let chosenIdx = 0;
for (let j = 0; j < weights.length; j++) {
cumulative += (weights[j] ?? 0);
if (pick < cumulative) {
chosenIdx = j;
break;
}
}
const chosen = remaining[chosenIdx];
if (chosen) selected.push(chosen);
remaining.splice(chosenIdx, 1);
}
return selected;
}
/**
* Renders GalleryAdCard(s) in a centered container.
* Intended for use on public pages between content sections.
*/
export default function AdBanner({ ads, maxAds = 1, placement }: AdBannerProps) {
if (ads.length === 0) return null;
const displayAds = placement ? selectAds(ads, maxAds, placement) : ads.slice(0, maxAds);
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: 16,
margin: '24px auto',
maxWidth: maxAds > 1 ? 800 : 400,
flexWrap: 'wrap',
}}
>
{displayAds.map((ad) => (
<div key={ad.id} style={{ flex: '1 1 300px', maxWidth: 400 }}>
<GalleryAdCard ad={ad} />
</div>
))}
</div>
);
}

View File

@ -18,6 +18,7 @@ import {
HomeOutlined,
ScissorOutlined,
CalendarOutlined,
ScheduleOutlined,
FileTextOutlined,
NotificationOutlined,
BookOutlined,
@ -42,6 +43,15 @@ import {
CrownOutlined,
PictureOutlined,
LockOutlined,
PhoneOutlined,
TagOutlined,
SearchOutlined,
ContactsOutlined,
VideoCameraOutlined,
ApartmentOutlined,
SafetyOutlined,
StarFilled,
StarOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { api } from '@/lib/api';
@ -49,15 +59,97 @@ import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { hasAnyRole } from '@/utils/roles';
import type { PageHeaderConfig, AppOutletContext } from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api';
import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useFavoritesStore } from '@/stores/favorites.store';
import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items';
import RocketChatWidget from './chat/RocketChatWidget';
// Re-export for backward compatibility
export type { PageHeaderConfig, AppOutletContext };
/** Wrap a leaf menu item's label with a favorite star toggle */
function FavoriteLabel({ label, itemKey }: { label: React.ReactNode; itemKey: string }) {
const { isFavorite, toggleFavorite } = useFavoritesStore();
const starred = isFavorite(itemKey);
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span
className={`favorite-star${starred ? ' favorite-star--active' : ''}`}
onClick={(e) => { e.stopPropagation(); toggleFavorite(itemKey); }}
style={{ fontSize: 12, lineHeight: 1, cursor: 'pointer', flexShrink: 0 }}
>
{starred ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined style={{ color: 'rgba(255,255,255,0.45)' }} />}
</span>
</span>
);
}
/** Recursively walk menu items, wrapping leaf labels with FavoriteLabel stars */
function addStarsToMenuItems(items: MenuProps['items'], leafKeys: Set<string>): MenuProps['items'] {
if (!items) return items;
return items.map(item => {
if (!item) return item;
if ('children' in item && item.children) {
return { ...item, children: addStarsToMenuItems(item.children as MenuProps['items'], leafKeys) } as typeof item;
}
if ('key' in item && 'label' in item && item.key && leafKeys.has(item.key as string)) {
return { ...item, label: <FavoriteLabel label={item.label} itemKey={item.key as string} /> } as typeof item;
}
return item;
});
}
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const { useBreakpoint } = Grid;
function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number }): MenuProps['items'] {
/** Default nav items for the admin header when navConfig is null */
const DEFAULT_ADMIN_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
/** Map icon string IDs to Ant Design icon components for the admin header */
const ADMIN_ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
SendOutlined: <SoundOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlaySquareOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <GlobalOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
};
/** Merge missing builtin defaults into stored navConfig items and sync icons */
function mergeAdminNavDefaults(stored: NavItem[]): NavItem[] {
const defaultMap = new Map(DEFAULT_ADMIN_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
// Sync icon for existing builtin items so code-level icon changes propagate
const synced = stored.map(item => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map(i => i.id));
const missing = DEFAULT_ADMIN_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
return missing.length > 0 ? [...synced, ...missing] : synced;
}
function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number; pendingEmails?: number; pendingCampaignReview?: number; pendingComments?: number }): MenuProps['items'] {
const items: MenuProps['items'] = [
{
key: '/app',
@ -66,6 +158,28 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
},
];
// People & Access submenu — Users always visible, People gated by feature flag
{
const communityChildren: MenuProps['items'] = [];
if (settings?.enablePeople) {
communityChildren.push({ key: '/app/people', icon: <ContactsOutlined />, label: 'People' });
}
communityChildren.push({ key: '/app/users', icon: <TeamOutlined />, label: 'Users' });
if (settings?.enableSocial) {
communityChildren.push(
{ key: '/app/social', icon: <HeartOutlined />, label: 'Social Dashboard' },
{ key: '/app/social/graph', icon: <ApartmentOutlined />, label: 'Social Graph' },
{ key: '/app/social/moderation', icon: <SafetyOutlined />, label: 'Social Moderation' },
);
}
items.push({
key: 'community-submenu',
icon: <ContactsOutlined />,
label: 'People & Access',
children: communityChildren,
});
}
if (settings?.enableInfluence !== false) {
items.push({
key: 'influence-submenu',
@ -73,9 +187,9 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
label: 'Advocacy',
children: [
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
{ key: '/app/campaign-moderation', icon: <FileTextOutlined />, label: 'Campaign Review' },
{ key: '/app/campaign-moderation', icon: <FileTextOutlined />, label: badges?.pendingCampaignReview ? <Badge count={badges.pendingCampaignReview} size="small" offset={[8, 0]}>Campaign Review</Badge> : 'Campaign Review' },
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Outgoing Emails' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: badges?.pendingEmails ? <Badge count={badges.pendingEmails} size="small" offset={[8, 0]}>Outgoing Emails</Badge> : 'Outgoing Emails' },
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
],
@ -83,14 +197,28 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
}
if (settings?.enableNewsletter !== false) {
const broadcastChildren: MenuProps['items'] = [
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
];
if (settings?.enableSms || isSuperAdmin) {
broadcastChildren.push(
{ key: '/app/sms/setup', icon: <SettingOutlined />, label: 'SMS Setup' },
);
}
if (settings?.enableSms) {
broadcastChildren.push(
{ key: '/app/sms', icon: <PhoneOutlined />, label: 'SMS Dashboard' },
{ key: '/app/sms/contacts', icon: <TeamOutlined />, label: 'SMS Contacts' },
{ key: '/app/sms/campaigns', icon: <SendOutlined />, label: 'SMS Campaigns' },
{ key: '/app/sms/conversations', icon: <MessageOutlined />, label: 'SMS Threads' },
);
}
items.push({
key: 'broadcast-submenu',
icon: <NotificationOutlined />,
label: 'Broadcast',
children: [
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
],
children: broadcastChildren,
});
}
@ -99,8 +227,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
if (settings?.enableLandingPages !== false) {
webChildren.push({ key: '/app/pages', icon: <FileTextOutlined />, label: 'Landing Pages' });
}
webChildren.push({ key: '/app/navigation', icon: <GlobalOutlined />, label: 'Navigation' });
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({
@ -118,7 +248,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
children: [
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
{ key: '/app/map/shifts', icon: <CalendarOutlined />, label: 'Shifts' },
{ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Areas' },
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
@ -136,7 +266,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/media/analytics', icon: <BarChartOutlined />, label: 'Analytics' },
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
{ key: '/app/media/moderation', icon: <MessageOutlined />, label: 'Moderation' },
{ key: '/app/media/gallery-ads', icon: <PictureOutlined />, label: 'Gallery Ads' },
{ key: '/app/media/jobs', icon: <HistoryOutlined />, label: 'Processing Jobs' },
],
});
@ -149,9 +278,12 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
label: 'Payments',
children: [
{ key: '/app/payments', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '/app/payments/plans', icon: <TagOutlined />, label: 'Plans' },
{ key: '/app/payments/subscribers', icon: <CrownOutlined />, label: 'Subscribers' },
{ key: '/app/payments/products', icon: <ShoppingOutlined />, label: 'Products' },
{ key: '/app/payments/donations', icon: <HeartOutlined />, label: 'Donations' },
{ key: '/app/payments/donation-pages', icon: <HeartOutlined />, label: 'Donation Pages' },
{ key: '/app/payments/donations', icon: <DollarOutlined />, label: 'Donation Orders' },
{ key: '/app/payments/ads', icon: <PictureOutlined />, label: 'Gallery Ads' },
{ key: '/app/payments/settings', icon: <SettingOutlined />, label: 'Settings' },
],
});
@ -175,6 +307,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
...(settings?.enableChat ? [{ key: '/app/services/rocketchat', icon: <MessageOutlined />, label: 'Team Chat' }] : []),
...(settings?.enableMeet ? [{ key: '/app/services/jitsi', icon: <VideoCameraOutlined />, label: 'Video Meet' }] : []),
{ key: '/app/services/gancio', icon: <CalendarOutlined />, label: 'Events' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]},
@ -183,11 +316,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
}
items.push(
{
key: '/app/users',
icon: <TeamOutlined />,
label: 'Users',
},
{
key: '/app/settings',
icon: <SettingOutlined />,
@ -210,11 +338,16 @@ export default function AppLayout() {
const screens = useBreakpoint();
const isMobile = !screens.md;
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const [pendingResponses, setPendingResponses] = useState(0);
const [badgeCounts, setBadgeCounts] = useState<{ pendingResponses: number; pendingEmails: number; pendingCampaignReview: number; pendingComments: number }>({ pendingResponses: 0, pendingEmails: 0, pendingCampaignReview: 0, pendingComments: 0 });
const fetchBadges = useCallback(() => {
api.get('/dashboard/summary').then(({ data }) => {
setPendingResponses(data?.responses?.pending ?? 0);
setBadgeCounts({
pendingResponses: data?.responses?.pending ?? 0,
pendingEmails: data?.emails?.queued ?? 0,
pendingCampaignReview: data?.campaignModeration?.pendingReview ?? 0,
pendingComments: data?.docsComments?.pending ?? 0,
});
}).catch(() => {});
}, []);
@ -224,10 +357,37 @@ export default function AppLayout() {
return () => clearInterval(interval);
}, [fetchBadges]);
const menuItems = buildMenuItems(settings, isSuperAdmin, { pendingResponses });
const baseMenuItems = buildMenuItems(settings, isSuperAdmin, badgeCounts);
const { favorites } = useFavoritesStore();
// Build final menu: resolve favorites, add stars, prepend favorites section
const menuItems = (() => {
const leafKeys = collectLeafKeys(baseMenuItems);
const starredItems = addStarsToMenuItems(baseMenuItems, leafKeys);
// Resolve favorites against current menu (handles feature-flag changes)
const validFavorites = resolveValidFavorites(baseMenuItems, favorites);
if (validFavorites.length === 0) return starredItems;
const favSection: NonNullable<MenuProps['items']> = [
{
type: 'group' as const,
label: 'Favorites',
children: validFavorites.map(fav => ({
key: `fav:${fav.key}`,
icon: fav.icon,
label: fav.label,
})),
},
{ type: 'divider' as const },
];
return [...favSection, ...(starredItems || [])];
})();
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
// Strip 'fav:' prefix from favorites section items
const route = key.startsWith('fav:') ? key.slice(4) : key;
navigate(route);
if (isMobile) setDrawerOpen(false);
};
@ -253,44 +413,72 @@ export default function AppLayout() {
},
];
// Match the current path to a menu key (supports submenus)
// Match the current path to a menu key (supports submenus and item groups)
const selectedKey = (() => {
const path = location.pathname;
// Exact match first
if (path === '/app') return '/app';
// Check all items including children — longest match wins
// Check all items including children and group grandchildren — longest match wins
let best = '';
const checkKey = (k: string) => {
if (k.startsWith('/') && (path === k || path.startsWith(k + '/'))) {
if (k.length > best.length) best = k;
}
};
for (const item of menuItems || []) {
if (!item || !('key' in item)) continue;
if ('children' in item && item.children) {
for (const child of item.children) {
if (!child || !('key' in child)) continue;
const k = child.key as string;
if (path === k || path.startsWith(k + '/')) {
if (k.length > best.length) best = k;
if (!child) continue;
// Handle item groups (type: 'group') — check their nested children
if ('type' in child && child.type === 'group' && 'children' in child && child.children) {
for (const grandchild of child.children) {
if (!grandchild || !('key' in grandchild)) continue;
checkKey(grandchild.key as string);
}
} else if ('key' in child) {
checkKey(child.key as string);
}
}
}
const k = item.key?.toString() || '';
if (k.startsWith('/') && k !== '/app' && (path === k || path.startsWith(k + '/'))) {
if (k.length > best.length) best = k;
}
if (k !== '/app') checkKey(k);
}
return best || '/app';
})();
// Also highlight the corresponding favorite item if present
const selectedKeys = favorites.includes(selectedKey)
? [selectedKey, `fav:${selectedKey}`]
: [selectedKey];
// Derive which submenus should be open based on active route
const defaultOpenKeys = (() => {
const path = location.pathname;
const keys: string[] = [];
for (const item of menuItems || []) {
if (!item || !('children' in item) || !item.children) continue;
let found = false;
for (const child of item.children) {
if (!child || !('key' in child)) continue;
const k = child.key as string;
if (path === k || path.startsWith(k + '/')) {
keys.push(item.key as string);
break;
if (found) break;
if (!child) continue;
// Handle item groups (type: 'group') — check their nested children
if ('type' in child && child.type === 'group' && 'children' in child && child.children) {
for (const grandchild of child.children) {
if (!grandchild || !('key' in grandchild)) continue;
const k = grandchild.key as string;
if (path === k || path.startsWith(k + '/')) {
keys.push(item.key as string);
found = true;
break;
}
}
} else if ('key' in child) {
const k = child.key as string;
if (path === k || path.startsWith(k + '/')) {
keys.push(item.key as string);
found = true;
}
}
}
}
@ -333,7 +521,7 @@ export default function AppLayout() {
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
selectedKeys={selectedKeys}
defaultOpenKeys={defaultOpenKeys}
items={menuItems}
onClick={handleMenuClick}
@ -342,6 +530,12 @@ export default function AppLayout() {
);
return (
<>
<style>{`
.favorite-star { opacity: 0; transition: opacity 0.15s; }
.favorite-star--active { opacity: 1; }
.ant-menu-item:hover .favorite-star { opacity: 1; }
`}</style>
<Layout style={{ minHeight: '100vh' }}>
{isMobile ? (
<Drawer
@ -366,11 +560,11 @@ export default function AppLayout() {
<Layout>
<Header
style={{
padding: '0 24px',
padding: '0 16px',
background: 'transparent',
display: 'flex',
alignItems: 'center',
gap: 6,
gap: 2,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
@ -391,103 +585,70 @@ export default function AppLayout() {
</Text>
)}
<div style={{ flex: 1 }} />
{pageHeader?.actions}
<Tooltip title="Home (Static Site)">
<Tooltip title={navigator.platform?.toLowerCase().includes('mac') ? 'Search (⌘K)' : 'Search (Ctrl+K)'}>
<Button
type="text"
icon={<HomeOutlined />}
onClick={() => window.open(`//${window.location.hostname}:4004`, '_blank')}
>
{!isMobile && 'Home'}
</Button>
icon={<SearchOutlined />}
onClick={() => useCommandPaletteStore.getState().open()}
/>
</Tooltip>
{settings?.enableInfluence !== false && (
<Tooltip title="View Public Campaigns">
<Button
type="text"
icon={<SoundOutlined />}
onClick={() => navigate('/campaigns')}
>
{!isMobile && 'Campaigns'}
</Button>
</Tooltip>
)}
{pageHeader?.actions}
{(() => {
const items = mergeAdminNavDefaults(settings?.navConfig?.items ?? DEFAULT_ADMIN_NAV_ITEMS);
const featureFlags: Record<string, boolean | undefined> = {
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
};
return items
.filter(item => item.enabled)
.filter(item => {
if (!item.featureFlag) return true;
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order)
.map(item => (
<Tooltip key={item.id} title={item.label}>
<Button
type="text"
size="small"
icon={ADMIN_ICON_MAP[item.icon] ?? <GlobalOutlined />}
onClick={() => {
if (item.path.startsWith('$')) {
window.open(resolveNavUrl(item.path), '_blank');
} else if (item.external && item.id === 'home') {
window.open(buildHomeUrl(), '_blank');
} else if (item.external) {
window.open(item.path, '_blank');
} else {
navigate(item.path);
}
}}
>
{!isMobile && !collapsed && item.label}
</Button>
</Tooltip>
));
})()}
{/* Canvass button — always tied to enableMap, not in navConfig */}
{settings?.enableMap !== false && (
<>
<Tooltip title="View Public Map">
<Button
type="text"
icon={<EnvironmentOutlined />}
onClick={() => navigate('/map')}
>
{!isMobile && 'Map'}
</Button>
</Tooltip>
<Tooltip title="View Public Shifts">
<Button
type="text"
icon={<CalendarOutlined />}
onClick={() => navigate('/shifts')}
>
{!isMobile && 'Shifts'}
</Button>
</Tooltip>
<Tooltip title="Switch to Volunteer Portal">
<Button
type="text"
icon={<TeamOutlined />}
onClick={() => navigate('/volunteer')}
>
{!isMobile && 'Canvass'}
</Button>
</Tooltip>
</>
)}
{settings?.enableMediaFeatures !== false && (
<Tooltip title="Open Gallery">
<Tooltip title="Switch to Volunteer Portal">
<Button
type="text"
icon={<PlaySquareOutlined />}
onClick={() => navigate('/gallery')}
size="small"
icon={<TeamOutlined />}
onClick={() => navigate('/volunteer')}
>
{!isMobile && 'Gallery'}
{!isMobile && !collapsed && 'Canvass'}
</Button>
</Tooltip>
)}
{settings?.enablePayments && (
<>
<Tooltip title="View Pricing Page">
<Button
type="text"
icon={<DollarOutlined />}
onClick={() => navigate('/pricing')}
>
{!isMobile && 'Pricing'}
</Button>
</Tooltip>
<Tooltip title="View Shop">
<Button
type="text"
icon={<ShoppingOutlined />}
onClick={() => navigate('/shop')}
>
{!isMobile && 'Shop'}
</Button>
</Tooltip>
<Tooltip title="View Donate Page">
<Button
type="text"
icon={<HeartOutlined />}
onClick={() => navigate('/donate')}
>
{!isMobile && 'Donate'}
</Button>
</Tooltip>
</>
)}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />}>
{!isMobile && (
{!isMobile && !collapsed && (
<Text style={{ marginLeft: 8 }}>
{user?.name || user?.email || 'User'}
</Text>
@ -509,5 +670,7 @@ export default function AppLayout() {
</Content>
</Layout>
</Layout>
<RocketChatWidget />
</>
);
}

View File

@ -15,10 +15,14 @@ const FEATURE_LABELS: Record<string, string> = {
enableMediaFeatures: 'Media Library',
enablePayments: 'Payments',
enableGalleryAds: 'Gallery Ads',
enablePeople: 'People CRM',
enableEvents: 'Events',
enableSocial: 'Social Connections',
enableMeet: 'Video Meetings',
};
interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds'>;
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet'>;
children: ReactNode;
}

View File

@ -14,6 +14,7 @@ import styleGradientPlugin from 'grapesjs-style-gradient';
import touchPlugin from 'grapesjs-touch';
import type { PageBlock } from '@/types/api';
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
import { generatePhotoCardHtml } from '@/utils/photoCardHtml';
interface GrapesJSEditorProps {
initialData?: Record<string, unknown>;
@ -103,6 +104,21 @@ const GrapesJSEditor = forwardRef<GrapesJSEditorHandle, GrapesJSEditorProps>(
}
}
// Register ad blocks
{
const bm = editor.Blocks;
bm.add('ad-specific', {
label: 'Specific Ad',
category: 'Ads',
content: generateBlockHtml('ad-specific', { adId: 0 }),
});
bm.add('ad-slot', {
label: 'Ad Slot (Dynamic)',
category: 'Ads',
content: generateBlockHtml('ad-slot', { variant: 'standard' }),
});
}
// Register save command
editor.Commands.add('save-page', {
run(ed: Editor) {
@ -395,6 +411,144 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
</div>
</section>`;
}
case 'photo': {
const photoId = defaults.photoId || 'PLACEHOLDER';
const size = defaults.size || 'large';
const caption = (defaults.caption as string) || '';
const linkToGallery = defaults.linkToGallery !== false;
const alignment = (defaults.alignment as string) || 'center';
const maxWidth = (defaults.maxWidth as string) || '100%';
return `
<section style="padding: 60px 40px;">
<div class="photo-block"
data-photo-id="${photoId}"
data-size="${size}"
data-caption="${caption}"
data-link-to-gallery="${linkToGallery}"
data-alignment="${alignment}"
style="max-width: ${maxWidth}; margin: 0 ${{ left: 'auto 0 0', center: 'auto', right: '0 0 auto' }[alignment as string] || 'auto'}; text-align: ${alignment};">
<div class="photo-placeholder" style="aspect-ratio: 3/2; background: linear-gradient(135deg, #43cea2 0%, #185a9d 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="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="m21 15-5-5L5 21"/>
</svg>
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">Photo</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;">ID: ${photoId}</p>
<p style="margin: 4px 0 0; font-size: 0.85rem; opacity: 0.7;">Size: ${size}</p>
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Photo will render on published page</p>
</div>
</div>
</div>
</section>`;
}
case 'photo-card': {
const photoId = defaults.photoId;
const title = (defaults.title as string) || 'Photo Title';
const description = (defaults.description as string) || '';
const showMetadata = defaults.showMetadata !== false;
const mediaApiUrl = 'http://localhost:4100';
if (!photoId || photoId === 'PLACEHOLDER') {
return `
<section style="padding: 40px 20px;">
<div class="photo-card-block" style="max-width: 480px; margin: 0 auto; border-radius: 12px; overflow: hidden; background: #1b2838; box-shadow: 0 4px 12px rgba(0,0,0,0.3);">
<div style="padding-bottom: 66.67%; background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%); position: relative;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: #fff;">
<svg style="width: 48px; height: 48px; margin-bottom: 8px; opacity: 0.9;" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
<p style="margin: 0; font-size: 14px; font-weight: 600;">Photo Card</p>
<p style="margin: 4px 0 0; font-size: 12px; opacity: 0.7;">Select a photo to display</p>
</div>
</div>
<div style="padding: 12px 16px;">
<div style="color: #fff; font-size: 15px; font-weight: 600;">Photo Title</div>
<div style="color: #8899aa; font-size: 13px; margin-top: 6px;">Card will render on published page</div>
</div>
</div>
</section>`;
}
const cardHtml = generatePhotoCardHtml({
id: photoId as number,
title,
description,
showMetadata,
viewCount: 0,
thumbnailUrl: `${mediaApiUrl}/api/public/photos/${photoId}/thumbnail`,
});
return `<section style="padding: 40px 20px;">${cardHtml}</section>`;
}
case 'photo-album': {
const albumId = defaults.albumId || 'PLACEHOLDER';
const columns = defaults.columns || '3';
const maxPhotos = defaults.maxPhotos || 12;
const showTitle = defaults.showTitle !== false;
return `
<section style="padding: 60px 40px;">
<div class="photo-album-block"
data-album-id="${albumId}"
data-columns="${columns}"
data-max-photos="${maxPhotos}"
data-show-title="${showTitle}"
style="max-width: 900px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%); border-radius: 12px; padding: 32px; position: relative; overflow: hidden;">
<div style="text-align: center; color: #fff; margin-bottom: 24px;">
<svg style="width: 48px; height: 48px; margin-bottom: 12px; opacity: 0.9;" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<rect x="2" y="2" width="20" height="20" rx="2"/>
<rect x="6" y="6" width="12" height="12" rx="1" opacity="0.6"/>
<rect x="9" y="9" width="6" height="6" rx="0.5" opacity="0.4"/>
</svg>
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">Photo Album</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;">Album ID: ${albumId} | ${columns} columns | Max ${maxPhotos} photos</p>
</div>
<div style="display: grid; grid-template-columns: repeat(${columns}, 1fr); gap: 8px;">
${Array.from({ length: Math.min(Number(columns) * 2, 6) }, () => `
<div style="aspect-ratio: 1; background: rgba(255,255,255,0.15); border-radius: 6px; display: flex; align-items: center; justify-content: center;">
<svg style="width: 24px; height: 24px; opacity: 0.5;" fill="none" stroke="#fff" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</div>`).join('')}
</div>
<p style="text-align: center; margin: 16px 0 0; font-size: 0.75rem; opacity: 0.6; color: #fff; font-style: italic;">Album will render on published page</p>
</div>
</div>
</section>`;
}
case 'ad-specific': {
const adId = defaults.adId || 0;
return `
<section style="padding: 40px 20px;">
<div class="ad-specific-block"
data-ad-id="${adId}"
style="max-width: 400px; margin: 0 auto;">
<div style="border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, #e65100 0%, #bf360c 100%); padding: 32px; text-align: center; color: #fff;">
<div style="font-size: 36px; margin-bottom: 12px;">&#x1F4E2;</div>
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">Specific Ad</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">ID: ${adId || 'Not set'}</p>
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Ad will render on published page</p>
</div>
</div>
</section>`;
}
case 'ad-slot': {
const variant = (defaults.variant as string) || 'standard';
return `
<section style="padding: 40px 20px;">
<div class="ad-slot-block"
data-placement="landing_page"
data-variant="${variant}"
style="max-width: 400px; margin: 0 auto;">
<div style="border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, #2e7d32 0%, #1b5e20 100%); padding: 32px; text-align: center; color: #fff;">
<div style="font-size: 36px; margin-bottom: 12px;">&#x1F500;</div>
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">Ad Slot (Dynamic)</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">Variant: ${variant}</p>
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Rotating ad will render on published page</p>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}

View File

@ -9,6 +9,7 @@ import ChatBar from '@/components/media/chatbar/ChatBar';
import { useChatNotifications } from '@/hooks/useChatNotifications';
import { useSettingsStore } from '@/stores/settings.store';
import { hexToRgba } from '@/utils/color';
import PublicNavBar from '@/components/PublicNavBar';
const { useBreakpoint } = Grid;
@ -79,6 +80,8 @@ export default function MediaPublicLayout() {
>
<ChatBarProvider>
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
<PublicNavBar activePath="/gallery" />
{/* Desktop: Show sidebar, Mobile: Hide */}
{!isMobile && <MediaSidebar />}
@ -86,7 +89,7 @@ export default function MediaPublicLayout() {
<main
style={{
marginLeft: mainContentMarginLeft,
minHeight: '100vh',
minHeight: 'calc(100vh - 56px)',
overflowY: 'auto',
paddingBottom: 'calc(48px + env(safe-area-inset-bottom, 0px))', // Space for bottom search bar + iOS safe area
transition: 'margin-left 0.3s ease',

View File

@ -1,126 +1,58 @@
import { useState, useEffect } from 'react';
import { ConfigProvider, Layout, Typography, theme, Space, Grid, Drawer, Button } from 'antd';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { PlayCircleOutlined, LoginOutlined, LogoutOutlined, HeartOutlined, EnvironmentOutlined, CalendarOutlined, MenuOutlined, CloseOutlined, SendOutlined, HomeOutlined, TeamOutlined, AppstoreOutlined } from '@ant-design/icons';
import { useState, useEffect, useMemo } from 'react';
import { ConfigProvider, Layout, theme } from 'antd';
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import AuthModal from '@/components/AuthModal';
import PublicNavBar from '@/components/PublicNavBar';
import NewsletterSignup from '@/components/public/NewsletterSignup';
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, active }: { to: string; icon: React.ReactNode; label: string; active?: boolean }) {
return (
<Link
to={to}
style={{
...navItemStyle,
color: active ? '#fff' : 'rgba(255, 255, 255, 0.85)',
fontWeight: active ? 600 : undefined,
borderBottom: active ? '2px solid #fff' : '2px solid transparent',
paddingBottom: 2,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<span>{label}</span>
</Link>
);
}
function NavExternalLink({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
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>
</a>
);
}
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' || e.key === ' ') { e.preventDefault(); 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>
);
}
const { Content, Footer } = Layout;
export default function PublicLayout() {
const { settings } = useSettingsStore();
const { isAuthenticated, logout, user } = useAuthStore();
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN';
const navigate = useNavigate();
const location = useLocation();
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authModalContext, setAuthModalContext] = useState<'generic' | 'campaign'>('generic');
const [drawerOpen, setDrawerOpen] = useState(false);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
// Donate/payment pages show minimal nav (Home + Logout only)
const isDonateSection = location.pathname.startsWith('/donate') || location.pathname.startsWith('/payment');
// Active route detection for nav highlight
const activeRoute = (() => {
const p = location.pathname;
if (p.startsWith('/campaign')) return '/campaigns';
if (p.startsWith('/map')) return '/map';
if (p.startsWith('/shifts') || p.startsWith('/volunteer')) return '/shifts';
if (p.startsWith('/gallery')) return '/gallery';
if (p.startsWith('/donate') || p.startsWith('/pricing') || p.startsWith('/shop')) return '/donate';
return '';
})();
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
const headerGradient = settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
const orgName = settings?.organizationName ?? 'Changemaker Lite';
const footerText = settings?.footerText ?? 'Powered by Changemaker Lite';
const logoUrl = settings?.organizationLogoUrl;
// Resolve Gancio URL — subdomain in production, direct port in dev
const gancioUrl = (() => {
const host = window.location.hostname;
if (host !== 'localhost' && host.includes('.')) {
const protocol = window.location.protocol;
const baseDomain = host.split('.').slice(-2).join('.');
return `${protocol}//events.${baseDomain}`;
// Build footer links from navConfig (or defaults)
const footerLinks = useMemo(() => {
const items = settings?.navConfig?.items;
if (!items) {
// Legacy fallback: hardcoded links
const links: { label: string; path: string; external?: boolean }[] = [];
if (settings?.enableInfluence !== false) links.push({ label: 'Campaigns', path: '/campaigns' });
if (settings?.enableMap !== false) {
links.push({ label: 'Map', path: '/map' });
links.push({ label: 'Shifts', path: '/shifts' });
}
if (settings?.enableMediaFeatures !== false) links.push({ label: 'Gallery', path: '/gallery' });
if (settings?.enablePayments) links.push({ label: 'Donate', path: '/donate' });
return links;
}
return `http://localhost:8092`;
})();
const featureFlags: Record<string, boolean | undefined> = {
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
};
return items
.filter(item => item.enabled && item.id !== 'home') // Skip home in footer
.filter(item => {
if (!item.featureFlag) return true;
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order)
.map(item => ({ label: item.label, path: item.path, external: item.external }));
}, [settings]);
// Dynamic document title + favicon for public pages
useEffect(() => {
@ -154,82 +86,10 @@ export default function PublicLayout() {
}}
>
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
<Header
style={{
background: headerGradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
height: 56,
borderBottom: 'none',
}}
>
{/* Left: Logo */}
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 10 }}>
{logoUrl && (
<img
src={logoUrl}
alt={orgName}
style={{ maxHeight: 32, objectFit: 'contain' }}
/>
)}
<Typography.Text strong style={{ fontSize: 18, color: '#fff' }}>
{orgName}
</Typography.Text>
</Link>
{/* Right: Navigation */}
{isMobile ? (
<Button
type="text"
icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />}
onClick={() => setDrawerOpen(true)}
aria-label="Open navigation menu"
style={{ padding: '4px 8px' }}
/>
) : (
<Space size={16}>
<NavExternalLink href={`//${window.location.hostname}:4004`} icon={<HomeOutlined />} label="Home" />
{isDonateSection ? (
<></>
) : (
<>
{settings?.enableInfluence !== false && (
<NavLink to="/campaigns" icon={<SendOutlined />} label="Campaigns" active={activeRoute === '/campaigns'} />
)}
{settings?.enableMap !== false && (
<>
<NavLink to="/map" icon={<EnvironmentOutlined />} label="Map" active={activeRoute === '/map'} />
<NavLink to="/shifts" icon={<CalendarOutlined />} label="Shifts" active={activeRoute === '/shifts'} />
</>
)}
{settings?.enableEvents !== false && (
<NavExternalLink href={gancioUrl} icon={<CalendarOutlined />} label="Events" />
)}
{settings?.enableMediaFeatures !== false && (
<NavLink to="/gallery" icon={<PlayCircleOutlined />} label="Gallery" active={activeRoute === '/gallery'} />
)}
{settings?.enablePayments && (
<NavLink to="/donate" icon={<HeartOutlined />} label="Donate" active={activeRoute === '/donate'} />
)}
</>
)}
{isAuthenticated ? (
<>
{isAdmin ? (
<NavLink to="/app" icon={<AppstoreOutlined />} label="Admin" />
) : (
<NavLink to="/volunteer" icon={<TeamOutlined />} label="Volunteer Portal" />
)}
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
</>
) : (
<NavButton onClick={() => { setAuthModalContext('generic'); setAuthModalOpen(true); }} icon={<LoginOutlined />} label="Sign In" />
)}
</Space>
)}
</Header>
<PublicNavBar
showAuth
onSignInClick={() => { setAuthModalContext('generic'); setAuthModalOpen(true); }}
/>
<Content
style={{
maxWidth: 960,
@ -249,155 +109,26 @@ export default function PublicLayout() {
borderTop: '1px solid rgba(255,255,255,0.06)',
}}
>
<NewsletterSignup />
<div>{footerText}</div>
<div style={{ marginTop: 8 }}>
{settings?.enableInfluence !== false && (
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Campaigns</Link>
)}
{settings?.enableMap !== false && (
<>
{' • '}
<Link to="/map" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Map</Link>
{' • '}
<Link to="/shifts" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Shifts</Link>
</>
)}
{settings?.enableEvents !== false && (
<>
{' • '}
<a href={gancioUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Events</a>
</>
)}
{settings?.enableMediaFeatures !== false && (
<>
{' • '}
<Link to="/gallery" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Gallery</Link>
</>
)}
{settings?.enablePayments && (
<>
{' • '}
<Link to="/donate" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Donate</Link>
</>
)}
{footerLinks.map((link, idx) => (
<span key={link.path}>
{idx > 0 && ' \u2022 '}
{link.external ? (
<a href={link.path} target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
{link.label}
</a>
) : (
<Link to={link.path} style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
{link.label}
</Link>
)}
</span>
))}
</div>
</Footer>
{/* Mobile Navigation Drawer */}
<Drawer
title={orgName}
placement="right"
onClose={() => setDrawerOpen(false)}
open={drawerOpen}
width={280}
closeIcon={<CloseOutlined style={{ color: 'rgba(255,255,255,0.85)' }} />}
styles={{
header: { background: colorBgContainer, borderBottom: '1px solid rgba(255,255,255,0.1)' },
body: { background: colorBgBase, padding: '16px 0' },
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<a
href={`//${window.location.hostname}:4004`}
target="_blank"
rel="noopener noreferrer"
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
borderRadius: 4,
}}
>
<HomeOutlined />
<span>Home</span>
</a>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '4px 24px' }} />
{[
{ to: '/campaigns', icon: <SendOutlined />, label: 'Campaigns', show: settings?.enableInfluence !== false },
{ to: '/map', icon: <EnvironmentOutlined />, label: 'Map', show: settings?.enableMap !== false },
{ to: '/shifts', icon: <CalendarOutlined />, label: 'Shifts', show: settings?.enableMap !== false },
{ to: '/gallery', icon: <PlayCircleOutlined />, label: 'Gallery', show: settings?.enableMediaFeatures !== false },
{ to: '/donate', icon: <HeartOutlined />, label: 'Donate', show: !!settings?.enablePayments },
].filter(item => item.show).map(item => (
<Link
key={item.to}
to={item.to}
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: activeRoute === item.to ? '#fff' : 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
fontWeight: activeRoute === item.to ? 600 : 400,
background: activeRoute === item.to ? 'rgba(255,255,255,0.1)' : 'transparent',
borderRadius: 4,
}}
>
{item.icon}
<span>{item.label}</span>
</Link>
))}
{settings?.enableEvents !== false && (
<a
href={gancioUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
borderRadius: 4,
}}
>
<CalendarOutlined />
<span>Events</span>
</a>
)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
{isAuthenticated ? (
<>
<Link
to={isAdmin ? '/app' : '/volunteer'}
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
borderRadius: 4,
}}
>
{isAdmin ? <AppstoreOutlined /> : <TeamOutlined />}
<span>{isAdmin ? 'Admin Panel' : 'Volunteer Portal'}</span>
</Link>
<span
role="button"
tabIndex={0}
onClick={() => { logout(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LogoutOutlined /> <span>Logout</span>
</span>
</>
) : (
<span
role="button"
tabIndex={0}
onClick={() => { setAuthModalContext('generic'); setAuthModalOpen(true); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { setAuthModalContext('generic'); setAuthModalOpen(true); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LoginOutlined /> <span>Sign In</span>
</span>
)}
</div>
</Drawer>
<AuthModal
open={authModalOpen}
onCancel={() => setAuthModalOpen(false)}

View File

@ -0,0 +1,511 @@
import { useState, useEffect, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Typography, Space, Grid, Drawer, Button, Tooltip, message } from 'antd';
import {
HomeOutlined,
SendOutlined,
EnvironmentOutlined,
CalendarOutlined,
ScheduleOutlined,
PlayCircleOutlined,
HeartOutlined,
DollarOutlined,
ShoppingOutlined,
MenuOutlined,
CloseOutlined,
LoginOutlined,
LogoutOutlined,
AppstoreOutlined,
TeamOutlined,
LinkOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SearchOutlined,
UserOutlined,
GlobalOutlined,
BookOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import PublicSearchModal from '@/components/PublicSearchModal';
import { api } from '@/lib/api';
import { resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api';
// Map icon string IDs to Ant Design icon components
const ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
SendOutlined: <SendOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <LinkOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
};
/** Default nav items used when navConfig is null (matches plan's builtin items) */
const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/home', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin' },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Calendar', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
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',
};
/** Resolve external URLs for builtin items (including $token paths) */
function resolveItemUrl(item: NavItem): string {
if (item.path.startsWith('$')) return resolveNavUrl(item.path);
if (item.external && item.path.startsWith('http')) return item.path;
return item.path;
}
/** Merge missing builtin defaults into stored navConfig items and sync icons */
function mergeNavDefaults(stored: NavItem[]): NavItem[] {
const defaultMap = new Map(DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
// Sync icon for existing builtin items so code-level icon changes propagate
const synced = stored.map(item => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map(i => i.id));
const missing = DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
return missing.length > 0 ? [...synced, ...missing] : synced;
}
interface PublicNavBarProps {
activePath?: string;
showAuth?: boolean;
onSignInClick?: () => void;
}
export default function PublicNavBar({ activePath, showAuth = true, onSignInClick }: PublicNavBarProps) {
const { settings } = useSettingsStore();
const { isAuthenticated, logout, user } = useAuthStore();
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN';
const location = useLocation();
const navigate = useNavigate();
const [drawerOpen, setDrawerOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [navCollapsed, setNavCollapsed] = useLocalStorage('public_nav_collapsed', false);
const [profileLoading, setProfileLoading] = useState(false);
const handleSignIn = onSignInClick ?? (() => navigate('/login'));
const handleMyProfile = async () => {
if (profileLoading) return;
setProfileLoading(true);
try {
const { data } = await api.get<{ token: string }>('/auth/me/profile-token');
navigate(`/profile/${data.token}`);
} catch {
message.error('Unable to load profile');
} finally {
setProfileLoading(false);
}
};
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
// Global Ctrl+K / Cmd+K to open search
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setSearchOpen(true);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
/** Animated label that collapses to zero-width when navCollapsed is true */
const NavLabel = ({ label }: { label: string }) => (
<span style={{
display: 'inline-block',
maxWidth: navCollapsed ? 0 : 200,
opacity: navCollapsed ? 0 : 1,
overflow: 'hidden',
transition: 'max-width 0.25s ease, opacity 0.2s ease',
whiteSpace: 'nowrap',
}}>
{label}
</span>
);
const headerGradient = settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
const orgName = settings?.organizationName ?? 'Changemaker Lite';
const logoUrl = settings?.organizationLogoUrl;
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
// Determine active route for nav highlight
const currentActive = activePath ?? (() => {
const p = location.pathname;
if (p === '/home') return '/home';
if (p.startsWith('/campaign')) return '/campaigns';
if (p.startsWith('/map')) return '/map';
if (p.startsWith('/shifts') || p.startsWith('/volunteer')) return '/shifts';
if (p.startsWith('/events')) return '/events';
if (p.startsWith('/gallery')) return '/gallery';
if (p.startsWith('/donate')) return '/donate';
if (p.startsWith('/pricing')) return '/pricing';
if (p.startsWith('/shop')) return '/shop';
return '';
})();
// Feature flag map for filtering
const featureFlags: Record<string, boolean | undefined> = useMemo(() => ({
enableInfluence: settings?.enableInfluence,
enableMap: settings?.enableMap,
enableMediaFeatures: settings?.enableMediaFeatures,
enablePayments: settings?.enablePayments,
enableEvents: settings?.enableEvents,
}), [settings]);
// Get filtered, sorted nav items
const navItems = useMemo(() => {
const items = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
return items
.filter(item => item.enabled)
.filter(item => {
if (!item.featureFlag) return true;
// For payments flag, enablePayments defaults to false (opt-in)
if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true;
// Other flags default to true
return featureFlags[item.featureFlag] !== false;
})
.sort((a, b) => a.order - b.order);
}, [settings?.navConfig, featureFlags]);
const renderDesktopLink = (item: NavItem) => {
const isActive = currentActive === item.path;
const icon = ICON_MAP[item.icon] ?? null;
const linkStyle: React.CSSProperties = {
...navItemStyle,
gap: navCollapsed ? 0 : 6,
color: isActive ? '#fff' : 'rgba(255, 255, 255, 0.85)',
fontWeight: isActive ? 600 : undefined,
borderBottom: isActive ? '2px solid #fff' : '2px solid transparent',
paddingBottom: 2,
};
if (item.external) {
return (
<Tooltip key={item.id} title={navCollapsed ? item.label : ''}>
<a
href={resolveItemUrl(item)}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<NavLabel label={item.label} />
</a>
</Tooltip>
);
}
return (
<Tooltip key={item.id} title={navCollapsed ? item.label : ''}>
<Link
to={item.path}
style={linkStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<NavLabel label={item.label} />
</Link>
</Tooltip>
);
};
const renderMobileLink = (item: NavItem) => {
const isActive = currentActive === item.path;
const icon = ICON_MAP[item.icon] ?? null;
const style: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 24px',
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
textDecoration: 'none',
fontSize: 15,
fontWeight: isActive ? 600 : 400,
background: isActive ? 'rgba(255,255,255,0.1)' : 'transparent',
borderRadius: 4,
};
if (item.external) {
return (
<a
key={item.id}
href={resolveItemUrl(item)}
target="_blank"
rel="noopener noreferrer"
onClick={() => setDrawerOpen(false)}
style={style}
>
{icon}
<span>{item.label}</span>
</a>
);
}
return (
<Link
key={item.id}
to={item.path}
onClick={() => setDrawerOpen(false)}
style={style}
>
{icon}
<span>{item.label}</span>
</Link>
);
};
return (
<>
<div
style={{
background: headerGradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
height: 56,
flexShrink: 0,
}}
>
{/* Left: Logo + Brand */}
<Link to="/home" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 10 }}>
{logoUrl && (
<img
src={logoUrl}
alt={orgName}
style={{ maxHeight: 32, objectFit: 'contain' }}
/>
)}
<Typography.Text strong style={{ fontSize: 18, color: '#fff' }}>
{orgName}
</Typography.Text>
</Link>
{/* Right: Navigation */}
{isMobile ? (
<Button
type="text"
icon={<MenuOutlined style={{ color: '#fff', fontSize: 20 }} />}
onClick={() => setDrawerOpen(true)}
aria-label="Open navigation menu"
style={{ padding: '4px 8px' }}
/>
) : (
<Space size={navCollapsed ? 8 : 16}>
{navItems.map(renderDesktopLink)}
{/* Search button */}
<Tooltip title={navCollapsed ? 'Search (Ctrl+K)' : 'Search'}>
<span
role="button"
tabIndex={0}
onClick={() => setSearchOpen(true)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSearchOpen(true); } }}
style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<SearchOutlined />
<NavLabel label="Search" />
</span>
</Tooltip>
{/* Collapse toggle */}
<Tooltip title={navCollapsed ? 'Expand navigation' : 'Collapse navigation'}>
<span
role="button"
tabIndex={0}
onClick={() => setNavCollapsed(!navCollapsed)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setNavCollapsed(!navCollapsed); } }}
style={{
...navItemStyle,
color: 'rgba(255, 255, 255, 0.5)',
borderLeft: '1px solid rgba(255, 255, 255, 0.2)',
paddingLeft: 12,
marginLeft: 4,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.5)'; }}
>
{navCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
</Tooltip>
{/* Auth buttons: always show Admin/Logout when logged in; show Sign In when not */}
{isAuthenticated ? (
<>
<Tooltip title={navCollapsed ? 'My Profile' : ''}>
<span
role="button"
tabIndex={0}
onClick={handleMyProfile}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleMyProfile(); } }}
style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6, opacity: profileLoading ? 0.5 : 1 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<UserOutlined /><NavLabel label="My Profile" />
</span>
</Tooltip>
{isAdmin ? (
<Tooltip title={navCollapsed ? 'Admin' : ''}>
<Link to="/app" style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<AppstoreOutlined /><NavLabel label="Admin" />
</Link>
</Tooltip>
) : (
<Tooltip title={navCollapsed ? 'Volunteer Portal' : ''}>
<Link to="/volunteer" style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<TeamOutlined /><NavLabel label="Volunteer Portal" />
</Link>
</Tooltip>
)}
<Tooltip title={navCollapsed ? 'Logout' : ''}>
<span
role="button"
tabIndex={0}
onClick={() => logout()}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); logout(); } }}
style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<LogoutOutlined /><NavLabel label="Logout" />
</span>
</Tooltip>
</>
) : showAuth && (
<Tooltip title={navCollapsed ? 'Sign In' : ''}>
<span
role="button"
tabIndex={0}
onClick={handleSignIn}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSignIn(); } }}
style={{ ...navItemStyle, gap: navCollapsed ? 0 : 6 }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
<LoginOutlined /><NavLabel label="Sign In" />
</span>
</Tooltip>
)}
</Space>
)}
</div>
{/* Mobile Navigation Drawer */}
<Drawer
title={orgName}
placement="right"
onClose={() => setDrawerOpen(false)}
open={drawerOpen}
width={280}
closeIcon={<CloseOutlined style={{ color: 'rgba(255,255,255,0.85)' }} />}
styles={{
header: { background: colorBgContainer, borderBottom: '1px solid rgba(255,255,255,0.1)' },
body: { background: colorBgBase, padding: '16px 0' },
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{navItems.map(renderMobileLink)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
{/* Auth buttons: always show Admin/Logout when logged in; show Sign In when not */}
{isAuthenticated ? (
<>
<span
role="button"
tabIndex={0}
onClick={() => { handleMyProfile(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { handleMyProfile(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit', opacity: profileLoading ? 0.5 : 1 }}
>
<UserOutlined /> <span>My Profile</span>
</span>
<Link
to={isAdmin ? '/app' : '/volunteer'}
onClick={() => setDrawerOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 24px',
color: 'rgba(255,255,255,0.85)',
textDecoration: 'none', fontSize: 15,
borderRadius: 4,
}}
>
{isAdmin ? <AppstoreOutlined /> : <TeamOutlined />}
<span>{isAdmin ? 'Admin Panel' : 'Volunteer Portal'}</span>
</Link>
<span
role="button"
tabIndex={0}
onClick={() => { logout(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LogoutOutlined /> <span>Logout</span>
</span>
</>
) : showAuth && (
<span
role="button"
tabIndex={0}
onClick={() => { handleSignIn(); setDrawerOpen(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { handleSignIn(); setDrawerOpen(false); } }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }}
>
<LoginOutlined /> <span>Sign In</span>
</span>
)}
</div>
</Drawer>
<PublicSearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
</>
);
}

View File

@ -0,0 +1,208 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal, Input, Typography, Tag, Empty, Spin, Grid } from 'antd';
import {
SearchOutlined,
SendOutlined,
CalendarOutlined,
ScheduleOutlined,
FileTextOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
const { Text } = Typography;
interface SearchResult {
type: 'campaign' | 'shift' | 'page' | 'video' | 'event';
id: string;
title: string;
description: string | null;
link: string;
}
const TYPE_ICONS: Record<string, React.ReactNode> = {
campaign: <SendOutlined />,
shift: <ScheduleOutlined />,
page: <FileTextOutlined />,
video: <PlayCircleOutlined />,
event: <CalendarOutlined />,
};
const TYPE_COLORS: Record<string, string> = {
campaign: 'blue',
shift: 'green',
page: 'purple',
video: 'magenta',
event: 'orange',
};
interface PublicSearchModalProps {
open: boolean;
onClose: () => void;
}
export default function PublicSearchModal({ open, onClose }: PublicSearchModalProps) {
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const inputRef = useRef<any>(null);
// Focus input on open
useEffect(() => {
if (open) {
setQuery('');
setResults([]);
setActiveIndex(0);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
// Debounced search
useEffect(() => {
clearTimeout(timerRef.current);
if (!query || query.length < 2) {
setResults([]);
return;
}
timerRef.current = setTimeout(async () => {
setLoading(true);
try {
const { data } = await axios.get<SearchResult[]>('/api/search', {
params: { q: query, limit: 10 },
});
setResults(data);
setActiveIndex(0);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(timerRef.current);
}, [query]);
const handleSelect = useCallback((result: SearchResult) => {
onClose();
navigate(result.link);
}, [navigate, onClose]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && results[activeIndex]) {
e.preventDefault();
handleSelect(results[activeIndex]);
}
};
// Global Ctrl+K handler
useEffect(() => {
// Only open from outside — parent manages the `open` state
}, []);
// Group results by type
const grouped = results.reduce((acc, r) => {
if (!acc[r.type]) acc[r.type] = [];
acc[r.type]!.push(r);
return acc;
}, {} as Record<string, SearchResult[]>);
let flatIndex = 0;
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
closable={false}
width={isMobile ? '100%' : 520}
style={{ top: isMobile ? 0 : 80 }}
styles={{ body: { padding: 0 } }}
>
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<Input
ref={inputRef}
prefix={<SearchOutlined style={{ color: 'rgba(255,255,255,0.4)' }} />}
placeholder="Search campaigns, shifts, pages..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
variant="borderless"
size="large"
suffix={loading ? <Spin size="small" /> : <Text type="secondary" style={{ fontSize: 11 }}>ESC</Text>}
/>
</div>
<div style={{ maxHeight: 400, overflowY: 'auto', padding: '8px 0' }}>
{query.length >= 2 && !loading && results.length === 0 && (
<Empty
description={<Text type="secondary">No results found</Text>}
style={{ padding: 32 }}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
{Object.entries(grouped).map(([type, items]) => (
<div key={type}>
<div style={{ padding: '8px 16px 4px', fontSize: 11, textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', letterSpacing: 1 }}>
{type}s
</div>
{items.map((item) => {
const idx = flatIndex++;
return (
<div
key={`${item.type}-${item.id}`}
onClick={() => handleSelect(item)}
onMouseEnter={() => setActiveIndex(idx)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 16px',
cursor: 'pointer',
background: idx === activeIndex ? 'rgba(255,255,255,0.06)' : 'transparent',
transition: 'background 0.1s',
}}
>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 16 }}>
{TYPE_ICONS[item.type]}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.title}
</div>
{item.description && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.description}
</Text>
)}
</div>
<Tag color={TYPE_COLORS[item.type]} style={{ margin: 0, fontSize: 10 }}>
{item.type}
</Tag>
</div>
);
})}
</div>
))}
</div>
{query.length < 2 && (
<div style={{ padding: '16px', textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
Type at least 2 characters to search
</Text>
</div>
)}
</Modal>
);
}

View File

@ -3,17 +3,18 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { theme } from 'antd';
import {
EnvironmentOutlined,
CalendarOutlined,
ScheduleOutlined,
HistoryOutlined,
NodeIndexOutlined,
MessageOutlined,
TeamOutlined,
MenuOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
const BASE_NAV_ITEMS = [
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' },
{ key: '/volunteer/shifts', icon: CalendarOutlined, label: 'Shifts' },
{ key: '/volunteer/shifts', icon: ScheduleOutlined, label: 'Shifts' },
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },
];
@ -32,11 +33,14 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
const NAV_ITEMS = useMemo(() => {
const items = [...BASE_NAV_ITEMS];
if (settings?.enableSocial) {
items.push({ key: '/volunteer/feed', icon: TeamOutlined, label: 'Social' });
}
if (settings?.enableChat) {
items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' });
}
return items;
}, [settings?.enableChat]);
}, [settings?.enableChat, settings?.enableSocial]);
const activeKey = (() => {
const path = location.pathname;

View File

@ -5,6 +5,9 @@ import type { MenuProps } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
import NotificationBell from '@/components/social/NotificationBell';
import { buildHomeUrl } from '@/lib/service-url';
import { useSSE } from '@/hooks/useSSE';
const { Header, Content, Footer } = Layout;
@ -13,6 +16,9 @@ export default function VolunteerLayout() {
const { user, logout } = useAuthStore();
const { settings } = useSettingsStore();
// Initialize SSE connection for real-time notifications + online presence
useSSE();
const orgName = settings?.organizationName ?? 'Changemaker Lite';
const handleLogout = async () => {
@ -21,7 +27,7 @@ export default function VolunteerLayout() {
};
const userMenuItems: MenuProps['items'] = [
{ key: 'home', icon: <HomeOutlined />, label: 'Home', onClick: () => window.open(`//${window.location.hostname}:4004`, '_blank') },
{ key: 'home', icon: <HomeOutlined />, label: 'Home', onClick: () => window.open(buildHomeUrl(), '_blank') },
{ key: 'browse', icon: <GlobalOutlined />, label: 'Browse Site', onClick: () => navigate('/campaigns') },
{ type: 'divider' },
{ key: 'logout', icon: <LogoutOutlined />, label: 'Logout', onClick: handleLogout },
@ -56,6 +62,7 @@ export default function VolunteerLayout() {
<Typography.Text strong style={{ fontSize: 16, color: '#fff', flex: 1 }}>
{orgName}
</Typography.Text>
{settings?.enableSocial && <NotificationBell />}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" size="small" icon={<UserOutlined style={{ color: '#fff' }} />}>
<Typography.Text style={{ marginLeft: 4, color: '#fff', fontSize: 13 }}>

View File

@ -0,0 +1,236 @@
import { useState } from 'react';
import {
Card,
Form,
Input,
Button,
DatePicker,
TimePicker,
Select,
Result,
Switch,
Typography,
message,
} from 'antd';
import { PlusOutlined, CheckCircleOutlined, VideoCameraOutlined, CopyOutlined } from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
const { TextArea } = Input;
const { Text } = Typography;
interface EventSubmissionFormProps {
/** Pre-fill the date picker with this date (YYYY-MM-DD) */
initialDate?: string;
onSuccess?: () => void;
gancioUrl?: string;
/** User is authenticated and non-TEMP */
canCreateMeeting?: boolean;
/** Site setting: enableMeet */
meetEnabled?: boolean;
}
export default function EventSubmissionForm({ initialDate, onSuccess, gancioUrl, canCreateMeeting, meetEnabled }: EventSubmissionFormProps) {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [addVideo, setAddVideo] = useState(false);
const [createdMeetingUrl, setCreatedMeetingUrl] = useState<string | null>(null);
const handleSubmit = async (values: any) => {
setSubmitting(true);
try {
let description = values.description || '';
let tags: string[] = values.tags || [];
// Step 1: Create Jitsi meeting if toggle is on
if (addVideo) {
const dateStr = values.date.format('YYYY-MM-DD');
const startISO = dayjs(`${dateStr}T${values.startTime.format('HH:mm')}`).toISOString();
const endISO = dayjs(`${dateStr}T${values.endTime.format('HH:mm')}`).toISOString();
const { data: meeting } = await api.post('/jitsi/meetings', {
title: values.title,
startTime: startISO,
endTime: endISO,
});
const meetingUrl = `${window.location.origin}/meet/${meeting.slug}`;
description = description
? `${description}\n\n---\nJoin Video Meeting: ${meetingUrl}`
: `Join Video Meeting: ${meetingUrl}`;
if (!tags.includes('video-meeting')) {
tags = [...tags, 'video-meeting'];
}
setCreatedMeetingUrl(meetingUrl);
}
// Step 2: Submit event to Gancio
await axios.post('/api/events/submit', {
title: values.title,
description: description || undefined,
date: values.date.format('YYYY-MM-DD'),
startTime: values.startTime.format('HH:mm'),
endTime: values.endTime.format('HH:mm'),
location: values.location || undefined,
tags: tags.length > 0 ? tags : undefined,
});
setSuccess(true);
form.resetFields();
onSuccess?.();
} catch (err: any) {
const msg = err.response?.data?.error?.message || err.response?.data?.error || 'Failed to submit event';
message.error(msg);
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setSuccess(false);
setAddVideo(false);
setCreatedMeetingUrl(null);
form.resetFields();
};
if (success) {
return (
<Card size="small" style={{ borderLeft: '4px solid #52c41a', marginBottom: 8 }}>
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 32 }} />}
title="Event Submitted!"
subTitle="Your event has been added to the community calendar."
style={{ padding: '12px 0' }}
extra={
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
{createdMeetingUrl && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'rgba(82,196,26,0.1)', borderRadius: 6 }}>
<VideoCameraOutlined style={{ color: '#52c41a' }} />
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.75)' }} copyable={{ text: createdMeetingUrl, icon: <CopyOutlined style={{ fontSize: 12 }} /> }}>
{createdMeetingUrl}
</Text>
</div>
)}
{gancioUrl && (
<Button type="link" size="small" href={gancioUrl} target="_blank" rel="noopener noreferrer">
View on Community Calendar
</Button>
)}
<Button size="small" onClick={handleReset}>
Submit Another
</Button>
</div>
}
/>
</Card>
);
}
const defaultDate = initialDate ? dayjs(initialDate) : dayjs().add(1, 'day');
return (
<Card
size="small"
title={
<span style={{ fontSize: 13 }}>
<PlusOutlined style={{ marginRight: 6 }} />
Submit a Community Event
</span>
}
style={{ borderLeft: '4px solid #52c41a', marginBottom: 8 }}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size="small"
initialValues={{
date: defaultDate,
startTime: dayjs().hour(18).minute(0),
endTime: dayjs().hour(20).minute(0),
}}
>
<Form.Item
name="title"
label="Event Title"
rules={[{ required: true, message: 'Title is required' }]}
style={{ marginBottom: 8 }}
>
<Input placeholder="Community Town Hall" maxLength={200} />
</Form.Item>
<Form.Item name="description" label="Description" style={{ marginBottom: 8 }}>
<TextArea
placeholder="Tell people what this event is about..."
rows={2}
maxLength={2000}
showCount
/>
</Form.Item>
<Form.Item
name="date"
label="Date"
rules={[{ required: true, message: 'Date is required' }]}
style={{ marginBottom: 8 }}
>
<DatePicker
style={{ width: '100%' }}
disabledDate={(d) => d.isBefore(dayjs(), 'day')}
/>
</Form.Item>
<div style={{ display: 'flex', gap: 8 }}>
<Form.Item
name="startTime"
label="Start"
rules={[{ required: true, message: 'Required' }]}
style={{ flex: 1, marginBottom: 8 }}
>
<TimePicker format="HH:mm" minuteStep={5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="endTime"
label="End"
rules={[{ required: true, message: 'Required' }]}
style={{ flex: 1, marginBottom: 8 }}
>
<TimePicker format="HH:mm" minuteStep={5} style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name="location" label="Location" style={{ marginBottom: 8 }}>
<Input placeholder="City Hall, 1 Sir Winston Churchill Square" maxLength={500} />
</Form.Item>
<Form.Item name="tags" label="Tags" style={{ marginBottom: 12 }}>
<Select
mode="tags"
placeholder="Add tags (e.g. meeting, workshop)"
maxCount={10}
style={{ width: '100%' }}
/>
</Form.Item>
{canCreateMeeting && meetEnabled && (
<Form.Item style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch checked={addVideo} onChange={setAddVideo} size="small" />
<Text type="secondary" style={{ fontSize: 13 }}>
<VideoCameraOutlined style={{ marginRight: 4 }} />
Add Video Meeting
</Text>
</div>
</Form.Item>
)}
<div style={{ textAlign: 'right' }}>
<Button type="primary" htmlType="submit" loading={submitting} icon={<PlusOutlined />}>
Submit Event
</Button>
</div>
</Form>
</Card>
);
}

View File

@ -0,0 +1,431 @@
import { useState, useEffect, useCallback, type MutableRefObject } from 'react';
import {
Calendar,
Card,
Typography,
Space,
Tag,
Button,
Progress,
Empty,
Spin,
Grid,
Tooltip,
} from 'antd';
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
TeamOutlined,
DownloadOutlined,
LinkOutlined,
CloseOutlined,
ClearOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs, { Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
import EventSubmissionForm from './EventSubmissionForm';
import type { UnifiedCalendarItem, UnifiedCalendarResponse } from '@/types/api';
dayjs.extend(utc);
const { Text } = Typography;
interface UnifiedCalendarProps {
onShiftSignup?: (item: UnifiedCalendarItem) => void;
gancioUrl?: string;
onEventSubmitted?: () => void;
/** Ref that receives a function to open the panel with an empty date (for "Submit Event" button) */
onAddEvent?: MutableRefObject<(() => void) | null>;
/** User is authenticated and non-TEMP */
canCreateMeeting?: boolean;
/** Site setting: enableMeet */
meetEnabled?: boolean;
}
/** Generate a .ics file and trigger download */
function downloadIcs(item: UnifiedCalendarItem) {
const startDt = dayjs(`${item.date}T${item.startTime}`);
const endDt = dayjs(`${item.date}T${item.endTime}`);
const fmt = (d: Dayjs) => d.utc().format('YYYYMMDDTHHmmss') + 'Z';
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Changemaker Lite//Calendar//EN',
'BEGIN:VEVENT',
`DTSTART:${fmt(startDt)}`,
`DTEND:${fmt(endDt)}`,
`SUMMARY:${item.title.replace(/[,;\\]/g, ' ')}`,
`LOCATION:${(item.location || '').replace(/[,;\\]/g, ' ')}`,
`UID:${item.id}@changemaker`,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${item.title.replace(/[^a-zA-Z0-9]/g, '_').slice(0, 40)}.ics`;
a.click();
URL.revokeObjectURL(url);
}
export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmitted, onAddEvent, canCreateMeeting, meetEnabled }: UnifiedCalendarProps) {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [calendarData, setCalendarData] = useState<UnifiedCalendarResponse | null>(null);
const [loading, setLoading] = useState(true);
const [openDates, setOpenDates] = useState<string[]>([]);
const [currentMonth, setCurrentMonth] = useState(dayjs());
// Expose a function for the parent "Submit Event" button to open the panel with tomorrow's date
useEffect(() => {
if (onAddEvent) {
onAddEvent.current = () => {
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD');
setOpenDates(prev =>
prev.includes(tomorrow) ? prev : [...prev, tomorrow].sort(),
);
};
}
return () => { if (onAddEvent) onAddEvent.current = null; };
}, [onAddEvent]);
const fetchCalendar = useCallback(async (month: Dayjs) => {
setLoading(true);
const startDate = month.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = month.endOf('month').add(7, 'day').format('YYYY-MM-DD');
try {
const { data } = await axios.get<UnifiedCalendarResponse>('/api/events/calendar', {
params: { startDate, endDate },
});
setCalendarData(data);
} catch {
setCalendarData({ dates: {} });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCalendar(currentMonth);
}, [currentMonth, fetchCalendar]);
const getDateItems = (dateKey: string): UnifiedCalendarItem[] => {
return calendarData?.dates[dateKey]?.items || [];
};
// Toggle a date in the open list
const handleSelect = (date: Dayjs) => {
const key = date.format('YYYY-MM-DD');
setOpenDates(prev =>
prev.includes(key)
? prev.filter(d => d !== key)
: [...prev, key].sort(),
);
};
const removeDate = (dateKey: string) => {
setOpenDates(prev => prev.filter(d => d !== dateKey));
};
const handlePanelChange = (date: Dayjs) => {
setCurrentMonth(date);
};
const MAX_CELL_ITEMS = 3;
// Cell renderer showing colorful mini-cards
const cellRender = (date: Dayjs) => {
const dateKey = date.format('YYYY-MM-DD');
const items = getDateItems(dateKey);
if (items.length === 0) return null;
const visible = items.slice(0, MAX_CELL_ITEMS);
const overflow = items.length - MAX_CELL_ITEMS;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '0 2px' }}>
{visible.map(item => {
const isShift = item.type === 'shift';
const bg = isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
const border = isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
const accent = isShift ? '#1890ff' : '#52c41a';
return (
<div
key={item.id}
style={{
background: bg,
border: `1px solid ${border}`,
borderLeft: `3px solid ${accent}`,
borderRadius: 4,
padding: '2px 5px',
fontSize: 11,
lineHeight: '15px',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
color: 'rgba(255,255,255,0.85)',
}}
>
<span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}>
{item.startTime}
</span>
{item.tags?.includes('video-meeting') && (
<VideoCameraOutlined style={{ fontSize: 9, marginRight: 3, color: 'rgba(255,255,255,0.5)' }} />
)}
{item.title}
</div>
);
})}
{overflow > 0 && (
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)', textAlign: 'center' }}>
+{overflow} more
</div>
)}
</div>
);
};
const renderItemCard = (item: UnifiedCalendarItem) => {
const isShift = item.type === 'shift';
const spotsLeft = isShift && item.maxVolunteers
? item.maxVolunteers - (item.currentVolunteers || 0)
: null;
const isFull = spotsLeft !== null && spotsLeft <= 0;
const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0
? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100)
: 0;
return (
<Card
key={item.id}
size="small"
style={{
borderLeft: `4px solid ${isShift ? '#1890ff' : '#52c41a'}`,
marginBottom: 8,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 4 }}>
<Text strong style={{ color: '#fff', fontSize: 14 }}>
{item.title}
{item.tags?.includes('video-meeting') && (
<Tooltip title="Video Meeting">
<VideoCameraOutlined style={{ marginLeft: 6, fontSize: 13, color: '#52c41a' }} />
</Tooltip>
)}
</Text>
<Tag color={isShift ? 'blue' : 'green'} style={{ margin: 0, fontSize: 11 }}>
{isShift ? 'Shift' : 'Event'}
</Tag>
</div>
<Space size={16} wrap style={{ marginBottom: 8 }}>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{item.startTime} {item.endTime}
</Text>
{item.location && (
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>
<EnvironmentOutlined style={{ marginRight: 4 }} />
{item.location}
</Text>
)}
</Space>
{item.tags.length > 0 && (
<div style={{ marginBottom: 8 }}>
{item.tags.map(tag => (
<Tag key={tag} style={{ margin: '0 4px 4px 0', fontSize: 11 }}>{tag}</Tag>
))}
</div>
)}
{/* Shift-specific: capacity bar */}
{isShift && item.maxVolunteers != null && (
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
<TeamOutlined style={{ marginRight: 4 }} />
Volunteers
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.currentVolunteers || 0}/{item.maxVolunteers}
</Text>
</div>
<Progress percent={pct} size="small" status={isFull ? 'exception' : 'active'} showInfo={false} />
</div>
)}
<Space size={8} wrap>
<Tooltip title="Add to Calendar">
<Button
type="text"
size="small"
icon={<DownloadOutlined />}
onClick={() => downloadIcs(item)}
>
.ics
</Button>
</Tooltip>
{isShift && !isFull && item.shiftId && (
<Button
type="primary"
size="small"
icon={<TeamOutlined />}
onClick={() => onShiftSignup?.(item)}
>
Sign Up
</Button>
)}
{!isShift && item.gancioUrl && (
<Button
type="link"
size="small"
icon={<LinkOutlined />}
href={item.gancioUrl}
target="_blank"
rel="noopener noreferrer"
>
View
</Button>
)}
</Space>
</Card>
);
};
const handleEventSubmitted = () => {
fetchCalendar(currentMonth);
onEventSubmitted?.();
};
// Render a date section in the side panel
const renderDateSection = (dateKey: string) => {
const items = getDateItems(dateKey);
const isPast = dateKey < dayjs().format('YYYY-MM-DD');
return (
<div key={dateKey} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{ color: '#fff', fontSize: 14 }}>
<CalendarOutlined style={{ marginRight: 6 }} />
{dayjs(dateKey).format('dddd, MMMM D')}
</Text>
<Button
type="text"
size="small"
icon={<CloseOutlined />}
onClick={() => removeDate(dateKey)}
style={{ color: 'rgba(255,255,255,0.45)' }}
/>
</div>
{items.length > 0 && items.map(renderItemCard)}
{items.length === 0 && !isPast ? (
<EventSubmissionForm
initialDate={dateKey}
gancioUrl={gancioUrl}
onSuccess={handleEventSubmitted}
canCreateMeeting={canCreateMeeting}
meetEnabled={meetEnabled}
/>
) : items.length === 0 && isPast ? (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>
Nothing was scheduled
</Text>
) : null}
</div>
);
};
// Mobile: date-grouped list fallback
if (isMobile) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
);
}
const sortedDates = Object.keys(calendarData?.dates || {}).sort();
const upcomingDates = sortedDates.filter(d => d >= dayjs().format('YYYY-MM-DD'));
if (upcomingDates.length === 0) {
return <Empty description="No upcoming events or shifts" style={{ padding: 40 }} />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{upcomingDates.map(dateKey => {
const items = getDateItems(dateKey);
return (
<div key={dateKey}>
<Text strong style={{ color: '#fff', fontSize: 14, display: 'block', marginBottom: 8 }}>
<CalendarOutlined style={{ marginRight: 6 }} />
{dayjs(dateKey).format('dddd, MMMM D')}
</Text>
{items.map(renderItemCard)}
</div>
);
})}
</div>
);
}
const panelVisible = openDates.length > 0;
// Desktop: Calendar + inline side panel
return (
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
<div style={{ flex: 1, minWidth: 0 }}>
{loading && (
<div style={{ textAlign: 'center', padding: 20 }}>
<Spin />
</div>
)}
<Calendar
fullscreen
cellRender={(date) => cellRender(date)}
onSelect={handleSelect}
onPanelChange={handlePanelChange}
/>
</div>
{/* Inline side panel — pushes calendar over, accumulates selected dates */}
{panelVisible && (
<div style={{ width: 380, flexShrink: 0 }}>
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#fff' }}>
<CalendarOutlined style={{ marginRight: 8 }} />
{openDates.length === 1 ? dayjs(openDates[0]).format('ddd, MMM D') : `${openDates.length} Dates`}
</span>
<Button
type="text"
size="small"
icon={<ClearOutlined />}
onClick={() => setOpenDates([])}
style={{ color: 'rgba(255,255,255,0.45)' }}
>
Clear
</Button>
</div>
}
styles={{ body: { maxHeight: 'calc(100vh - 340px)', overflowY: 'auto', padding: '12px 16px' } }}
>
{openDates.map(renderDateSection)}
</Card>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,210 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { Spin, Typography, theme } from 'antd';
import {
CloseOutlined,
MinusOutlined,
ExpandOutlined,
LinkOutlined,
} from '@ant-design/icons';
import {
useChatWidgetStore,
CHANNELS,
PANEL_EXPANDED_WIDTH,
PANEL_EXPANDED_HEIGHT,
PANEL_MINIMIZED_WIDTH,
PANEL_TITLE_HEIGHT,
type ChatPanel as ChatPanelType,
} from '@/stores/chat-widget.store';
const { Text } = Typography;
interface Props {
panel: ChatPanelType;
leftOffset: number;
}
export default function ChatPanel({ panel, leftOffset }: Props) {
const { token } = theme.useToken();
const rcServiceUrl = useChatWidgetStore((s) => s.rcServiceUrl);
const rcAuthToken = useChatWidgetStore((s) => s.rcAuthToken);
const rcEnabled = useChatWidgetStore((s) => s.rcEnabled);
const rcOnline = useChatWidgetStore((s) => s.rcOnline);
const closePanel = useChatWidgetStore((s) => s.closePanel);
const minimizePanel = useChatWidgetStore((s) => s.minimizePanel);
const expandPanel = useChatWidgetStore((s) => s.expandPanel);
const [iframeReady, setIframeReady] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const retryTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
const channelLabel =
CHANNELS.find((c) => c.value === panel.channel)?.label ?? panel.channel;
// iframe auth via postMessage with retry
const handleIframeLoad = useCallback(() => {
retryTimers.current.forEach(clearTimeout);
retryTimers.current = [];
if (!rcAuthToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
if (!iframeRef.current?.contentWindow) return;
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: rcAuthToken },
'*',
);
};
sendToken();
retryTimers.current.push(setTimeout(sendToken, 1000));
retryTimers.current.push(setTimeout(sendToken, 3000));
setIframeReady(true);
}, [rcAuthToken]);
useEffect(() => {
return () => retryTimers.current.forEach(clearTimeout);
}, []);
const handlePopOut = () => {
if (rcServiceUrl) window.open(`${rcServiceUrl}/channel/${panel.channel}`, '_blank');
};
const width = panel.isMinimized ? PANEL_MINIMIZED_WIDTH : PANEL_EXPANDED_WIDTH;
const height = panel.isMinimized ? PANEL_TITLE_HEIGHT : PANEL_EXPANDED_HEIGHT;
// Determine content state
const isLoading = rcEnabled === null;
const isNotEnabled = rcEnabled === false;
const isOffline = rcEnabled && rcOnline === false;
const isReady = rcServiceUrl && rcAuthToken && rcEnabled && rcOnline;
const renderContent = () => {
if (isNotEnabled) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16, textAlign: 'center' }}>
<Text type="secondary">Chat not enabled</Text>
</div>
);
}
if (isOffline) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16, textAlign: 'center' }}>
<Text type="secondary">Chat offline</Text>
</div>
);
}
if (isLoading || !isReady) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin />
</div>
);
}
return (
<>
{!iframeReady && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin />
</div>
)}
<iframe
ref={iframeRef}
src={`${rcServiceUrl}/channel/${panel.channel}?layout=embedded`}
onLoad={handleIframeLoad}
style={{
flex: 1,
width: '100%',
border: 'none',
display: iframeReady ? 'block' : 'none',
}}
title={`Chat: #${channelLabel}`}
allow="microphone; camera"
/>
</>
);
};
return (
<div
style={{
position: 'fixed',
bottom: 0,
left: leftOffset,
zIndex: 1050,
width,
height,
display: 'flex',
flexDirection: 'column',
background: token.colorBgContainer,
borderRadius: '12px 12px 0 0',
border: `1px solid ${token.colorBorder}`,
borderBottom: 'none',
overflow: 'hidden',
transition: 'width 0.25s ease, height 0.25s ease, left 0.25s ease',
boxShadow: '0 -4px 20px rgba(0,0,0,0.3)',
}}
>
{/* Title bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 12px',
background: token.colorBgElevated,
borderBottom: panel.isMinimized ? 'none' : `1px solid ${token.colorBorder}`,
cursor: 'pointer',
userSelect: 'none',
minHeight: PANEL_TITLE_HEIGHT,
flexShrink: 0,
}}
onClick={() =>
panel.isMinimized
? expandPanel(panel.id)
: minimizePanel(panel.id)
}
>
<Text
strong
ellipsis
style={{
flex: 1,
minWidth: 0,
fontSize: 13,
pointerEvents: 'none',
}}
>
#{channelLabel}
</Text>
<div
style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}
onClick={(e) => e.stopPropagation()}
>
{panel.isMinimized ? (
<ExpandOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={() => expandPanel(panel.id)}
/>
) : (
<MinusOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={() => minimizePanel(panel.id)}
/>
)}
<LinkOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={handlePopOut}
/>
<CloseOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={() => closePanel(panel.id)}
/>
</div>
</div>
{/* Content area — only when expanded */}
{!panel.isMinimized && renderContent()}
</div>
);
}

View File

@ -0,0 +1,115 @@
import { useEffect, useCallback, useRef } from 'react';
import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url';
import {
useChatWidgetStore,
FAB_LEFT,
FAB_SIZE,
PANEL_GAP,
PANEL_EXPANDED_WIDTH,
PANEL_MINIMIZED_WIDTH,
} from '@/stores/chat-widget.store';
import ChatPanel from './ChatPanel';
interface RCConfig {
enabled: boolean;
embedPort: number;
subdomain: string;
domain: string;
}
interface RCAuthResponse {
authToken: string;
rcUserId: string;
}
/**
* Container that:
* 1. Fetches RC auth once (shared across all panels)
* 2. Computes left offsets and renders <ChatPanel> instances
*/
export default function ChatPanelTray() {
const panels = useChatWidgetStore((s) => s.panels);
const rcServiceUrl = useChatWidgetStore((s) => s.rcServiceUrl);
const setRCState = useChatWidgetStore((s) => s.setRCState);
const initAttempted = useRef(false);
const initRC = useCallback(async () => {
if (initAttempted.current) return;
initAttempted.current = true;
try {
const [statusRes, rcConfigRes] = await Promise.all([
api.get<{ online: boolean; enabled: boolean }>('/rocketchat/status'),
api.get<RCConfig>('/rocketchat/config'),
]);
if (!rcConfigRes.data.enabled) {
setRCState({
serviceUrl: '',
authToken: '',
enabled: false,
online: false,
});
return;
}
if (!statusRes.data.online) {
setRCState({
serviceUrl: '',
authToken: '',
enabled: true,
online: false,
});
return;
}
const authRes = await api.post<RCAuthResponse>('/rocketchat/auth');
const serviceUrl = buildServiceUrl(
rcConfigRes.data.subdomain,
rcConfigRes.data.domain,
rcConfigRes.data.embedPort,
);
setRCState({
serviceUrl,
authToken: authRes.data.authToken,
enabled: rcConfigRes.data.enabled,
online: statusRes.data.online,
});
} catch {
// Auth failure — panels will show loading spinner
setRCState({
serviceUrl: '',
authToken: '',
enabled: true,
online: false,
});
}
}, [setRCState]);
// Initialize RC when first panel opens and we don't have state yet
useEffect(() => {
if (panels.length > 0 && !rcServiceUrl) {
initRC();
}
}, [panels.length, rcServiceUrl, initRC]);
// Compute left offsets: FAB_LEFT + FAB_SIZE + GAP, then accumulate
const TRAY_START = FAB_LEFT + FAB_SIZE + PANEL_GAP;
let cursor = TRAY_START;
const offsets: number[] = [];
for (const panel of panels) {
offsets.push(cursor);
const w = panel.isMinimized ? PANEL_MINIMIZED_WIDTH : PANEL_EXPANDED_WIDTH;
cursor += w + PANEL_GAP;
}
return (
<>
{panels.map((panel, i) => (
<ChatPanel key={panel.id} panel={panel} leftOffset={offsets[i] ?? 0} />
))}
</>
);
}

View File

@ -0,0 +1,170 @@
import { useEffect, useRef } from 'react';
import { Badge, Button, Typography, theme } from 'antd';
import { MessageOutlined, CloseOutlined, CheckOutlined } from '@ant-design/icons';
import {
useChatWidgetStore,
CHANNELS,
MAX_PANELS,
FAB_LEFT,
FAB_SIZE,
} from '@/stores/chat-widget.store';
const { Text } = Typography;
/** Bottom of FAB from viewport bottom */
const FAB_BOTTOM = 24;
/** Gap between FAB and the picker panel */
const PICKER_GAP = 8;
/** Panel width */
const PICKER_WIDTH = 200;
export default function ChatWidgetFAB() {
const { token } = theme.useToken();
const panels = useChatWidgetStore((s) => s.panels);
const pickerOpen = useChatWidgetStore((s) => s.pickerOpen);
const togglePicker = useChatWidgetStore((s) => s.togglePicker);
const closePicker = useChatWidgetStore((s) => s.closePicker);
const openChannel = useChatWidgetStore((s) => s.openChannel);
const panelRef = useRef<HTMLDivElement>(null);
const fabRef = useRef<HTMLButtonElement>(null);
const openChannels = new Set(panels.map((p) => p.channel));
const isFull = panels.length >= MAX_PANELS;
const handleSelect = (channel: string) => {
openChannel(channel);
closePicker();
};
// Close on click-outside
useEffect(() => {
if (!pickerOpen) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (
panelRef.current?.contains(target) ||
fabRef.current?.contains(target)
)
return;
closePicker();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [pickerOpen, closePicker]);
// Close on Escape
useEffect(() => {
if (!pickerOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closePicker();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [pickerOpen, closePicker]);
// Picker panel bottom: align so it sits just above the FAB
const pickerBottom = FAB_BOTTOM + FAB_SIZE + PICKER_GAP;
return (
<>
{/* ── Channel picker panel ─────────────────────── */}
<div
ref={panelRef}
style={{
position: 'fixed',
bottom: pickerBottom,
left: FAB_LEFT,
width: PICKER_WIDTH,
zIndex: 1051,
background: token.colorBgElevated,
borderRadius: token.borderRadiusLG,
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
overflow: 'hidden',
// animation: scale + slide up from FAB origin
transformOrigin: 'bottom left',
transform: pickerOpen ? 'scale(1) translateY(0)' : 'scale(0.75) translateY(12px)',
opacity: pickerOpen ? 1 : 0,
pointerEvents: pickerOpen ? 'auto' : 'none',
transition: 'transform 0.2s cubic-bezier(0.34,1.56,0.64,1), opacity 0.15s ease',
}}
>
{/* Header */}
<div
style={{
padding: '10px 14px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Text strong style={{ fontSize: 13 }}>Open Channel</Text>
<CloseOutlined
style={{ fontSize: 12, color: token.colorTextTertiary, cursor: 'pointer' }}
onClick={closePicker}
/>
</div>
{/* Channel list */}
<div style={{ padding: '6px 0' }}>
{CHANNELS.map((ch) => {
const isOpen = openChannels.has(ch.value);
const disabled = !isOpen && isFull;
return (
<div
key={ch.value}
onClick={() => !disabled && handleSelect(ch.value)}
style={{
padding: '8px 14px',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.4 : 1,
display: 'flex',
alignItems: 'center',
gap: 8,
background: isOpen ? token.colorBgTextHover : undefined,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
if (!disabled && !isOpen)
(e.currentTarget as HTMLDivElement).style.background = token.colorBgTextHover;
}}
onMouseLeave={(e) => {
if (!isOpen)
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
}}
>
<Text style={{ flex: 1, fontSize: 13 }}>#{ch.label}</Text>
{isOpen && (
<CheckOutlined style={{ color: token.colorSuccess, fontSize: 12 }} />
)}
</div>
);
})}
</div>
</div>
{/* ── FAB button ───────────────────────────────── */}
<Badge count={panels.length} size="small" offset={[-4, 4]} style={{ position: 'fixed', bottom: FAB_BOTTOM, left: FAB_LEFT, zIndex: 1050 }}>
<Button
ref={fabRef}
type="primary"
shape="circle"
icon={pickerOpen ? <CloseOutlined /> : <MessageOutlined />}
onClick={togglePicker}
style={{
position: 'fixed',
bottom: FAB_BOTTOM,
left: FAB_LEFT,
zIndex: 1050,
width: FAB_SIZE,
height: FAB_SIZE,
fontSize: 20,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
transition: 'transform 0.2s ease',
transform: pickerOpen ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
</Badge>
</>
);
}

View File

@ -0,0 +1,26 @@
import { Grid } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useChatWidgetStore } from '@/stores/chat-widget.store';
import ChatWidgetFAB from './ChatWidgetFAB';
import ChatPanelTray from './ChatPanelTray';
const { useBreakpoint } = Grid;
export default function RocketChatWidget() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const enableChat = useSettingsStore((s) => s.settings?.enableChat);
const panelCount = useChatWidgetStore((s) => s.panels.length);
const screens = useBreakpoint();
const isMobile = !screens.md;
// Gate: hide on mobile, when not authenticated, or when chat is disabled
if (isMobile || !isAuthenticated || !enableChat) return null;
return (
<>
<ChatWidgetFAB />
{panelCount > 0 && <ChatPanelTray />}
</>
);
}

View File

@ -0,0 +1,510 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Modal, Typography, Grid, Spin, theme, type GlobalToken } from 'antd';
import {
SearchOutlined,
DashboardOutlined,
SendOutlined,
IdcardOutlined,
MailOutlined,
MessageOutlined,
EnvironmentOutlined,
TeamOutlined,
SettingOutlined,
CalendarOutlined,
ScheduleOutlined,
FileTextOutlined,
BookOutlined,
GlobalOutlined,
CodeOutlined,
DatabaseOutlined,
ApiOutlined,
BranchesOutlined,
CloudServerOutlined,
QrcodeOutlined,
PlaySquareOutlined,
FolderOutlined,
HistoryOutlined,
LineChartOutlined,
BarChartOutlined,
EditOutlined,
OrderedListOutlined,
DollarOutlined,
ShoppingOutlined,
HeartOutlined,
CrownOutlined,
PictureOutlined,
LockOutlined,
PhoneOutlined,
TagOutlined,
ThunderboltOutlined,
UserOutlined,
FileMarkdownOutlined,
} from '@ant-design/icons';
import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useAuthStore } from '@/stores/auth.store';
import { useCommandIndex } from './useCommandIndex';
import { useEntitySearch } from './useEntitySearch';
import type { CommandItem, EntityResult, CommandCategory } from './types';
const { Text } = Typography;
const { useBreakpoint } = Grid;
/** Map icon string IDs to Ant Design components */
const ICON_MAP: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
SendOutlined: <SendOutlined />,
IdcardOutlined: <IdcardOutlined />,
MailOutlined: <MailOutlined />,
MessageOutlined: <MessageOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
TeamOutlined: <TeamOutlined />,
SettingOutlined: <SettingOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
FileTextOutlined: <FileTextOutlined />,
BookOutlined: <BookOutlined />,
GlobalOutlined: <GlobalOutlined />,
CodeOutlined: <CodeOutlined />,
DatabaseOutlined: <DatabaseOutlined />,
ApiOutlined: <ApiOutlined />,
BranchesOutlined: <BranchesOutlined />,
CloudServerOutlined: <CloudServerOutlined />,
QrcodeOutlined: <QrcodeOutlined />,
PlaySquareOutlined: <PlaySquareOutlined />,
FolderOutlined: <FolderOutlined />,
HistoryOutlined: <HistoryOutlined />,
LineChartOutlined: <LineChartOutlined />,
BarChartOutlined: <BarChartOutlined />,
EditOutlined: <EditOutlined />,
OrderedListOutlined: <OrderedListOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
HeartOutlined: <HeartOutlined />,
CrownOutlined: <CrownOutlined />,
PictureOutlined: <PictureOutlined />,
LockOutlined: <LockOutlined />,
PhoneOutlined: <PhoneOutlined />,
TagOutlined: <TagOutlined />,
FileMarkdownOutlined: <FileMarkdownOutlined />,
UserOutlined: <UserOutlined />,
};
const CATEGORY_LABELS: Record<CommandCategory, string> = {
navigation: 'Pages',
settings: 'Settings',
action: 'Actions',
};
/** A unified item type for the flattened result list */
type FlatItem =
| { type: 'command'; item: CommandItem }
| { type: 'entity'; item: EntityResult };
export default function CommandPalette() {
const { isOpen, close, recentItems, addRecent } = useCommandPaletteStore();
const { isAuthenticated } = useAuthStore();
const location = useLocation();
const navigate = useNavigate();
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { search, allItems } = useCommandIndex();
const { results: entityResults, loading: entityLoading } = useEntitySearch(query);
// Only render inside admin panel
const isAdminRoute = location.pathname.startsWith('/app');
const shouldRender = isAuthenticated && isAdminRoute;
// Global Cmd+K / Ctrl+K listener
useEffect(() => {
if (!shouldRender) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
useCommandPaletteStore.getState().toggle();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [shouldRender]);
// Reset state when opening
useEffect(() => {
if (isOpen) {
setQuery('');
setSelectedIndex(0);
}
}, [isOpen]);
// Focus input after modal open animation completes
const handleAfterOpenChange = useCallback((open: boolean) => {
if (open) {
inputRef.current?.focus();
}
}, []);
// Build command results from search
const commandResults = useMemo(() => {
if (!query) return [];
return search(query);
}, [query, search]);
// Build recent items list (when no query)
const recentCommandItems = useMemo(() => {
if (query) return [];
return recentItems
.map((id) => allItems.find((item) => item.id === id))
.filter((item): item is CommandItem => !!item);
}, [query, recentItems, allItems]);
// Flatten all results into a single list for keyboard navigation
const flatList = useMemo((): FlatItem[] => {
const items: FlatItem[] = [];
if (!query) {
// Show recents
for (const item of recentCommandItems) {
items.push({ type: 'command', item });
}
} else {
// Show search results
for (const item of commandResults) {
items.push({ type: 'command', item });
}
for (const item of entityResults) {
items.push({ type: 'entity', item });
}
}
return items;
}, [query, recentCommandItems, commandResults, entityResults]);
// Clamp selected index
useEffect(() => {
setSelectedIndex((prev) => Math.min(prev, Math.max(0, flatList.length - 1)));
}, [flatList.length]);
// Scroll selected item into view
useEffect(() => {
const container = listRef.current;
if (!container) return;
const el = container.querySelector(`[data-index="${selectedIndex}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
const handleSelect = useCallback(
(flatItem: FlatItem) => {
if (flatItem.type === 'command') {
const cmd = flatItem.item;
addRecent(cmd.id);
navigate(cmd.path, { state: cmd.navigationState });
} else {
navigate(flatItem.item.path, { state: flatItem.item.navigationState });
}
close();
},
[navigate, close, addRecent],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, flatList.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (flatList[selectedIndex]) {
handleSelect(flatList[selectedIndex]);
}
}
},
[flatList, selectedIndex, handleSelect],
);
if (!shouldRender) return null;
// Group commands by category for display
const groupedCommands = useMemo(() => {
const items = query ? commandResults : recentCommandItems;
const groups = new Map<string, CommandItem[]>();
for (const item of items) {
const key = query ? item.category : 'recent';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
return groups;
}, [query, commandResults, recentCommandItems]);
// Group entities by type
const groupedEntities = useMemo(() => {
const groups = new Map<string, EntityResult[]>();
for (const item of entityResults) {
if (!groups.has(item.entityType)) groups.set(item.entityType, []);
groups.get(item.entityType)!.push(item);
}
return groups;
}, [entityResults]);
// Get flat index for a given item
let flatIndex = 0;
const isMac = navigator.platform?.toLowerCase().includes('mac');
return (
<Modal
open={isOpen}
onCancel={close}
afterOpenChange={handleAfterOpenChange}
footer={null}
closable={false}
width={isMobile ? '100%' : 560}
centered
destroyOnClose
styles={{
content: {
padding: 0,
overflow: 'hidden',
background: token.colorBgElevated,
borderRadius: token.borderRadiusLG,
},
mask: { backdropFilter: 'blur(4px)' },
}}
>
<div onKeyDown={handleKeyDown}>
{/* Search input */}
<div
style={{
padding: '12px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<SearchOutlined style={{ color: token.colorTextSecondary, fontSize: 16 }} />
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedIndex(0);
}}
placeholder="Search pages, settings, data..."
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
color: token.colorText,
fontSize: 15,
}}
autoFocus
/>
{entityLoading && <Spin size="small" />}
</div>
{/* Results list — fixed height keeps search bar anchored */}
<div
ref={listRef}
style={{
height: 400,
overflowY: 'auto',
padding: '4px 0',
}}
>
{/* Command groups */}
{Array.from(groupedCommands.entries()).map(([groupKey, items]) => (
<div key={groupKey}>
<div
style={{
padding: '8px 16px 4px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: token.colorTextSecondary,
}}
>
{groupKey === 'recent'
? 'Recent'
: CATEGORY_LABELS[groupKey as CommandCategory] ?? groupKey}
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
</div>
{items.map((item) => {
const idx = flatIndex++;
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
badge={item.category === 'action' ? <ThunderboltOutlined style={{ fontSize: 10, color: token.colorWarning }} /> : null}
subtitle={item.group}
token={token}
onSelect={() => handleSelect({ type: 'command', item })}
onHover={() => setSelectedIndex(idx)}
/>
);
})}
</div>
))}
{/* Entity groups */}
{query &&
Array.from(groupedEntities.entries()).map(([entityType, items]) => (
<div key={entityType}>
<div
style={{
padding: '8px 16px 4px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: token.colorTextSecondary,
}}
>
{entityType}s
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
</div>
{items.map((item) => {
const idx = flatIndex++;
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
subtitle={item.subtitle || item.entityType}
token={token}
onSelect={() => handleSelect({ type: 'entity', item })}
onHover={() => setSelectedIndex(idx)}
/>
);
})}
</div>
))}
{/* Empty state */}
{flatList.length === 0 && !entityLoading && query && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">No results for "{query}"</Text>
</div>
)}
{/* No query, no recents */}
{flatList.length === 0 && !query && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">Type to search pages, settings, and data</Text>
</div>
)}
</div>
{/* Footer hints */}
<div
style={{
padding: '6px 16px',
borderTop: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
gap: 16,
fontSize: 12,
color: token.colorTextTertiary,
}}
>
<span><kbd style={kbdStyle(token)}></kbd> navigate</span>
<span><kbd style={kbdStyle(token)}></kbd> open</span>
<span><kbd style={kbdStyle(token)}>esc</kbd> close</span>
<span style={{ marginLeft: 'auto' }}>
<kbd style={kbdStyle(token)}>{isMac ? '⌘' : 'Ctrl'}</kbd>+<kbd style={kbdStyle(token)}>K</kbd>
</span>
</div>
</div>
</Modal>
);
}
function kbdStyle(token: GlobalToken): React.CSSProperties {
return {
display: 'inline-block',
padding: '0 4px',
fontSize: 11,
lineHeight: '18px',
borderRadius: 3,
border: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
};
}
/** Single result row */
function ResultRow({
index,
selected,
icon,
title,
subtitle,
badge,
token,
onSelect,
onHover,
}: {
index: number;
selected: boolean;
icon: React.ReactNode;
title: string;
subtitle?: string;
badge?: React.ReactNode;
token: GlobalToken;
onSelect: () => void;
onHover: () => void;
}) {
return (
<div
data-index={index}
onClick={onSelect}
onMouseEnter={onHover}
style={{
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
cursor: 'pointer',
background: selected ? token.colorPrimaryBg : 'transparent',
borderRadius: 4,
margin: '0 4px',
transition: 'background 0.1s',
}}
>
<span style={{ fontSize: 16, color: token.colorTextSecondary, flexShrink: 0, width: 20, textAlign: 'center' }}>
{icon}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: token.colorText, fontSize: 14 }}>
{title}
</span>
{badge}
{subtitle && (
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
{subtitle}
</Text>
)}
</div>
);
}

View File

@ -0,0 +1,773 @@
import type { CommandItem } from './types';
/**
* Static registry of all navigable pages, settings targets, and quick actions.
* Mirrors the sidebar structure in AppLayout.tsx buildMenuItems().
*
* featureFlag values match SiteSettings boolean keys.
* requiredRoles restricts visibility to specific user roles.
*/
export const commandRegistry: CommandItem[] = [
// ── Navigation: Top-level ──────────────────────────────
{
id: 'nav-dashboard',
title: 'Dashboard',
group: 'General',
path: '/app',
icon: 'DashboardOutlined',
keywords: ['home', 'overview', 'summary', 'stats'],
category: 'navigation',
},
{
id: 'nav-users',
title: 'Users',
group: 'People & Access',
path: '/app/users',
icon: 'TeamOutlined',
keywords: ['people', 'accounts', 'members', 'roles'],
category: 'navigation',
},
{
id: 'nav-people',
title: 'People',
group: 'People & Access',
path: '/app/people',
icon: 'ContactsOutlined',
keywords: ['crm', 'contacts', 'relationships', 'engagement', 'directory'],
category: 'navigation',
featureFlag: 'enablePeople',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
},
{
id: 'nav-settings',
title: 'Settings',
group: 'General',
path: '/app/settings',
icon: 'SettingOutlined',
keywords: ['config', 'configuration', 'preferences', 'organization'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
// ── Navigation: Advocacy ──────────────────────────────
{
id: 'nav-campaigns',
title: 'Campaigns',
group: 'Advocacy',
path: '/app/campaigns',
icon: 'SendOutlined',
keywords: ['influence', 'advocacy', 'email campaigns', 'letters'],
category: 'navigation',
featureFlag: 'enableInfluence',
},
{
id: 'nav-campaign-review',
title: 'Campaign Review',
group: 'Advocacy',
path: '/app/campaign-moderation',
icon: 'FileTextOutlined',
keywords: ['moderation', 'approve', 'pending', 'review campaigns'],
category: 'navigation',
featureFlag: 'enableInfluence',
},
{
id: 'nav-representatives',
title: 'Representatives',
group: 'Advocacy',
path: '/app/representatives',
icon: 'IdcardOutlined',
keywords: ['reps', 'politicians', 'elected', 'officials', 'cache'],
category: 'navigation',
featureFlag: 'enableInfluence',
},
{
id: 'nav-outgoing-emails',
title: 'Outgoing Emails',
group: 'Advocacy',
path: '/app/email-queue',
icon: 'MailOutlined',
keywords: ['queue', 'email queue', 'sent', 'sending', 'bullmq'],
category: 'navigation',
featureFlag: 'enableInfluence',
},
{
id: 'nav-responses',
title: 'Responses',
group: 'Advocacy',
path: '/app/responses',
icon: 'MessageOutlined',
keywords: ['response wall', 'moderation', 'feedback', 'replies'],
category: 'navigation',
featureFlag: 'enableInfluence',
},
{
id: 'nav-effectiveness',
title: 'Effectiveness',
group: 'Advocacy',
path: '/app/influence/effectiveness',
icon: 'LineChartOutlined',
keywords: ['analytics', 'metrics', 'campaign stats', 'performance'],
category: 'navigation',
featureFlag: 'enableInfluence',
},
// ── Navigation: Broadcast ─────────────────────────────
{
id: 'nav-newsletter',
title: 'Newsletter',
group: 'Broadcast',
path: '/app/listmonk',
icon: 'MailOutlined',
keywords: ['listmonk', 'mailing list', 'subscribers', 'broadcast', 'email marketing'],
category: 'navigation',
featureFlag: 'enableNewsletter',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-email-templates',
title: 'Email Templates',
group: 'Broadcast',
path: '/app/email-templates',
icon: 'FileTextOutlined',
keywords: ['template', 'email design', 'template editor'],
category: 'navigation',
featureFlag: 'enableNewsletter',
},
{
id: 'nav-sms-setup',
title: 'SMS Setup',
group: 'Broadcast',
path: '/app/sms/setup',
icon: 'SettingOutlined',
keywords: ['sms config', 'text message', 'termux', 'phone setup'],
category: 'navigation',
featureFlag: 'enableSms',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-sms-dashboard',
title: 'SMS Dashboard',
group: 'Broadcast',
path: '/app/sms',
icon: 'PhoneOutlined',
keywords: ['text messages', 'sms overview', 'sms stats'],
category: 'navigation',
featureFlag: 'enableSms',
},
{
id: 'nav-sms-contacts',
title: 'SMS Contacts',
group: 'Broadcast',
path: '/app/sms/contacts',
icon: 'TeamOutlined',
keywords: ['contact list', 'phone numbers', 'sms recipients'],
category: 'navigation',
featureFlag: 'enableSms',
},
{
id: 'nav-sms-campaigns',
title: 'SMS Campaigns',
group: 'Broadcast',
path: '/app/sms/campaigns',
icon: 'SendOutlined',
keywords: ['text campaigns', 'bulk sms', 'mass text'],
category: 'navigation',
featureFlag: 'enableSms',
},
{
id: 'nav-sms-conversations',
title: 'SMS Threads',
group: 'Broadcast',
path: '/app/sms/conversations',
icon: 'MessageOutlined',
keywords: ['sms replies', 'threads', 'text conversations'],
category: 'navigation',
featureFlag: 'enableSms',
},
// ── Navigation: Web ───────────────────────────────────
{
id: 'nav-landing-pages',
title: 'Landing Pages',
group: 'Web',
path: '/app/pages',
icon: 'FileTextOutlined',
keywords: ['pages', 'grapesjs', 'page builder', 'website'],
category: 'navigation',
featureFlag: 'enableLandingPages',
},
{
id: 'nav-navigation',
title: 'Navigation',
group: 'Web',
path: '/app/navigation',
icon: 'GlobalOutlined',
keywords: ['nav', 'menu', 'header links', 'navigation settings'],
category: 'navigation',
},
{
id: 'nav-documentation',
title: 'Documentation',
group: 'Web',
path: '/app/docs',
icon: 'BookOutlined',
keywords: ['mkdocs', 'docs', 'knowledge base', 'articles'],
category: 'navigation',
},
{
id: 'nav-docs-analytics',
title: 'Docs Analytics',
group: 'Web',
path: '/app/docs/analytics',
icon: 'BarChartOutlined',
keywords: ['documentation stats', 'page views', 'docs metrics'],
category: 'navigation',
},
{
id: 'nav-docs-comments',
title: 'Docs Comments',
group: 'Web',
path: '/app/docs/comments',
icon: 'MessageOutlined',
keywords: ['documentation comments', 'feedback', 'discussion'],
category: 'navigation',
},
{
id: 'nav-docs-settings',
title: 'Docs Settings',
group: 'Web',
path: '/app/docs/settings',
icon: 'SettingOutlined',
keywords: ['mkdocs config', 'documentation settings'],
category: 'navigation',
},
{
id: 'nav-code-editor',
title: 'Code Editor',
group: 'Web',
path: '/app/code',
icon: 'CodeOutlined',
keywords: ['code server', 'vscode', 'ide', 'editor'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
// ── Navigation: Map ───────────────────────────────────
{
id: 'nav-locations',
title: 'Locations',
group: 'Map',
path: '/app/map',
icon: 'EnvironmentOutlined',
keywords: ['addresses', 'geocoding', 'map', 'places', 'csv import'],
category: 'navigation',
featureFlag: 'enableMap',
},
{
id: 'nav-data-quality',
title: 'Data Quality',
group: 'Map',
path: '/app/map/data-quality',
icon: 'BarChartOutlined',
keywords: ['geocoding quality', 'metrics', 'data dashboard'],
category: 'navigation',
featureFlag: 'enableMap',
},
{
id: 'nav-shifts',
title: 'Shifts',
group: 'Map',
path: '/app/map/shifts',
icon: 'ScheduleOutlined',
keywords: ['volunteer', 'schedule', 'calendar', 'signup', 'canvass shifts'],
category: 'navigation',
featureFlag: 'enableMap',
},
{
id: 'nav-areas',
title: 'Areas',
group: 'Map',
path: '/app/map/cuts',
icon: 'ScissorOutlined',
keywords: ['cuts', 'polygons', 'boundaries', 'zones', 'districts'],
category: 'navigation',
featureFlag: 'enableMap',
},
{
id: 'nav-canvassing',
title: 'Canvassing',
group: 'Map',
path: '/app/map/canvass',
icon: 'TeamOutlined',
keywords: ['door knocking', 'volunteers', 'sessions', 'visits'],
category: 'navigation',
featureFlag: 'enableMap',
},
{
id: 'nav-map-settings',
title: 'Map Settings',
group: 'Map',
path: '/app/map/settings',
icon: 'SettingOutlined',
keywords: ['map config', 'geocoding provider', 'map center'],
category: 'navigation',
featureFlag: 'enableMap',
},
// ── Navigation: Media ─────────────────────────────────
{
id: 'nav-media-library',
title: 'Library',
group: 'Media',
path: '/app/media/library',
icon: 'FolderOutlined',
keywords: ['videos', 'uploads', 'media manager', 'video library'],
category: 'navigation',
featureFlag: 'enableMediaFeatures',
},
{
id: 'nav-media-analytics',
title: 'Media Analytics',
group: 'Media',
path: '/app/media/analytics',
icon: 'BarChartOutlined',
keywords: ['views', 'watch time', 'video stats', 'media metrics'],
category: 'navigation',
featureFlag: 'enableMediaFeatures',
},
{
id: 'nav-media-curated',
title: 'Curated',
group: 'Media',
path: '/app/media/curated',
icon: 'OrderedListOutlined',
keywords: ['playlists', 'collections', 'featured', 'curated content'],
category: 'navigation',
featureFlag: 'enableMediaFeatures',
},
{
id: 'nav-media-moderation',
title: 'Media Moderation',
group: 'Media',
path: '/app/media/moderation',
icon: 'MessageOutlined',
keywords: ['comments', 'review', 'moderate', 'media comments'],
category: 'navigation',
featureFlag: 'enableMediaFeatures',
},
{
id: 'nav-gallery-ads',
title: 'Gallery Ads',
group: 'Payments',
path: '/app/payments/ads',
icon: 'PictureOutlined',
keywords: ['advertisements', 'banners', 'promotions'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-media-jobs',
title: 'Processing Jobs',
group: 'Media',
path: '/app/media/jobs',
icon: 'HistoryOutlined',
keywords: ['ffmpeg', 'encoding', 'job queue', 'processing'],
category: 'navigation',
featureFlag: 'enableMediaFeatures',
},
// ── Navigation: Payments ──────────────────────────────
{
id: 'nav-payments-dashboard',
title: 'Payments Dashboard',
group: 'Payments',
path: '/app/payments',
icon: 'DashboardOutlined',
keywords: ['revenue', 'payments overview', 'transactions'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-plans',
title: 'Plans',
group: 'Payments',
path: '/app/payments/plans',
icon: 'TagOutlined',
keywords: ['subscriptions', 'pricing', 'tiers', 'membership'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-subscribers',
title: 'Subscribers',
group: 'Payments',
path: '/app/payments/subscribers',
icon: 'CrownOutlined',
keywords: ['members', 'paying users', 'subscriptions'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-products',
title: 'Products',
group: 'Payments',
path: '/app/payments/products',
icon: 'ShoppingOutlined',
keywords: ['store', 'shop', 'items', 'merchandise'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-donation-pages',
title: 'Donation Pages',
group: 'Payments',
path: '/app/payments/donation-pages',
icon: 'HeartOutlined',
keywords: ['donate', 'fundraising', 'giving'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-donation-orders',
title: 'Donation Orders',
group: 'Payments',
path: '/app/payments/donations',
icon: 'DollarOutlined',
keywords: ['donations received', 'donation history', 'transaction log'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-payment-settings',
title: 'Payment Settings',
group: 'Payments',
path: '/app/payments/settings',
icon: 'SettingOutlined',
keywords: ['stripe', 'payment config', 'gateway'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
// ── Navigation: Services (SUPER_ADMIN only) ───────────
{
id: 'nav-tunnel',
title: 'Tunnel',
group: 'Services',
path: '/app/tunnel',
icon: 'CloudServerOutlined',
keywords: ['pangolin', 'newt', 'tunnel', 'reverse proxy', 'expose'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-monitoring',
title: 'Monitoring',
group: 'Services',
path: '/app/observability',
icon: 'LineChartOutlined',
keywords: ['prometheus', 'grafana', 'alertmanager', 'observability', 'metrics'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-database',
title: 'Database',
group: 'Services',
path: '/app/services/nocodb',
icon: 'DatabaseOutlined',
keywords: ['nocodb', 'data browser', 'tables', 'sql'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-vault',
title: 'Vault',
group: 'Services',
path: '/app/services/vaultwarden',
icon: 'LockOutlined',
keywords: ['vaultwarden', 'passwords', 'secrets', 'bitwarden'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-mailhog',
title: 'MailHog',
group: 'Services',
path: '/app/services/mailhog',
icon: 'MailOutlined',
keywords: ['email capture', 'test emails', 'development mail'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-workflows',
title: 'Workflows',
group: 'Services',
path: '/app/services/n8n',
icon: 'ApiOutlined',
keywords: ['n8n', 'automation', 'integrations', 'workflows'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-git',
title: 'Git',
group: 'Services',
path: '/app/services/gitea',
icon: 'BranchesOutlined',
keywords: ['gitea', 'repository', 'source code', 'version control'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-whiteboard',
title: 'Whiteboard',
group: 'Services',
path: '/app/services/excalidraw',
icon: 'EditOutlined',
keywords: ['excalidraw', 'drawing', 'diagrams', 'sketch'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-team-chat',
title: 'Team Chat',
group: 'Services',
path: '/app/services/rocketchat',
icon: 'MessageOutlined',
keywords: ['rocketchat', 'chat', 'messaging', 'communication'],
category: 'navigation',
featureFlag: 'enableChat',
},
{
id: 'nav-events',
title: 'Events',
group: 'Services',
path: '/app/services/gancio',
icon: 'CalendarOutlined',
keywords: ['gancio', 'events', 'calendar', 'activities'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-qr-codes',
title: 'QR Codes',
group: 'Services',
path: '/app/services/miniqr',
icon: 'QrcodeOutlined',
keywords: ['qr generator', 'mini qr', 'barcode'],
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
// ── Settings deep links ───────────────────────────────
{
id: 'settings-general',
title: 'General Settings',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
keywords: ['organization name', 'branding', 'logo', 'general config'],
category: 'settings',
requiredRoles: ['SUPER_ADMIN'],
navigationState: { activeTab: 'general' },
},
{
id: 'settings-features',
title: 'Feature Flags',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
keywords: ['toggle', 'enable', 'disable', 'feature flags', 'modules'],
category: 'settings',
requiredRoles: ['SUPER_ADMIN'],
navigationState: { activeTab: 'features' },
},
{
id: 'settings-theme',
title: 'Theme Settings',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
keywords: ['colors', 'appearance', 'dark mode', 'branding', 'primary color'],
category: 'settings',
requiredRoles: ['SUPER_ADMIN'],
navigationState: { activeTab: 'theme' },
},
{
id: 'settings-email',
title: 'Email Settings',
group: 'Settings',
path: '/app/settings',
icon: 'MailOutlined',
keywords: ['smtp', 'email config', 'mail server', 'sender'],
category: 'settings',
requiredRoles: ['SUPER_ADMIN'],
navigationState: { activeTab: 'email' },
},
{
id: 'settings-provisioning',
title: 'User Provisioning',
group: 'Settings',
path: '/app/settings',
icon: 'TeamOutlined',
keywords: ['provision', 'auto-create', 'service accounts', 'gitea', 'vaultwarden'],
category: 'settings',
requiredRoles: ['SUPER_ADMIN'],
navigationState: { activeTab: 'provisioning' },
},
{
id: 'settings-navigation',
title: 'Navigation Settings',
group: 'Settings',
path: '/app/navigation',
icon: 'GlobalOutlined',
keywords: ['header links', 'nav config', 'menu items'],
category: 'settings',
},
{
id: 'settings-map',
title: 'Map Settings',
group: 'Settings',
path: '/app/map/settings',
icon: 'EnvironmentOutlined',
keywords: ['map center', 'geocoding provider', 'map config'],
category: 'settings',
featureFlag: 'enableMap',
},
{
id: 'settings-payments',
title: 'Payment Settings',
group: 'Settings',
path: '/app/payments/settings',
icon: 'DollarOutlined',
keywords: ['stripe', 'payment gateway', 'payment config'],
category: 'settings',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
// ── Quick actions ─────────────────────────────────────
{
id: 'action-create-campaign',
title: 'Create Campaign',
group: 'Actions',
path: '/app/campaigns',
icon: 'SendOutlined',
keywords: ['new campaign', 'add campaign'],
category: 'action',
featureFlag: 'enableInfluence',
navigationState: { openCreate: true },
},
{
id: 'action-add-location',
title: 'Add Location',
group: 'Actions',
path: '/app/map',
icon: 'EnvironmentOutlined',
keywords: ['new location', 'add address', 'create location'],
category: 'action',
featureFlag: 'enableMap',
navigationState: { openCreate: true },
},
{
id: 'action-new-shift',
title: 'New Shift',
group: 'Actions',
path: '/app/map/shifts',
icon: 'ScheduleOutlined',
keywords: ['create shift', 'add shift', 'schedule shift'],
category: 'action',
featureFlag: 'enableMap',
navigationState: { openCreate: true },
},
{
id: 'action-new-landing-page',
title: 'New Landing Page',
group: 'Actions',
path: '/app/pages',
icon: 'FileTextOutlined',
keywords: ['create page', 'add page', 'new page'],
category: 'action',
featureFlag: 'enableLandingPages',
navigationState: { openCreate: true },
},
{
id: 'action-new-email-template',
title: 'New Email Template',
group: 'Actions',
path: '/app/email-templates',
icon: 'FileTextOutlined',
keywords: ['create template', 'add template'],
category: 'action',
featureFlag: 'enableNewsletter',
navigationState: { openCreate: true },
},
{
id: 'action-new-product',
title: 'New Product',
group: 'Actions',
path: '/app/payments/products',
icon: 'ShoppingOutlined',
keywords: ['create product', 'add product', 'new item'],
category: 'action',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
navigationState: { openCreate: true },
},
{
id: 'action-add-contact',
title: 'Add Contact',
group: 'Actions',
path: '/app/people',
icon: 'ContactsOutlined',
keywords: ['new contact', 'add person', 'create contact', 'crm'],
category: 'action',
featureFlag: 'enablePeople',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
navigationState: { openCreate: true },
},
{
id: 'action-new-plan',
title: 'New Plan',
group: 'Actions',
path: '/app/payments/plans',
icon: 'TagOutlined',
keywords: ['create plan', 'add plan', 'new subscription', 'pricing tier'],
category: 'action',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
navigationState: { openCreate: true },
},
{
id: 'action-new-donation-page',
title: 'New Donation Page',
group: 'Actions',
path: '/app/payments/donation-pages',
icon: 'HeartOutlined',
keywords: ['create donation page', 'fundraising', 'giving page'],
category: 'action',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
navigationState: { openCreate: true },
},
{
id: 'action-new-sms-campaign',
title: 'New SMS Campaign',
group: 'Actions',
path: '/app/sms/campaigns',
icon: 'PhoneOutlined',
keywords: ['create sms campaign', 'text campaign', 'bulk sms', 'new text'],
category: 'action',
featureFlag: 'enableSms',
navigationState: { openCreate: true },
},
];

View File

@ -0,0 +1,33 @@
import type { UserRole } from '@/types/api';
export type CommandCategory = 'navigation' | 'settings' | 'action';
export interface CommandItem {
id: string;
title: string;
group: string;
path: string;
keywords: string[];
category: CommandCategory;
icon?: string;
featureFlag?: string;
requiredRoles?: UserRole[];
/** For settings deep links — passed as navigate state */
navigationState?: Record<string, unknown>;
}
export interface EntityResult {
id: string;
title: string;
subtitle?: string;
entityType: string;
path: string;
icon?: string;
navigationState?: Record<string, unknown>;
}
export interface SearchResults {
commands: CommandItem[];
entities: EntityResult[];
entitiesLoading: boolean;
}

View File

@ -0,0 +1,81 @@
import { useMemo } from 'react';
import MiniSearch from 'minisearch';
import { commandRegistry } from './registry';
import type { CommandItem } from './types';
import type { UserRole } from '@/types/api';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import { getUserRoles, hasAnyRole } from '@/utils/roles';
interface IndexedItem extends CommandItem {
keywordsJoined: string;
}
/**
* Builds a MiniSearch index from the command registry,
* filtered by the current user's roles and enabled feature flags.
*/
export function useCommandIndex() {
const { settings } = useSettingsStore();
const { user } = useAuthStore();
const { search, allItems } = useMemo(() => {
const userRoles = user ? getUserRoles(user) : [];
const isSuperAdmin = userRoles.includes('SUPER_ADMIN');
// Feature flag resolution — matches logic in AppLayout.tsx
const featureEnabled = (flag: string | undefined): boolean => {
if (!flag) return true;
if (!settings) return true; // show all when settings haven't loaded
const value = (settings as unknown as Record<string, unknown>)[flag];
// enablePayments, enableSms, and enablePeople default off, others default on
if (flag === 'enablePayments' || flag === 'enableSms' || flag === 'enablePeople') return value === true;
return value !== false;
};
// Filter registry by role + feature flags
const filtered = commandRegistry.filter((item) => {
if (!featureEnabled(item.featureFlag)) return false;
if (item.requiredRoles && item.requiredRoles.length > 0) {
// SUPER_ADMIN can see everything
if (isSuperAdmin) return true;
if (!hasAnyRole(user, item.requiredRoles as UserRole[])) return false;
}
return true;
});
// Build index
const indexed: IndexedItem[] = filtered.map((item) => ({
...item,
keywordsJoined: item.keywords.join(' '),
}));
const idx = new MiniSearch<IndexedItem>({
fields: ['title', 'keywordsJoined', 'group'],
storeFields: ['id'],
idField: 'id',
searchOptions: {
boost: { title: 10, keywordsJoined: 3, group: 1 },
prefix: true,
fuzzy: 0.2,
},
});
idx.addAll(indexed);
// Create lookup map for fast retrieval
const itemMap = new Map(filtered.map((item) => [item.id, item]));
const searchFn = (query: string): CommandItem[] => {
if (!query || query.length < 1) return [];
const results = idx.search(query);
return results
.map((r) => itemMap.get(r.id as string))
.filter((item): item is CommandItem => !!item);
};
return { search: searchFn, allItems: filtered };
}, [settings, user]);
return { search, allItems };
}

View File

@ -0,0 +1,216 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '@/lib/api';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import { hasAnyRole } from '@/utils/roles';
import type { EntityResult } from './types';
interface EntitySearchConfig {
entityType: string;
endpoint: string;
displayField: string;
subtitleField?: string;
pathPrefix: string;
featureFlag?: string;
requiredRoles?: string[];
icon?: string;
/** Extract items from response (default: data.items or data.users or data.data) */
extractItems?: (data: unknown) => unknown[];
/** Build navigation state to pass with navigate() for deep-linking */
buildNavigationState?: (item: Record<string, unknown>) => Record<string, unknown>;
}
const entityConfigs: EntitySearchConfig[] = [
{
entityType: 'User',
endpoint: '/users',
displayField: 'name',
subtitleField: 'email',
pathPrefix: '/app/users',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
extractItems: (data: unknown) => (data as { users?: unknown[] })?.users ?? [],
},
{
entityType: 'Campaign',
endpoint: '/campaigns',
displayField: 'title',
subtitleField: 'status',
pathPrefix: '/app/campaigns',
featureFlag: 'enableInfluence',
extractItems: (data: unknown) => (data as { campaigns?: unknown[] })?.campaigns ?? [],
},
{
entityType: 'Location',
endpoint: '/map/locations',
displayField: 'address',
pathPrefix: '/app/map',
featureFlag: 'enableMap',
extractItems: (data: unknown) => (data as { locations?: unknown[] })?.locations ?? [],
},
{
entityType: 'Shift',
endpoint: '/map/shifts',
displayField: 'title',
subtitleField: 'date',
pathPrefix: '/app/map/shifts',
featureFlag: 'enableMap',
extractItems: (data: unknown) => (data as { shifts?: unknown[] })?.shifts ?? [],
},
{
entityType: 'Landing Page',
endpoint: '/pages',
displayField: 'title',
subtitleField: 'slug',
pathPrefix: '/app/pages',
icon: 'FileTextOutlined',
featureFlag: 'enableLandingPages',
extractItems: (data: unknown) => (data as { pages?: unknown[] })?.pages ?? [],
buildNavigationState: (item) => ({ editPageId: item.id as string }),
},
{
entityType: 'Email Template',
endpoint: '/email-templates',
displayField: 'name',
pathPrefix: '/app/email-templates',
extractItems: (data: unknown) => Array.isArray(data) ? data : [],
},
{
entityType: 'Product',
endpoint: '/payments/products',
displayField: 'title',
subtitleField: 'slug',
pathPrefix: '/app/payments/products',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
extractItems: (data: unknown) => (data as { products?: unknown[] })?.products ?? [],
},
{
entityType: 'Donation Page',
endpoint: '/payments/donation-pages',
displayField: 'title',
subtitleField: 'slug',
pathPrefix: '/app/payments/donation-pages',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
extractItems: (data: unknown) => (data as { donationPages?: unknown[] })?.donationPages ?? [],
},
{
entityType: 'Person',
endpoint: '/people',
displayField: 'displayName',
subtitleField: 'email',
pathPrefix: '/app/people',
icon: 'ContactsOutlined',
featureFlag: 'enablePeople',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
extractItems: (data: unknown) => (data as { people?: unknown[] })?.people ?? [],
},
{
entityType: 'Plan',
endpoint: '/payments/admin/plans',
displayField: 'name',
subtitleField: 'slug',
pathPrefix: '/app/payments/plans',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
extractItems: (data: unknown) => (data as { plans?: unknown[] })?.plans ?? [],
},
{
entityType: 'Doc File',
endpoint: '/docs/files/search',
displayField: 'name',
subtitleField: 'path',
pathPrefix: '/app/docs',
icon: 'FileMarkdownOutlined',
extractItems: (data: unknown) => (data as { files?: unknown[] })?.files ?? [],
buildNavigationState: (item) => ({ selectFile: item.path as string }),
},
];
/**
* Debounced API entity search. Fires after 300ms when query is 2+ chars.
* Uses AbortController to cancel in-flight requests on query change.
*/
export function useEntitySearch(query: string) {
const [results, setResults] = useState<EntityResult[]>([]);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const { settings } = useSettingsStore();
const { user } = useAuthStore();
useEffect(() => {
// Cancel any in-flight request
abortRef.current?.abort();
if (!query || query.length < 2) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
const controller = new AbortController();
abortRef.current = controller;
const timer = setTimeout(async () => {
try {
// Filter configs by feature flags + roles
const activeConfigs = entityConfigs.filter((cfg) => {
if (cfg.featureFlag && settings) {
const val = (settings as unknown as Record<string, unknown>)[cfg.featureFlag];
if (cfg.featureFlag === 'enablePayments' || cfg.featureFlag === 'enableSms' || cfg.featureFlag === 'enablePeople') {
if (val !== true) return false;
} else if (val === false) return false;
}
if (cfg.requiredRoles && cfg.requiredRoles.length > 0) {
if (!hasAnyRole(user, cfg.requiredRoles as import('@/types/api').UserRole[])) return false;
}
return true;
});
const promises = activeConfigs.map(async (cfg) => {
try {
const { data } = await api.get(cfg.endpoint, {
params: { search: query, limit: 3, page: 1 },
signal: controller.signal,
});
const items = cfg.extractItems?.(data) ?? [];
return (items as Record<string, unknown>[]).slice(0, 3).map((item) => ({
id: `entity-${cfg.entityType}-${item.id ?? item.path ?? ''}`,
title: String(item[cfg.displayField] || item.email || item.id || ''),
subtitle: cfg.subtitleField ? String(item[cfg.subtitleField] || '') : undefined,
entityType: cfg.entityType,
path: cfg.pathPrefix,
icon: cfg.icon,
navigationState: cfg.buildNavigationState?.(item),
}));
} catch {
return [];
}
});
const settled = await Promise.allSettled(promises);
if (controller.signal.aborted) return;
const allResults: EntityResult[] = [];
for (const result of settled) {
if (result.status === 'fulfilled') {
allResults.push(...result.value);
}
}
setResults(allResults);
} catch {
if (!controller.signal.aborted) setResults([]);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, settings, user]);
return { results, loading };
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Segmented, Button, Spin, Flex } from 'antd';
import {
CalendarOutlined,
ScheduleOutlined,
MailOutlined,
CompassOutlined,
UserAddOutlined,
@ -18,7 +18,7 @@ dayjs.extend(relativeTime);
const { Text } = Typography;
const TYPE_CONFIG: Record<ActivityItem['type'], { color: string; icon: React.ReactNode }> = {
shift_signup: { color: '#eb2f96', icon: <CalendarOutlined style={{ fontSize: 13 }} /> },
shift_signup: { color: '#eb2f96', icon: <ScheduleOutlined style={{ fontSize: 13 }} /> },
response_submitted: { color: '#faad14', icon: <MessageOutlined style={{ fontSize: 13 }} /> },
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 13 }} /> },
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 13 }} /> },

View File

@ -5,11 +5,12 @@ import {
ReloadOutlined,
RobotOutlined,
UserOutlined,
TeamOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { ChatSummaryResult, ChatMessage } from '@/types/api';
import type { ChatSummaryResult, ChatMessage, RocketChatStatsData } from '@/types/api';
dayjs.extend(relativeTime);
@ -72,13 +73,18 @@ function ChatRow({ message }: { message: ChatMessage }) {
export default function ChatNotifierCard() {
const [result, setResult] = useState<ChatSummaryResult | null>(null);
const [stats, setStats] = useState<RocketChatStatsData | null>(null);
const [loading, setLoading] = useState(true);
const fetchChat = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ChatSummaryResult>('/dashboard/chat-summary');
setResult(res.data);
const [chatRes, statsRes] = await Promise.allSettled([
api.get<ChatSummaryResult>('/dashboard/chat-summary'),
api.get<RocketChatStatsData>('/dashboard/rocketchat-stats'),
]);
if (chatRes.status === 'fulfilled') setResult(chatRes.value.data);
if (statsRes.status === 'fulfilled' && !statsRes.value.data.error) setStats(statsRes.value.data);
} catch {
// non-critical widget
} finally {
@ -99,20 +105,49 @@ export default function ChatNotifierCard() {
title={<span style={{ fontSize: 14 }}><MessageOutlined style={{ marginRight: 6, fontSize: 15 }} />Team Chat</span>}
size="small"
extra={
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchChat} />
<Flex align="center" gap={6}>
{stats && (
<Flex gap={8} align="center">
<Flex align="center" gap={3}>
<TeamOutlined style={{ fontSize: 12, color: '#1890ff' }} />
<Text type="secondary" style={{ fontSize: 12 }}>{stats.totalUsers}</Text>
</Flex>
<Flex align="center" gap={3}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#52c41a' }} />
<Text type="secondary" style={{ fontSize: 12 }}>{stats.onlineUsers} online</Text>
</Flex>
<Text type="secondary" style={{ fontSize: 12 }}>{stats.channels} ch</Text>
</Flex>
)}
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchChat} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : result && result.messages.length > 0 ? (
<div>
{result.messages.map(msg => (
<ChatRow key={msg.id} message={msg} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent messages</Text>
<>
{stats && (
<Flex gap={12} align="center" style={{ marginBottom: 6, paddingBottom: 6, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<Text type="secondary" style={{ fontSize: 12 }}>{stats.totalMessages.toLocaleString()} messages</Text>
{stats.topChannels.slice(0, 4).map((ch, i) => (
<Tooltip key={i} title={`${ch.messages} msgs, ${ch.users} users`}>
<Text style={{ fontSize: 11 }}>#{ch.name}</Text>
</Tooltip>
))}
</Flex>
)}
{result && result.messages.length > 0 ? (
<div>
{result.messages.map(msg => (
<ChatRow key={msg.id} message={msg} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent messages</Text>
)}
</>
)}
</Card>
);

View File

@ -0,0 +1,95 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
import {
BranchesOutlined,
ReloadOutlined,
UserOutlined,
CodeOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { GiteaActivityData } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
export default function GiteaActivityCard() {
const [data, setData] = useState<GiteaActivityData | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<GiteaActivityData>('/dashboard/gitea-activity');
if (res.data.error) {
setHasError(true);
} else {
setData(res.data);
setHasError(false);
}
} catch {
setHasError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchData]);
if (hasError && !data) return null;
return (
<Card
title={<span style={{ fontSize: 14 }}><BranchesOutlined style={{ marginRight: 6, fontSize: 15 }} />Gitea</span>}
size="small"
extra={<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : data ? (
<>
<Flex gap={16} style={{ marginBottom: 8 }}>
<Flex align="center" gap={4}>
<CodeOutlined style={{ color: '#52c41a' }} />
<Text strong style={{ fontSize: 16 }}>{data.repos}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>repos</Text>
</Flex>
<Flex align="center" gap={4}>
<UserOutlined style={{ color: '#1890ff' }} />
<Text strong style={{ fontSize: 16 }}>{data.users}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>users</Text>
</Flex>
</Flex>
{data.recentCommits.length > 0 && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Recent Commits</Text>
{data.recentCommits.slice(0, 6).map((c, i) => (
<Flex key={i} align="center" gap={6} style={{ padding: '3px 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px', flexShrink: 0 }}>{c.repo}</Tag>
<Tooltip title={c.message}>
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>
{c.message}
</Text>
</Tooltip>
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
{c.date ? dayjs(c.date).fromNow(true) : ''}
</Text>
</Flex>
))}
</div>
)}
</>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>Gitea unavailable</Text>
)}
</Card>
);
}

View File

@ -1,27 +1,48 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tooltip } from 'antd';
import { Card, Typography, Spin, Flex, Button, Tooltip, Tag } from 'antd';
import {
MailOutlined,
ReloadOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { ListmonkStats } from '@/types/api';
import type { ListmonkStats, ListmonkStatus, ListmonkCampaignsData } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const CAMPAIGN_STATUS_COLORS: Record<string, string> = {
running: 'green',
finished: 'blue',
paused: 'orange',
draft: 'default',
scheduled: 'purple',
cancelled: 'red',
};
export default function NewsletterStatsCard() {
const navigate = useNavigate();
const [data, setData] = useState<ListmonkStats | null>(null);
const [status, setStatus] = useState<ListmonkStatus | null>(null);
const [campaigns, setCampaigns] = useState<ListmonkCampaignsData | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ListmonkStats>('/listmonk/stats');
setData(res.data);
const [statsRes, statusRes, campaignsRes] = await Promise.all([
api.get<ListmonkStats>('/listmonk/stats'),
api.get<ListmonkStatus>('/listmonk').catch(() => null),
api.get<ListmonkCampaignsData>('/dashboard/listmonk-campaigns').catch(() => null),
]);
setData(statsRes.data);
if (statusRes) setStatus(statusRes.data);
if (campaignsRes && !campaignsRes.data.error) setCampaigns(campaignsRes.data);
setHasError(false);
} catch {
setHasError(true);
@ -40,6 +61,7 @@ export default function NewsletterStatsCard() {
const lists = data?.lists || [];
const totalSubscribers = lists.reduce((sum, l) => sum + l.subscriberCount, 0);
const campaignList = campaigns?.campaigns || [];
return (
<Card
@ -76,6 +98,35 @@ export default function NewsletterStatsCard() {
</Flex>
))}
</div>
{/* Campaign Performance Section */}
{campaignList.length > 0 && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6, marginTop: 6 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Campaigns</Text>
{campaignList.slice(0, 5).map((c, i) => (
<Flex key={i} justify="space-between" align="center" style={{ padding: '2px 0' }}>
<Flex align="center" gap={4} style={{ overflow: 'hidden', flex: 1, minWidth: 0 }}>
<Tag color={CAMPAIGN_STATUS_COLORS[c.status] || 'default'} style={{ fontSize: 10, margin: 0, padding: '0 4px', flexShrink: 0 }}>
{c.status}
</Tag>
<Tooltip title={c.name}>
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.name}
</Text>
</Tooltip>
</Flex>
<Flex gap={8} style={{ flexShrink: 0 }}>
<Text type="secondary" style={{ fontSize: 11 }}>{c.sentCount.toLocaleString()} sent</Text>
<Text type="secondary" style={{ fontSize: 11 }}>{c.openRate}% open</Text>
</Flex>
</Flex>
))}
</div>
)}
{status?.lastSyncAt && (
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
Last synced {dayjs(status.lastSyncAt).fromNow()}
</Text>
)}
</>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No lists configured</Text>

View File

@ -0,0 +1,96 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tooltip } from 'antd';
import {
MessageOutlined,
ReloadOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { RocketChatStatsData } from '@/types/api';
const { Text } = Typography;
export default function RocketChatStatsCard() {
const [data, setData] = useState<RocketChatStatsData | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<RocketChatStatsData>('/dashboard/rocketchat-stats');
if (res.data.error) {
setHasError(true);
} else {
setData(res.data);
setHasError(false);
}
} catch {
setHasError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 3 * 60_000);
return () => clearInterval(interval);
}, [fetchData]);
if (hasError && !data) return null;
return (
<Card
title={<span style={{ fontSize: 14 }}><MessageOutlined style={{ marginRight: 6, fontSize: 15 }} />Rocket.Chat</span>}
size="small"
extra={<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : data ? (
<>
<Flex gap={16} wrap="wrap" style={{ marginBottom: 8 }}>
<Flex align="center" gap={4}>
<TeamOutlined style={{ color: '#1890ff' }} />
<Text strong style={{ fontSize: 16 }}>{data.totalUsers}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>users</Text>
</Flex>
<Flex align="center" gap={4}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#52c41a', flexShrink: 0 }} />
<Text strong style={{ fontSize: 16, color: '#52c41a' }}>{data.onlineUsers}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>online</Text>
</Flex>
<Flex align="center" gap={4}>
<Text type="secondary" style={{ fontSize: 12 }}>{data.channels} channels</Text>
</Flex>
</Flex>
<Flex style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>{data.totalMessages.toLocaleString()} total messages</Text>
</Flex>
{data.topChannels.length > 0 && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 6 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Top Channels</Text>
{data.topChannels.slice(0, 5).map((ch, i) => (
<Flex key={i} justify="space-between" style={{ padding: '2px 0' }}>
<Tooltip title={ch.name}>
<Text style={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '60%' }}>
#{ch.name}
</Text>
</Tooltip>
<Flex gap={8}>
<Text type="secondary" style={{ fontSize: 11 }}>{ch.messages.toLocaleString()} msgs</Text>
<Text type="secondary" style={{ fontSize: 11 }}>{ch.users} users</Text>
</Flex>
</Flex>
))}
</div>
)}
</>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>Rocket.Chat unavailable</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,149 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
import {
VideoCameraOutlined,
ReloadOutlined,
ClockCircleOutlined,
LoginOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
const { Text } = Typography;
interface Meeting {
id: string;
slug: string;
title: string;
isActive: boolean;
startTime: string | null;
endTime: string | null;
createdAt: string;
}
function getStatusTag(meeting: Meeting) {
if (!meeting.isActive) {
return <Tag style={{ margin: 0, fontSize: 10, padding: '0 4px', lineHeight: '18px' }}>Ended</Tag>;
}
if (meeting.startTime && dayjs(meeting.startTime).isAfter(dayjs())) {
return <Tag color="blue" style={{ margin: 0, fontSize: 10, padding: '0 4px', lineHeight: '18px' }}>Scheduled</Tag>;
}
return <Tag color="green" style={{ margin: 0, fontSize: 10, padding: '0 4px', lineHeight: '18px' }}>Live</Tag>;
}
function MeetingRow({ meeting }: { meeting: Meeting }) {
const timeLabel = meeting.startTime
? dayjs(meeting.startTime).format('MMM D, h:mm A')
: dayjs(meeting.createdAt).format('MMM D');
return (
<Flex
align="center"
gap={8}
style={{
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.4,
}}
>
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0, width: 80, whiteSpace: 'nowrap' }}>
<ClockCircleOutlined style={{ marginRight: 2, fontSize: 10 }} />
{timeLabel}
</Text>
<Tooltip title={meeting.title}>
<Text
strong
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{meeting.title}
</Text>
</Tooltip>
{getStatusTag(meeting)}
{meeting.isActive && (
<Tooltip title="Join meeting">
<Button
type="link"
size="small"
icon={<LoginOutlined style={{ fontSize: 12 }} />}
href={`/meet/${meeting.slug}`}
target="_blank"
style={{ padding: 0, height: 'auto', lineHeight: 1 }}
/>
</Tooltip>
)}
</Flex>
);
}
export default function UpcomingMeetingsCard() {
const navigate = useNavigate();
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const fetchMeetings = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<{ meetings: Meeting[] }>('/jitsi/meetings');
const all = res.data.meetings || [];
// Show active meetings first (sorted by startTime), then recently ended
const active = all
.filter((m) => m.isActive)
.sort((a, b) => {
if (a.startTime && b.startTime) return new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
return 0;
});
setTotal(active.length);
setMeetings(active.slice(0, 5));
} catch {
// non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchMeetings();
const interval = setInterval(fetchMeetings, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchMeetings]);
return (
<Card
title={<span style={{ fontSize: 14 }}><VideoCameraOutlined style={{ marginRight: 6, fontSize: 15 }} />Upcoming Meetings</span>}
size="small"
extra={
<Flex align="center" gap={6}>
{total > 5 && (
<Button type="link" size="small" onClick={() => navigate('/app/services/jitsi')} style={{ fontSize: 12, padding: 0 }}>
+{total - 5} more
</Button>
)}
<Button
type="text"
size="small"
onClick={() => navigate('/app/services/jitsi')}
style={{ fontSize: 12, padding: '0 4px' }}
>
View All
</Button>
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchMeetings} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && meetings.length === 0 ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : meetings.length > 0 ? (
<div>
{meetings.map((meeting) => (
<MeetingRow key={meeting.id} meeting={meeting} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No upcoming meetings</Text>
)}
</Card>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip, Progress } from 'antd';
import {
CalendarOutlined,
ScheduleOutlined,
ReloadOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
@ -87,7 +87,7 @@ export default function UpcomingShiftsCard() {
return (
<Card
title={<span style={{ fontSize: 14 }}><CalendarOutlined style={{ marginRight: 6, fontSize: 15 }} />Upcoming Shifts</span>}
title={<span style={{ fontSize: 14 }}><ScheduleOutlined style={{ marginRight: 6, fontSize: 15 }} />Upcoming Shifts</span>}
size="small"
extra={
<Flex align="center" gap={6}>

View File

@ -0,0 +1,95 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Card, Typography, Spin, Flex, Button } from 'antd';
import {
LockOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import MiniDonutChart from '@/components/dashboard/MiniDonutChart';
import type { VaultwardenAdoptionData } from '@/types/api';
const { Text } = Typography;
export default function VaultwardenAdoptionCard() {
const [data, setData] = useState<VaultwardenAdoptionData | null>(null);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<VaultwardenAdoptionData>('/dashboard/vaultwarden-adoption');
if (res.data.error) {
setHasError(true);
} else {
setData(res.data);
setHasError(false);
}
} catch {
setHasError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 10 * 60_000);
return () => clearInterval(interval);
}, [fetchData]);
const donutData = useMemo(() => {
if (!data) return [];
return [
{ name: 'Enabled', value: data.enabled, color: '#52c41a' },
{ name: 'Invited', value: data.invited, color: '#faad14' },
{ name: 'Disabled', value: data.disabled, color: '#ff4d4f' },
];
}, [data]);
if (hasError && !data) return null;
return (
<Card
title={<span style={{ fontSize: 14 }}><LockOutlined style={{ marginRight: 6, fontSize: 15 }} />Vaultwarden</span>}
size="small"
extra={<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchData} />}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && !data ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : data ? (
<Flex gap={12} align="center">
<div style={{ width: 90, flexShrink: 0 }}>
<MiniDonutChart data={donutData} height={90} innerRadius={22} outerRadius={38} />
</div>
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 22 }}>{data.adoptionRate}%</Text>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 6 }}>adoption rate</Text>
<Flex gap={4} wrap="wrap">
<Flex align="center" gap={4}>
<div style={{ width: 8, height: 8, borderRadius: 2, background: '#52c41a' }} />
<Text style={{ fontSize: 12 }}>{data.enabled} enabled</Text>
</Flex>
<Flex align="center" gap={4}>
<div style={{ width: 8, height: 8, borderRadius: 2, background: '#faad14' }} />
<Text style={{ fontSize: 12 }}>{data.invited} invited</Text>
</Flex>
{data.disabled > 0 && (
<Flex align="center" gap={4}>
<div style={{ width: 8, height: 8, borderRadius: 2, background: '#ff4d4f' }} />
<Text style={{ fontSize: 12 }}>{data.disabled} disabled</Text>
</Flex>
)}
</Flex>
<Text type="secondary" style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
{data.total} total users
</Text>
</div>
</Flex>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>Vaultwarden unavailable</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,57 @@
import { Checkbox, Badge } from 'antd';
const VARIANT_BG = {
public: 'rgba(27, 40, 56, 0.92)',
admin: 'rgba(26, 16, 37, 0.92)',
};
interface Props {
showEvents: boolean;
onToggle: (checked: boolean) => void;
eventCount: number;
variant?: 'admin' | 'public';
}
export default function EventLayerToggle({ showEvents, onToggle, eventCount, variant = 'admin' }: Props) {
return (
<div
style={{
position: 'absolute',
top: 10,
left: 12,
zIndex: 1000,
background: VARIANT_BG[variant],
borderRadius: 8,
padding: '8px 12px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.12)',
}}
>
<Checkbox
checked={showEvents}
onChange={(e) => onToggle(e.target.checked)}
style={{ fontSize: 12 }}
>
<span style={{ color: 'rgba(255,255,255,0.85)', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: '#e67e22',
border: '1px solid rgba(255,255,255,0.4)',
flexShrink: 0,
}}
/>
Events
<Badge
count={eventCount}
size="small"
style={{ backgroundColor: '#e67e22' }}
/>
</span>
</Checkbox>
</div>
);
}

View File

@ -0,0 +1,116 @@
import { Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import type { MapEvent } from '@/types/api';
const eventIcon = L.divIcon({
className: '',
html: `<div style="
width: 28px; height: 28px;
background: #e67e22;
border: 2px solid #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
">&#128197;</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
popupAnchor: [0, -16],
});
function formatEventDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString('en-CA', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
function formatEventTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString('en-CA', {
hour: 'numeric',
minute: '2-digit',
});
}
interface Props {
events: MapEvent[];
}
export default function EventMarkers({ events }: Props) {
return (
<>
{events.map((event) => (
<Marker
key={`event-${event.id}`}
position={[event.latitude, event.longitude]}
icon={eventIcon}
>
<Popup>
<div style={{ minWidth: 200, maxWidth: 300 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: '#e67e22', marginBottom: 4 }}>
{event.title}
</div>
<div style={{ fontSize: 12, color: '#555', marginBottom: 4 }}>
{formatEventDate(event.startDatetime)}{' '}
{formatEventTime(event.startDatetime)}
{event.endDatetime && ` ${formatEventTime(event.endDatetime)}`}
</div>
{event.placeName && (
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{event.placeName}
</div>
)}
{event.description && (
<div style={{ fontSize: 11, color: '#777', marginBottom: 6, lineHeight: 1.4 }}>
{event.description}
</div>
)}
{event.tags.length > 0 && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: event.shiftId ? 6 : 0 }}>
{event.tags.map((tag) => (
<span
key={tag}
style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 10,
background: 'rgba(230, 126, 34, 0.15)',
color: '#e67e22',
border: '1px solid rgba(230, 126, 34, 0.3)',
}}
>
{tag}
</span>
))}
</div>
)}
{event.shiftId && (
<a
href="/shifts"
style={{
display: 'inline-block',
marginTop: 6,
fontSize: 12,
fontWeight: 600,
color: '#fff',
background: '#e67e22',
padding: '4px 12px',
borderRadius: 4,
textDecoration: 'none',
}}
>
Sign Up &rarr;
</a>
)}
</div>
</Popup>
</Marker>
))}
</>
);
}

View File

@ -0,0 +1,136 @@
import { useState, useEffect } from 'react';
import { Modal, Tabs, Table, Button, Segmented, Typography, Spin } from 'antd';
import { api } from '@/lib/api';
import type { GalleryAd, AdVariant } from '@/types/gallery-ads';
import { AD_TYPE_LABELS } from '@/types/gallery-ads';
const { Text } = Typography;
export interface AdInsertResult {
type: 'specific' | 'slot';
adId?: number;
adTitle?: string;
variant?: string;
}
interface AdPickerModalProps {
open: boolean;
onCancel: () => void;
onInsert: (result: AdInsertResult) => void;
}
export function AdPickerModal({ open, onCancel, onInsert }: AdPickerModalProps) {
const [ads, setAds] = useState<GalleryAd[]>([]);
const [loading, setLoading] = useState(false);
const [slotVariant, setSlotVariant] = useState<AdVariant>('standard');
useEffect(() => {
if (!open) return;
setLoading(true);
api.get('/gallery-ads/admin', { params: { limit: 100, isActive: 'true' } })
.then(({ data }) => setAds(data.ads || []))
.catch(() => setAds([]))
.finally(() => setLoading(false));
}, [open]);
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 60,
},
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
width: 100,
render: (type: string) => AD_TYPE_LABELS[type as keyof typeof AD_TYPE_LABELS] || type,
},
{
title: 'Variant',
dataIndex: 'variant',
key: 'variant',
width: 100,
},
{
title: '',
key: 'action',
width: 80,
render: (_: unknown, record: GalleryAd) => (
<Button
type="primary"
size="small"
onClick={() => onInsert({ type: 'specific', adId: record.id, adTitle: record.title })}
>
Insert
</Button>
),
},
];
return (
<Modal
open={open}
onCancel={onCancel}
title="Insert Ad"
footer={null}
width={640}
>
<Tabs
items={[
{
key: 'specific',
label: 'Specific Ad',
children: loading ? (
<div style={{ textAlign: 'center', padding: 32 }}><Spin /></div>
) : (
<Table
dataSource={ads}
columns={columns}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 300 }}
/>
),
},
{
key: 'slot',
label: 'Ad Slot (Dynamic)',
children: (
<div style={{ padding: '16px 0' }}>
<Text style={{ display: 'block', marginBottom: 12 }}>
Inserts a dynamic ad slot that rotates between active ads targeted to documentation pages.
</Text>
<Text strong style={{ display: 'block', marginBottom: 8 }}>Variant:</Text>
<Segmented
value={slotVariant}
onChange={(v) => setSlotVariant(v as AdVariant)}
options={[
{ label: 'Standard', value: 'standard' },
{ label: 'Highlight', value: 'highlight' },
{ label: 'Minimal', value: 'minimal' },
]}
style={{ marginBottom: 16 }}
/>
<Button
type="primary"
onClick={() => onInsert({ type: 'slot', variant: slotVariant })}
>
Insert Ad Slot
</Button>
</div>
),
},
]}
/>
</Modal>
);
}

View File

@ -24,6 +24,7 @@ import {
import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color';
import { mediaApi } from '@/lib/media-api';
import { buildHomeUrl } from '@/lib/service-url';
const { Text } = Typography;
@ -166,7 +167,7 @@ export default function MediaSidebar() {
<div
style={{
width: sidebarWidth,
height: '100vh',
height: 'calc(100vh - 56px)',
background: token.colorBgContainer,
borderRight: '1px solid rgba(255,255,255,0.06)',
display: 'flex',
@ -175,7 +176,7 @@ export default function MediaSidebar() {
overflow: 'hidden',
position: 'fixed',
left: 0,
top: 0,
top: 56,
zIndex: 100,
}}
>
@ -232,7 +233,7 @@ export default function MediaSidebar() {
<div style={{ padding: collapsed ? '8px 0' : '8px 12px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<Tooltip title={collapsed ? 'Home' : ''} placement="right">
<a
href={`//${window.location.hostname}:4004`}
href={buildHomeUrl()}
target="_blank"
rel="noopener noreferrer"
style={{

View File

@ -0,0 +1,449 @@
import { useState, useEffect, useCallback } from 'react';
import {
Modal,
Radio,
InputNumber,
Switch,
Select,
Typography,
Space,
Input,
Spin,
Empty,
Row,
Col,
Card,
theme,
Grid,
} from 'antd';
import {
PictureOutlined,
AppstoreOutlined,
BlockOutlined,
SlidersOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { mediaApi } from '../../lib/media-api';
const { Text, Paragraph } = Typography;
export type PhotoInsertVariant = 'single-photo' | 'photo-card' | 'album-grid' | 'album-carousel';
export interface PhotoInsertResult {
variant: PhotoInsertVariant;
album?: { id: number; title: string; photoCount: number };
options: {
size?: string;
alignment?: string;
linkToGallery?: boolean;
columns?: number;
maxPhotos?: number;
showTitle?: boolean;
autoPlay?: boolean;
};
}
interface Album {
id: number;
title: string;
photoCount: number;
coverThumbnailUrl: string | null;
}
interface PhotoInsertModalProps {
open: boolean;
onClose: () => void;
onInsert: (result: PhotoInsertResult) => void;
}
export function PhotoInsertModal({ open, onClose, onInsert }: PhotoInsertModalProps) {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
// Step: 'variant' = pick variant + options, 'album' = pick album
const [step, setStep] = useState<'variant' | 'album'>('variant');
const [variant, setVariant] = useState<PhotoInsertVariant>('single-photo');
// Options for single-photo
const [size, setSize] = useState<string>('large');
const [alignment, setAlignment] = useState<string>('center');
const [linkToGallery, setLinkToGallery] = useState(true);
// Options for album-grid
const [columns, setColumns] = useState<number>(3);
const [maxPhotos, setMaxPhotos] = useState<number>(12);
const [showTitle, setShowTitle] = useState(true);
// Options for album-carousel
const [carouselMaxPhotos, setCarouselMaxPhotos] = useState<number>(20);
const [carouselShowTitle, setCarouselShowTitle] = useState(true);
const [autoPlay, setAutoPlay] = useState(false);
// Album picker state
const [albums, setAlbums] = useState<Album[]>([]);
const [albumsTotal, setAlbumsTotal] = useState(0);
const [albumsLoading, setAlbumsLoading] = useState(false);
const [albumSearch, setAlbumSearch] = useState('');
const [selectedAlbum, setSelectedAlbum] = useState<Album | null>(null);
// Reset when modal opens
useEffect(() => {
if (open) {
setStep('variant');
setVariant('single-photo');
setSize('large');
setAlignment('center');
setLinkToGallery(true);
setColumns(3);
setMaxPhotos(12);
setShowTitle(true);
setCarouselMaxPhotos(20);
setCarouselShowTitle(true);
setAutoPlay(false);
setAlbumSearch('');
setSelectedAlbum(null);
}
}, [open]);
// Fetch albums when album step is shown
const fetchAlbums = useCallback(async (search?: string) => {
setAlbumsLoading(true);
try {
const params = new URLSearchParams({ limit: '48' });
if (search) params.append('search', search);
const res = await mediaApi.get(`/albums?${params.toString()}`);
setAlbums(res.data.albums || []);
setAlbumsTotal(res.data.total || 0);
} catch {
console.error('Failed to fetch albums');
setAlbums([]);
} finally {
setAlbumsLoading(false);
}
}, []);
useEffect(() => {
if (step === 'album') {
fetchAlbums(albumSearch || undefined);
}
}, [step, albumSearch, fetchAlbums]);
const isAlbumVariant = variant === 'album-grid' || variant === 'album-carousel';
const handleOk = () => {
if (step === 'variant') {
if (isAlbumVariant) {
// Go to album picker step
setStep('album');
return;
}
// Photo variants — return result, DocsPage will open PhotoPickerModal
const result: PhotoInsertResult = {
variant,
options: variant === 'single-photo'
? { size, alignment, linkToGallery }
: {},
};
onInsert(result);
onClose();
} else {
// Album step — submit with selected album
if (!selectedAlbum) return;
const options = variant === 'album-grid'
? { columns, maxPhotos, showTitle }
: { maxPhotos: carouselMaxPhotos, showTitle: carouselShowTitle, autoPlay };
const result: PhotoInsertResult = {
variant,
album: {
id: selectedAlbum.id,
title: selectedAlbum.title,
photoCount: selectedAlbum.photoCount,
},
options,
};
onInsert(result);
onClose();
}
};
const handleBack = () => {
setStep('variant');
setSelectedAlbum(null);
};
const cardStyle = (selected: boolean) => ({
padding: '16px',
borderRadius: 8,
border: `2px solid ${selected ? token.colorPrimary : token.colorBorderSecondary}`,
background: selected ? token.colorPrimaryBg : token.colorBgContainer,
cursor: 'pointer',
transition: 'all 0.2s',
});
const mediaThumbnailBase = '/media';
const okDisabled = step === 'album' && !selectedAlbum;
return (
<Modal
title={step === 'variant' ? 'Insert Photo Block' : 'Select Album'}
open={open}
onCancel={step === 'album' ? handleBack : onClose}
onOk={handleOk}
okText={step === 'variant' && isAlbumVariant ? 'Next' : 'Insert'}
okButtonProps={{ disabled: okDisabled }}
cancelText={step === 'album' ? 'Back' : 'Cancel'}
width={isMobile ? '95vw' : step === 'album' ? 700 : 560}
afterClose={() => setStep('variant')}
>
{step === 'variant' ? (
<>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Choose a photo block style to insert into your document.
</Paragraph>
<Radio.Group
value={variant}
onChange={(e) => setVariant(e.target.value)}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{/* Single Photo */}
<div style={cardStyle(variant === 'single-photo')} onClick={() => setVariant('single-photo')}>
<Radio value="single-photo">
<Space>
<PictureOutlined style={{ fontSize: 18, color: '#43cea2' }} />
<div>
<Text strong>Single Photo</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
A standalone image with optional caption and gallery link
</Text>
</div>
</Space>
</Radio>
{variant === 'single-photo' && (
<div style={{ marginTop: 12, marginLeft: 32, display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }}>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Size:</Text>
<Select size="small" value={size} onChange={setSize} style={{ width: 100 }}>
<Select.Option value="small">Small</Select.Option>
<Select.Option value="medium">Medium</Select.Option>
<Select.Option value="large">Large</Select.Option>
</Select>
</div>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Align:</Text>
<Select size="small" value={alignment} onChange={setAlignment} style={{ width: 90 }}>
<Select.Option value="left">Left</Select.Option>
<Select.Option value="center">Center</Select.Option>
<Select.Option value="right">Right</Select.Option>
</Select>
</div>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Gallery link:</Text>
<Switch size="small" checked={linkToGallery} onChange={setLinkToGallery} />
</div>
</div>
)}
</div>
{/* Photo Card */}
<div style={cardStyle(variant === 'photo-card')} onClick={() => setVariant('photo-card')}>
<Radio value="photo-card">
<Space>
<BlockOutlined style={{ fontSize: 18, color: '#185a9d' }} />
<div>
<Text strong>Photo Card</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
A styled card with title, metadata, and gallery link
</Text>
</div>
</Space>
</Radio>
</div>
{/* Album Grid */}
<div style={cardStyle(variant === 'album-grid')} onClick={() => setVariant('album-grid')}>
<Radio value="album-grid">
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#722ed1' }} />
<div>
<Text strong>Album Grid</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
A responsive grid of album thumbnails
</Text>
</div>
</Space>
</Radio>
{variant === 'album-grid' && (
<div style={{ marginTop: 12, marginLeft: 32, display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }}>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Columns:</Text>
<Select size="small" value={columns} onChange={setColumns} style={{ width: 70 }}>
<Select.Option value={2}>2</Select.Option>
<Select.Option value={3}>3</Select.Option>
<Select.Option value={4}>4</Select.Option>
</Select>
</div>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Max photos:</Text>
<InputNumber size="small" min={1} max={50} value={maxPhotos} onChange={(v) => setMaxPhotos(v || 12)} style={{ width: 70 }} />
</div>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Show title:</Text>
<Switch size="small" checked={showTitle} onChange={setShowTitle} />
</div>
</div>
)}
</div>
{/* Album Carousel */}
<div style={cardStyle(variant === 'album-carousel')} onClick={() => setVariant('album-carousel')}>
<Radio value="album-carousel">
<Space>
<SlidersOutlined style={{ fontSize: 18, color: '#eb2f96' }} />
<div>
<Text strong>Album Carousel</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
A slideshow with navigation arrows, dots, and swipe support
</Text>
</div>
</Space>
</Radio>
{variant === 'album-carousel' && (
<div style={{ marginTop: 12, marginLeft: 32, display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }}>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Max photos:</Text>
<InputNumber size="small" min={1} max={50} value={carouselMaxPhotos} onChange={(v) => setCarouselMaxPhotos(v || 20)} style={{ width: 70 }} />
</div>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Show title:</Text>
<Switch size="small" checked={carouselShowTitle} onChange={setCarouselShowTitle} />
</div>
<div>
<Text style={{ fontSize: 12, marginRight: 6 }}>Auto-play:</Text>
<Switch size="small" checked={autoPlay} onChange={setAutoPlay} />
</div>
</div>
)}
</div>
</Space>
</Radio.Group>
</>
) : (
/* Album picker step */
<>
<Input
placeholder="Search albums..."
prefix={<SearchOutlined />}
value={albumSearch}
onChange={(e) => setAlbumSearch(e.target.value)}
allowClear
style={{ marginBottom: 16 }}
/>
{albumsLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : albums.length === 0 ? (
<Empty
description={albumSearch ? 'No albums match your search' : 'No albums found'}
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: 40 }}
>
<Text type="secondary" style={{ fontSize: 12 }}>
Create albums in the Media Library first.
</Text>
</Empty>
) : (
<>
<Row gutter={[12, 12]}>
{albums.map((album) => {
const isSelected = selectedAlbum?.id === album.id;
return (
<Col key={album.id} xs={12} sm={8} md={8}>
<Card
hoverable
cover={
<div
style={{
position: 'relative',
paddingBottom: '66.67%',
background: '#f0f0f0',
overflow: 'hidden',
}}
>
{album.coverThumbnailUrl ? (
<img
src={`${mediaThumbnailBase}${album.coverThumbnailUrl}`}
alt={album.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%)',
}}
>
<AppstoreOutlined style={{ fontSize: 32, color: '#999' }} />
</div>
)}
</div>
}
onClick={() => setSelectedAlbum(album)}
style={{
border: isSelected ? '2px solid #52c41a' : undefined,
}}
>
<Card.Meta
title={
<div
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 13,
}}
>
{album.title}
</div>
}
description={
<Text type="secondary" style={{ fontSize: 11 }}>
{album.photoCount} photo{album.photoCount !== 1 ? 's' : ''}
</Text>
}
/>
</Card>
</Col>
);
})}
</Row>
{albumsTotal > albums.length && (
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 12, fontSize: 12 }}>
Showing {albums.length} of {albumsTotal} albums. Use search to find more.
</Text>
)}
</>
)}
</>
)}
</Modal>
);
}

View File

@ -0,0 +1,399 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
Tabs,
Input,
Select,
Row,
Col,
Card,
Pagination,
Empty,
Spin,
Button,
Tag,
message,
Grid,
} from 'antd';
import {
SearchOutlined,
PictureOutlined,
CheckCircleOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { mediaApi } from '../../lib/media-api';
const { TabPane } = Tabs;
const { Option } = Select;
export interface Photo {
id: number;
title: string | null;
originalFilename: string | null;
format: string | null;
width: number | null;
height: number | null;
orientation: string | null;
producer: string | null;
description: string | null;
thumbnailPath: string | null;
thumbnailUrl: string | null;
isPublished: boolean;
viewCount: number;
createdAt: string;
}
export interface PhotoPickerModalProps {
open: boolean;
onClose: () => void;
onSelect: (photo: Photo) => void;
mode?: 'single' | 'multiple';
title?: string;
}
function formatDimensions(w: number | null, h: number | null): string {
if (!w || !h) return '';
return `${w}\u00D7${h}`;
}
/**
* Photo Picker Modal with Library and Upload tabs.
* Mirrors VideoPickerModal with photo-specific fields and filters.
*/
export const PhotoPickerModal: React.FC<PhotoPickerModalProps> = ({
open,
onClose,
onSelect,
mode = 'single',
title = 'Select Photo',
}) => {
const [activeTab, setActiveTab] = useState('library');
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [loading, setLoading] = useState(false);
const [photos, setPhotos] = useState<Photo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(12);
const [search, setSearch] = useState('');
const [format, setFormat] = useState<string>('');
const [orientation, setOrientation] = useState<string>('');
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
useEffect(() => {
if (open && activeTab === 'library') {
fetchPhotos();
}
}, [open, activeTab, page, search, format, orientation]);
const fetchPhotos = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((page - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (format) params.append('format', format);
if (orientation) params.append('orientation', orientation);
const response = await mediaApi.get(`/photos?${params.toString()}`);
setPhotos(response.data.photos || []);
setTotal(response.data.total || 0);
} catch (error) {
console.error('Failed to fetch photos:', error);
message.error('Failed to load photos');
} finally {
setLoading(false);
}
};
const handlePhotoClick = (photo: Photo) => {
if (mode === 'single') {
onSelect(photo);
onClose();
} else {
setSelectedPhotos((prev) => {
const newSet = new Set(prev);
if (newSet.has(photo.id)) {
newSet.delete(photo.id);
} else {
newSet.add(photo.id);
}
return newSet;
});
}
};
const handleSelectMultiple = () => {
if (selectedPhotos.size === 0) {
message.warning('Please select at least one photo');
return;
}
const selected = photos.filter((p) => selectedPhotos.has(p.id));
selected.forEach((photo) => onSelect(photo));
onClose();
};
const handleFilterReset = () => {
setSearch('');
setFormat('');
setOrientation('');
setPage(1);
};
// Use Vite proxy path for browser-facing URLs (img src, etc.)
const mediaThumbnailBase = '/media';
return (
<Modal
open={open}
onCancel={onClose}
title={title}
width={isMobile ? '95vw' : 900}
footer={
mode === 'multiple' && activeTab === 'library' ? (
<div>
<Button onClick={onClose}>Cancel</Button>
<Button
type="primary"
onClick={handleSelectMultiple}
disabled={selectedPhotos.size === 0}
>
Select ({selectedPhotos.size})
</Button>
</div>
) : null
}
>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane
tab={
<span>
<PictureOutlined />
Library
</span>
}
key="library"
>
{/* Filters */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col span={12}>
<Input
placeholder="Search photos..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
allowClear
/>
</Col>
<Col span={6}>
<Select
placeholder="Format"
value={format || undefined}
onChange={(value) => {
setFormat(value || '');
setPage(1);
}}
style={{ width: '100%' }}
allowClear
>
<Option value="jpeg">JPEG</Option>
<Option value="png">PNG</Option>
<Option value="webp">WebP</Option>
<Option value="gif">GIF</Option>
</Select>
</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>
<Option value="S">Square</Option>
</Select>
</Col>
</Row>
{/* Photo Grid */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : photos.length === 0 ? (
<Empty description="No photos found" style={{ padding: 40 }}>
<Button onClick={handleFilterReset}>Reset Filters</Button>
</Empty>
) : (
<>
<Row gutter={[12, 12]}>
{photos.map((photo) => {
const isSelected = selectedPhotos.has(photo.id);
const dims = formatDimensions(photo.width, photo.height);
return (
<Col key={photo.id} span={6}>
<Card
hoverable
cover={
<div
style={{
position: 'relative',
paddingBottom: '66.67%',
background: '#f0f0f0',
overflow: 'hidden',
}}
>
{photo.thumbnailPath ? (
<img
src={`${mediaThumbnailBase}/photos/${photo.id}/thumbnail`}
alt={photo.title || photo.originalFilename || 'Photo'}
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%)',
}}
>
<PictureOutlined 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>
)}
{photo.format && (
<Tag
style={{
position: 'absolute',
top: 8,
left: 8,
margin: 0,
}}
>
{photo.format.toUpperCase()}
</Tag>
)}
{dims && (
<Tag
style={{
position: 'absolute',
bottom: 8,
right: 8,
margin: 0,
}}
>
{dims}
</Tag>
)}
</div>
}
onClick={() => handlePhotoClick(photo)}
style={{
border: isSelected ? '2px solid #52c41a' : undefined,
}}
>
<Card.Meta
title={
<div
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 13,
}}
>
{photo.title || photo.originalFilename || 'Untitled'}
</div>
}
description={
<div style={{ fontSize: 11, color: '#999' }}>
{photo.orientation === 'H' ? 'Horizontal' : photo.orientation === 'V' ? 'Vertical' : photo.orientation === 'S' ? 'Square' : ''}
{photo.producer && ` \u2022 ${photo.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} photos`}
/>
</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 photos 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 PhotoPickerModal;

View File

@ -0,0 +1,176 @@
import { useState, useRef, useCallback } from 'react';
import { Input, Select, Switch, Button, Space, Typography, message } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { UnifiedPerson, ConnectionType, PeopleListResponse } from '@/types/api';
import { CONNECTION_TYPE_LABELS } from '@/types/api';
const connectionTypeOptions = Object.entries(CONNECTION_TYPE_LABELS).map(([value, label]) => ({
value: value as ConnectionType,
label,
}));
interface ConnectionFormProps {
contactId: string;
onCreated: () => void;
onCancel: () => void;
}
export default function ConnectionForm({ contactId, onCreated, onCancel }: ConnectionFormProps) {
const [searchResults, setSearchResults] = useState<UnifiedPerson[]>([]);
const [searching, setSearching] = useState(false);
const [selectedPerson, setSelectedPerson] = useState<UnifiedPerson | null>(null);
const [connectionType, setConnectionType] = useState<ConnectionType>('HOUSEHOLD');
const [customLabel, setCustomLabel] = useState('');
const [notes, setNotes] = useState('');
const [bidirectional, setBidirectional] = useState(true);
const [creating, setCreating] = useState(false);
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleSearch = useCallback((value: string) => {
clearTimeout(searchTimerRef.current);
if (!value.trim()) {
setSearchResults([]);
return;
}
searchTimerRef.current = setTimeout(async () => {
setSearching(true);
try {
const { data } = await api.get<PeopleListResponse>('/people', {
params: { search: value, limit: 5 },
});
setSearchResults(data.people);
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
}, 300);
}, []);
const handleCreate = async () => {
if (!selectedPerson) {
message.warning('Please select a person to connect with');
return;
}
const [toType, toId] = selectedPerson.id.split(':');
setCreating(true);
try {
await api.post(`/people/contacts/${contactId}/connections`, {
toPersonType: toType,
toPersonId: toId,
type: connectionType,
label: connectionType === 'CUSTOM' ? customLabel || undefined : undefined,
notes: notes || undefined,
isBidirectional: bidirectional,
});
message.success('Connection created');
onCreated();
} catch {
message.error('Failed to create connection');
} finally {
setCreating(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Typography.Text strong style={{ fontSize: 13 }}>
Add Connection
</Typography.Text>
{/* Person search */}
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Find Person
</Typography.Text>
<Select
showSearch
placeholder="Search by name, email, or phone..."
filterOption={false}
onSearch={handleSearch}
loading={searching}
notFoundContent={searching ? 'Searching...' : 'No results'}
style={{ width: '100%' }}
value={selectedPerson?.id}
onChange={(value) => {
const person = searchResults.find((p) => p.id === value);
setSelectedPerson(person || null);
}}
options={searchResults.map((p) => ({
value: p.id,
label: (
<div>
<span style={{ fontWeight: 500 }}>{p.displayName}</span>
{p.email && (
<span style={{ color: '#888', marginLeft: 8, fontSize: 12 }}>
{p.email}
</span>
)}
</div>
),
}))}
suffixIcon={<SearchOutlined />}
/>
</div>
{/* Connection type */}
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Connection Type
</Typography.Text>
<Select
options={connectionTypeOptions}
value={connectionType}
onChange={setConnectionType}
style={{ width: '100%' }}
/>
</div>
{/* Custom label (only when type=CUSTOM) */}
{connectionType === 'CUSTOM' && (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Custom Label
</Typography.Text>
<Input
value={customLabel}
onChange={(e) => setCustomLabel(e.target.value)}
placeholder="e.g., Neighbor, Friend..."
/>
</div>
)}
{/* Notes */}
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Notes (optional)
</Typography.Text>
<Input.TextArea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder="Any context about this connection..."
/>
</div>
{/* Bidirectional toggle */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text style={{ fontSize: 13 }}>Bidirectional</Typography.Text>
<Switch checked={bidirectional} onChange={setBidirectional} size="small" />
</div>
{/* Actions */}
<Space style={{ marginTop: 4 }}>
<Button type="primary" onClick={handleCreate} loading={creating} size="small">
Create
</Button>
<Button onClick={onCancel} size="small">
Cancel
</Button>
</Space>
</div>
);
}

View File

@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
type Node,
type Edge,
type NodeProps,
type Connection,
Handle,
Position,
} from '@xyflow/react';
import dagre from '@dagrejs/dagre';
import '@xyflow/react/dist/style.css';
import { Spin, Empty, Tag, Avatar, Modal, Select, message } from 'antd';
import { api } from '@/lib/api';
import type { GraphData, GraphNode as GNode, GraphEdge, ContactSource, ConnectionType } from '@/types/api';
import {
CONTACT_SOURCE_LABELS,
CONTACT_SOURCE_COLORS,
CONNECTION_TYPE_LABELS,
} from '@/types/api';
const SOURCE_AVATAR_COLORS: Record<ContactSource, string> = {
USER: '#1890ff',
ADDRESS_OCCUPANT: '#52c41a',
CAMPAIGN_SENDER: '#722ed1',
SHIFT_SIGNUP: '#13c2c2',
SMS_CONTACT: '#fa8c16',
DONATION: '#fadb14',
MANUAL: '#8c8c8c',
};
function getInitials(name: string): string {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
const first = parts[0];
const last = parts[parts.length - 1];
if (parts.length >= 2 && first && last) {
return ((first[0] || '') + (last[0] || '')).toUpperCase();
}
return (name[0] || '?').toUpperCase();
}
const NODE_WIDTH = 180;
const NODE_HEIGHT = 70;
/** Custom node component for the graph */
function PersonNode({ data }: NodeProps) {
const nodeData = data as { displayName: string; source: ContactSource; email: string | null };
return (
<div
style={{
padding: '8px 12px',
borderRadius: 8,
background: 'rgba(30, 30, 50, 0.95)',
border: `2px solid ${SOURCE_AVATAR_COLORS[nodeData.source] || '#8c8c8c'}`,
minWidth: NODE_WIDTH,
textAlign: 'center',
cursor: 'pointer',
}}
>
<Handle type="target" position={Position.Top} style={{ background: '#555', width: 6, height: 6 }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar
size={28}
style={{ backgroundColor: SOURCE_AVATAR_COLORS[nodeData.source], fontWeight: 600, fontSize: 11, flexShrink: 0 }}
>
{getInitials(nodeData.displayName)}
</Avatar>
<div style={{ minWidth: 0, textAlign: 'left' }}>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: '#fff',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 120,
}}
>
{nodeData.displayName}
</div>
<Tag
color={CONTACT_SOURCE_COLORS[nodeData.source]}
style={{ fontSize: 9, margin: 0, lineHeight: '14px', padding: '0 4px' }}
>
{CONTACT_SOURCE_LABELS[nodeData.source]}
</Tag>
</div>
</div>
<Handle type="source" position={Position.Bottom} style={{ background: '#555', width: 6, height: 6 }} />
</div>
);
}
const nodeTypes = { person: PersonNode };
const connectionTypeOptions = Object.entries(CONNECTION_TYPE_LABELS).map(([value, label]) => ({
value,
label,
}));
function applyDagreLayout(nodes: Node[], edges: Edge[]): Node[] {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 });
nodes.forEach((node) => {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
return nodes.map((node) => {
const nodeWithPosition = g.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
};
});
}
interface ConnectionGraphProps {
onNodeClick: (personId: string) => void;
filters?: { source?: string; tag?: string; minScore?: number };
}
export default function ConnectionGraph({ onNodeClick, filters }: ConnectionGraphProps) {
const [loading, setLoading] = useState(true);
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]);
const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]);
// Connection creation state
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null);
const [connectionType, setConnectionType] = useState<ConnectionType>('HOUSEHOLD');
const [saving, setSaving] = useState(false);
// Build a lookup from node ID → graph node data (for contactId resolution)
const nodeDataMap = useMemo(() => {
const map = new Map<string, GNode>();
if (graphData) {
for (const gn of graphData.nodes) {
map.set(gn.id, gn);
}
}
return map;
}, [graphData]);
const fetchGraph = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<GraphData>('/people/graph', {
params: filters,
});
setGraphData(data);
} catch {
setGraphData(null);
} finally {
setLoading(false);
}
}, [filters]);
// Keep fetchGraph in a ref so onConnect and handleCreateConnection always have the latest
const fetchGraphRef = useRef(fetchGraph);
fetchGraphRef.current = fetchGraph;
useEffect(() => {
fetchGraph();
}, [fetchGraph]);
useEffect(() => {
if (!graphData || graphData.nodes.length === 0) {
setNodes([]);
setEdges([]);
return;
}
const flowNodes: Node[] = graphData.nodes.map((gn: GNode) => ({
id: gn.id,
type: 'person',
position: { x: 0, y: 0 },
data: {
displayName: gn.displayName,
source: gn.source,
email: gn.email,
},
}));
const flowEdges: Edge[] = graphData.edges.map((ge: GraphEdge) => ({
id: ge.id,
source: ge.source,
target: ge.target,
label: ge.label || CONNECTION_TYPE_LABELS[ge.type] || ge.type,
animated: ge.isBidirectional,
style: { stroke: '#8c8c8c' },
labelStyle: { fontSize: 10, fill: '#aaa' },
labelBgStyle: { fill: 'rgba(20,20,40,0.9)' },
labelBgPadding: [4, 2] as [number, number],
markerEnd: ge.isBidirectional ? undefined : { type: 'arrowclosed' as const, color: '#8c8c8c' },
}));
const layoutNodes = applyDagreLayout(flowNodes, flowEdges);
setNodes(layoutNodes);
setEdges(flowEdges);
}, [graphData]); // eslint-disable-line react-hooks/exhaustive-deps
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
onNodeClick(node.id);
},
[onNodeClick],
);
/** When user finishes dragging a connection between two handles */
const handleConnect = useCallback((connection: Connection) => {
if (!connection.source || !connection.target) return;
if (connection.source === connection.target) return;
setPendingConnection(connection);
setConnectionType('HOUSEHOLD');
}, []);
/** Persist the connection via API */
const handleCreateConnection = useCallback(async () => {
if (!pendingConnection?.source || !pendingConnection?.target) return;
const sourceNode = nodeDataMap.get(pendingConnection.source);
const targetNode = nodeDataMap.get(pendingConnection.target);
if (!sourceNode || !targetNode) return;
// Determine which end is a Contact — the API requires a contactId as the "from" side
let fromContactId = sourceNode.contactId;
let toId = pendingConnection.target;
// If source isn't a contact but target is, swap
if (!fromContactId && targetNode.contactId) {
fromContactId = targetNode.contactId;
toId = pendingConnection.source;
}
if (!fromContactId) {
message.warning('At least one person must be a managed contact to create a connection.');
setPendingConnection(null);
return;
}
// Parse the target unified ID (e.g. "contact:abc123" or "user:xyz")
const colonIdx = toId.indexOf(':');
const toPersonType = colonIdx >= 0 ? toId.substring(0, colonIdx) : 'contact';
const toPersonId = colonIdx >= 0 ? toId.substring(colonIdx + 1) : toId;
setSaving(true);
try {
await api.post(`/people/contacts/${fromContactId}/connections`, {
toPersonType,
toPersonId,
type: connectionType,
isBidirectional: true,
});
message.success('Connection created');
fetchGraphRef.current();
} catch {
message.error('Failed to create connection');
} finally {
setSaving(false);
setPendingConnection(null);
}
}, [pendingConnection, nodeDataMap, connectionType]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
if (!graphData || graphData.nodes.length === 0) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Empty
description="No people found. Add contacts or register users to see the graph."
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
);
}
return (
<div style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onConnect={handleConnect}
nodeTypes={nodeTypes}
fitView
minZoom={0.2}
maxZoom={2}
connectionLineStyle={{ stroke: '#8c8c8c', strokeWidth: 2 }}
proOptions={{ hideAttribution: true }}
>
<Background color="rgba(255,255,255,0.05)" gap={20} />
<Controls
showInteractive={false}
style={{ background: 'rgba(30,30,50,0.9)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}
/>
<MiniMap
nodeColor={(node) => {
const src = (node.data as { source?: ContactSource })?.source;
return src ? SOURCE_AVATAR_COLORS[src] : '#8c8c8c';
}}
maskColor="rgba(0,0,0,0.6)"
style={{ background: 'rgba(20,20,40,0.9)', borderRadius: 6 }}
/>
</ReactFlow>
{/* Connection type picker modal */}
<Modal
title="Create Connection"
open={!!pendingConnection}
onOk={handleCreateConnection}
onCancel={() => setPendingConnection(null)}
confirmLoading={saving}
okText="Create"
width={360}
>
<div style={{ marginBottom: 8 }}>
<label style={{ fontSize: 13, fontWeight: 500 }}>Connection Type</label>
</div>
<Select
value={connectionType}
onChange={setConnectionType}
options={connectionTypeOptions}
style={{ width: '100%' }}
/>
</Modal>
</div>
);
}

View File

@ -0,0 +1,243 @@
import { useState, useEffect, useCallback } from 'react';
import {
Button,
Input,
Checkbox,
Tag,
Space,
Typography,
message,
Popconfirm,
Spin,
Tooltip,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EnvironmentOutlined,
StarFilled,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { ContactAddressLink, AddContactAddressPayload } from '@/types/api';
interface ContactAddressPanelProps {
contactId: string;
}
export default function ContactAddressPanel({ contactId }: ContactAddressPanelProps) {
const [addresses, setAddresses] = useState<ContactAddressLink[]>([]);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
// Form state
const [formAddress, setFormAddress] = useState('');
const [formUnit, setFormUnit] = useState('');
const [formPrimary, setFormPrimary] = useState(false);
const fetchAddresses = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<{ addresses: ContactAddressLink[] }>(
`/people/contacts/${contactId}/addresses`,
);
setAddresses(data.addresses);
} catch {
// Silently fail — addresses section is supplementary
setAddresses([]);
} finally {
setLoading(false);
}
}, [contactId]);
useEffect(() => {
fetchAddresses();
}, [fetchAddresses]);
const handleAdd = async () => {
if (!formAddress.trim()) {
message.warning('Street address is required');
return;
}
setSaving(true);
try {
const payload: AddContactAddressPayload = {
address: formAddress.trim(),
unitNumber: formUnit.trim() || undefined,
isPrimary: formPrimary,
addToMap: true,
};
await api.post(`/people/contacts/${contactId}/addresses`, payload);
message.success('Address added');
setFormAddress('');
setFormUnit('');
setFormPrimary(false);
setShowForm(false);
fetchAddresses();
} catch {
message.error('Failed to add address');
} finally {
setSaving(false);
}
};
const handleRemove = async (linkId: string) => {
try {
await api.delete(`/people/contacts/${contactId}/addresses/${linkId}`);
message.success('Address unlinked');
fetchAddresses();
} catch {
message.error('Failed to remove address');
}
};
if (loading && addresses.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 12 }}>
<Spin size="small" />
</div>
);
}
return (
<div>
{/* Address list */}
{addresses.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
{addresses.map((ca) => (
<div
key={ca.id}
style={{
padding: '8px 12px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<EnvironmentOutlined style={{ color: 'rgba(255,255,255,0.45)', fontSize: 14 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13 }}>
{ca.address.location.address}
{ca.address.unitNumber && (
<span style={{ color: 'rgba(255,255,255,0.45)' }}>
{' '}Unit {ca.address.unitNumber}
</span>
)}
</div>
<Space size={4} style={{ marginTop: 2 }}>
{ca.isPrimary && (
<Tag color="gold" style={{ margin: 0, fontSize: 10, lineHeight: '16px' }}>
<StarFilled style={{ marginRight: 2 }} />Primary
</Tag>
)}
{ca.address.location.geocodeConfidence != null && (
<Typography.Text
type="secondary"
style={{ fontSize: 11 }}
>
{ca.address.location.geocodeConfidence}% geocoded
</Typography.Text>
)}
</Space>
</div>
<Space size={4}>
<Tooltip title="View on Map">
<Button
type="text"
size="small"
icon={<EnvironmentOutlined />}
onClick={() => {
window.open(`/app/map/locations`, '_blank');
}}
/>
</Tooltip>
<Popconfirm
title="Unlink this address?"
description="The location will remain on the map."
onConfirm={() => handleRemove(ca.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
))}
</div>
)}
{/* Add address form */}
{showForm ? (
<div
style={{
padding: 12,
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Street Address *
</Typography.Text>
<Input
value={formAddress}
onChange={(e) => setFormAddress(e.target.value)}
placeholder="123 Main St, City, Province"
onPressEnter={handleAdd}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Unit / Apt #
</Typography.Text>
<Input
value={formUnit}
onChange={(e) => setFormUnit(e.target.value)}
placeholder="e.g. 4B"
style={{ width: 120 }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Checkbox
checked={formPrimary}
onChange={(e) => setFormPrimary(e.target.checked)}
>
<Typography.Text style={{ fontSize: 13 }}>Set as primary address</Typography.Text>
</Checkbox>
</div>
<Space>
<Button
type="primary"
size="small"
onClick={handleAdd}
loading={saving}
>
Add
</Button>
<Button
size="small"
onClick={() => {
setShowForm(false);
setFormAddress('');
setFormUnit('');
setFormPrimary(false);
}}
>
Cancel
</Button>
</Space>
</div>
) : (
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setShowForm(true)}
>
Add Address
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,248 @@
import { useState, useEffect, useCallback } from 'react';
import {
Button,
Input,
Select,
Checkbox,
Tag,
Space,
Typography,
message,
Popconfirm,
Spin,
Tooltip,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
MailOutlined,
StarFilled,
StarOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { ContactEmailLink } from '@/types/api';
interface ContactEmailPanelProps {
contactId: string;
}
const LABEL_OPTIONS = [
{ value: 'Personal', label: 'Personal' },
{ value: 'Work', label: 'Work' },
{ value: 'Campaign', label: 'Campaign' },
{ value: 'Other', label: 'Other' },
];
export default function ContactEmailPanel({ contactId }: ContactEmailPanelProps) {
const [emails, setEmails] = useState<ContactEmailLink[]>([]);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
// Form state
const [formEmail, setFormEmail] = useState('');
const [formLabel, setFormLabel] = useState<string | undefined>(undefined);
const [formPrimary, setFormPrimary] = useState(false);
const fetchEmails = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<{ emails: ContactEmailLink[] }>(
`/people/contacts/${contactId}/emails`,
);
setEmails(data.emails);
} catch {
setEmails([]);
} finally {
setLoading(false);
}
}, [contactId]);
useEffect(() => {
fetchEmails();
}, [fetchEmails]);
const handleAdd = async () => {
if (!formEmail.trim()) {
message.warning('Email address is required');
return;
}
setSaving(true);
try {
await api.post(`/people/contacts/${contactId}/emails`, {
email: formEmail.trim(),
label: formLabel || undefined,
isPrimary: formPrimary,
});
message.success('Email added');
setFormEmail('');
setFormLabel(undefined);
setFormPrimary(false);
setShowForm(false);
fetchEmails();
} catch {
message.error('Failed to add email');
} finally {
setSaving(false);
}
};
const handleRemove = async (emailId: string) => {
try {
await api.delete(`/people/contacts/${contactId}/emails/${emailId}`);
message.success('Email removed');
fetchEmails();
} catch {
message.error('Failed to remove email');
}
};
const handleSetPrimary = async (emailId: string) => {
try {
await api.put(`/people/contacts/${contactId}/emails/${emailId}/primary`);
message.success('Primary email updated');
fetchEmails();
} catch {
message.error('Failed to set primary email');
}
};
if (loading && emails.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 12 }}>
<Spin size="small" />
</div>
);
}
return (
<div>
{/* Email list */}
{emails.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
{emails.map((ce) => (
<div
key={ce.id}
style={{
padding: '8px 12px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<MailOutlined style={{ color: 'rgba(255,255,255,0.45)', fontSize: 14 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13 }}>{ce.email}</div>
<Space size={4} style={{ marginTop: 2 }}>
{ce.isPrimary && (
<Tag color="gold" style={{ margin: 0, fontSize: 10, lineHeight: '16px' }}>
<StarFilled style={{ marginRight: 2 }} />Primary
</Tag>
)}
{ce.label && (
<Tag style={{ margin: 0, fontSize: 10, lineHeight: '16px' }}>{ce.label}</Tag>
)}
</Space>
</div>
<Space size={4}>
{!ce.isPrimary && (
<Tooltip title="Set as Primary">
<Button
type="text"
size="small"
icon={<StarOutlined />}
onClick={() => handleSetPrimary(ce.id)}
/>
</Tooltip>
)}
<Popconfirm
title="Remove this email?"
onConfirm={() => handleRemove(ce.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
))}
</div>
)}
{/* Add email form */}
{showForm ? (
<div
style={{
padding: 12,
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Email Address *
</Typography.Text>
<Input
value={formEmail}
onChange={(e) => setFormEmail(e.target.value)}
placeholder="jane@example.com"
onPressEnter={handleAdd}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Label
</Typography.Text>
<Select
value={formLabel}
onChange={setFormLabel}
options={LABEL_OPTIONS}
allowClear
placeholder="Select label..."
style={{ width: '100%' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Checkbox
checked={formPrimary}
onChange={(e) => setFormPrimary(e.target.checked)}
>
<Typography.Text style={{ fontSize: 13 }}>Set as primary email</Typography.Text>
</Checkbox>
</div>
<Space>
<Button
type="primary"
size="small"
onClick={handleAdd}
loading={saving}
>
Add
</Button>
<Button
size="small"
onClick={() => {
setShowForm(false);
setFormEmail('');
setFormLabel(undefined);
setFormPrimary(false);
}}
>
Cancel
</Button>
</Space>
</div>
) : (
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setShowForm(true)}
>
Add Email
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,248 @@
import { useState, useEffect, useCallback } from 'react';
import {
Button,
Input,
Select,
Checkbox,
Tag,
Space,
Typography,
message,
Popconfirm,
Spin,
Tooltip,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
PhoneOutlined,
StarFilled,
StarOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { ContactPhoneLink } from '@/types/api';
interface ContactPhonePanelProps {
contactId: string;
}
const LABEL_OPTIONS = [
{ value: 'Mobile', label: 'Mobile' },
{ value: 'Home', label: 'Home' },
{ value: 'Work', label: 'Work' },
{ value: 'Other', label: 'Other' },
];
export default function ContactPhonePanel({ contactId }: ContactPhonePanelProps) {
const [phones, setPhones] = useState<ContactPhoneLink[]>([]);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
// Form state
const [formPhone, setFormPhone] = useState('');
const [formLabel, setFormLabel] = useState<string | undefined>(undefined);
const [formPrimary, setFormPrimary] = useState(false);
const fetchPhones = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<{ phones: ContactPhoneLink[] }>(
`/people/contacts/${contactId}/phones`,
);
setPhones(data.phones);
} catch {
setPhones([]);
} finally {
setLoading(false);
}
}, [contactId]);
useEffect(() => {
fetchPhones();
}, [fetchPhones]);
const handleAdd = async () => {
if (!formPhone.trim()) {
message.warning('Phone number is required');
return;
}
setSaving(true);
try {
await api.post(`/people/contacts/${contactId}/phones`, {
phone: formPhone.trim(),
label: formLabel || undefined,
isPrimary: formPrimary,
});
message.success('Phone added');
setFormPhone('');
setFormLabel(undefined);
setFormPrimary(false);
setShowForm(false);
fetchPhones();
} catch {
message.error('Failed to add phone');
} finally {
setSaving(false);
}
};
const handleRemove = async (phoneId: string) => {
try {
await api.delete(`/people/contacts/${contactId}/phones/${phoneId}`);
message.success('Phone removed');
fetchPhones();
} catch {
message.error('Failed to remove phone');
}
};
const handleSetPrimary = async (phoneId: string) => {
try {
await api.put(`/people/contacts/${contactId}/phones/${phoneId}/primary`);
message.success('Primary phone updated');
fetchPhones();
} catch {
message.error('Failed to set primary phone');
}
};
if (loading && phones.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 12 }}>
<Spin size="small" />
</div>
);
}
return (
<div>
{/* Phone list */}
{phones.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
{phones.map((cp) => (
<div
key={cp.id}
style={{
padding: '8px 12px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<PhoneOutlined style={{ color: 'rgba(255,255,255,0.45)', fontSize: 14 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13 }}>{cp.phone}</div>
<Space size={4} style={{ marginTop: 2 }}>
{cp.isPrimary && (
<Tag color="gold" style={{ margin: 0, fontSize: 10, lineHeight: '16px' }}>
<StarFilled style={{ marginRight: 2 }} />Primary
</Tag>
)}
{cp.label && (
<Tag style={{ margin: 0, fontSize: 10, lineHeight: '16px' }}>{cp.label}</Tag>
)}
</Space>
</div>
<Space size={4}>
{!cp.isPrimary && (
<Tooltip title="Set as Primary">
<Button
type="text"
size="small"
icon={<StarOutlined />}
onClick={() => handleSetPrimary(cp.id)}
/>
</Tooltip>
)}
<Popconfirm
title="Remove this phone?"
onConfirm={() => handleRemove(cp.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
))}
</div>
)}
{/* Add phone form */}
{showForm ? (
<div
style={{
padding: 12,
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Phone Number *
</Typography.Text>
<Input
value={formPhone}
onChange={(e) => setFormPhone(e.target.value)}
placeholder="+1 555 000 0000"
onPressEnter={handleAdd}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Label
</Typography.Text>
<Select
value={formLabel}
onChange={setFormLabel}
options={LABEL_OPTIONS}
allowClear
placeholder="Select label..."
style={{ width: '100%' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<Checkbox
checked={formPrimary}
onChange={(e) => setFormPrimary(e.target.checked)}
>
<Typography.Text style={{ fontSize: 13 }}>Set as primary phone</Typography.Text>
</Checkbox>
</div>
<Space>
<Button
type="primary"
size="small"
onClick={handleAdd}
loading={saving}
>
Add
</Button>
<Button
size="small"
onClick={() => {
setShowForm(false);
setFormPhone('');
setFormLabel(undefined);
setFormPrimary(false);
}}
>
Cancel
</Button>
</Space>
</div>
) : (
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setShowForm(true)}
>
Add Phone
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,139 @@
import { Modal, Form, Input, Select, Switch, Button, Typography, message, Space } from 'antd';
import { useState } from 'react';
import { CopyOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { UserRole, CreateUserFromContactResponse } from '@/types/api';
interface CreateUserFromContactModalProps {
open: boolean;
contactId: string;
contactEmail: string;
contactName: string;
onClose: () => void;
onCreated: () => void;
}
const roleOptions: { value: UserRole; label: string }[] = [
{ value: 'USER', label: 'User' },
{ value: 'TEMP', label: 'Temp' },
{ value: 'MAP_ADMIN', label: 'Map Admin' },
{ value: 'INFLUENCE_ADMIN', label: 'Influence Admin' },
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
];
function generatePassword(): string {
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lower = 'abcdefghijklmnopqrstuvwxyz';
const digits = '0123456789';
const all = upper + lower + digits + '!@#$%&*';
const pick = (charset: string) => charset[Math.floor(Math.random() * charset.length)];
// Guarantee at least one of each required class
const parts = [pick(upper), pick(lower), pick(digits)];
for (let i = 0; i < 13; i++) parts.push(pick(all));
// Shuffle
for (let i = parts.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[parts[i], parts[j]] = [parts[j], parts[i]];
}
return parts.join('');
}
export default function CreateUserFromContactModal({
open, contactId, contactEmail, contactName, onClose, onCreated,
}: CreateUserFromContactModalProps) {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const handleGenerate = () => {
const pw = generatePassword();
form.setFieldsValue({ password: pw });
};
const handleSubmit = async (values: { password: string; role: UserRole; sendWelcomeEmail: boolean }) => {
setSubmitting(true);
try {
const { data } = await api.post<CreateUserFromContactResponse>(
`/people/contacts/${contactId}/create-user`,
{
password: values.password,
role: values.role,
sendWelcomeEmail: values.sendWelcomeEmail,
},
);
const actionMsg = data.action === 'linked'
? 'Existing user account linked to contact'
: 'User account created and linked';
message.success(actionMsg);
form.resetFields();
onCreated();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to create user account';
message.error(msg);
} finally {
setSubmitting(false);
}
};
return (
<Modal
title="Create User Account"
open={open}
onCancel={() => { form.resetFields(); onClose(); }}
footer={null}
destroyOnHidden
width={480}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Contact</Typography.Text>
<div style={{ fontWeight: 500 }}>{contactName}</div>
<div style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>{contactEmail}</div>
</div>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{ role: 'USER', sendWelcomeEmail: false }}
>
<Form.Item
name="password"
label="Password"
rules={[
{ required: true, message: 'Password is required' },
{ min: 12, message: 'Minimum 12 characters' },
{ pattern: /[A-Z]/, message: 'Must contain an uppercase letter' },
{ pattern: /[a-z]/, message: 'Must contain a lowercase letter' },
{ pattern: /[0-9]/, message: 'Must contain a digit' },
]}
>
<Space.Compact style={{ width: '100%' }}>
<Input.Password style={{ flex: 1 }} placeholder="Min 12 chars, upper+lower+digit" />
<Button icon={<CopyOutlined />} onClick={handleGenerate} title="Generate random password">
Generate
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="role" label="Role">
<Select options={roleOptions} />
</Form.Item>
<Form.Item name="sendWelcomeEmail" label="Send Welcome Email" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space>
<Button type="primary" htmlType="submit" loading={submitting}>
Create Account
</Button>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -0,0 +1,218 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
List,
Typography,
Tag,
Button,
Space,
Avatar,
Spin,
Empty,
Progress,
Grid,
message,
} from 'antd';
import { MergeCellsOutlined, CloseOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { UnifiedPerson, DuplicatePair } from '@/types/api';
import { CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS } from '@/types/api';
interface DuplicateDetectionDrawerProps {
open: boolean;
onClose: () => void;
onMerge: (personA: UnifiedPerson, personB: UnifiedPerson) => void;
}
const MATCH_TYPE_COLORS: Record<string, string> = {
email: 'blue',
phone: 'green',
name: 'orange',
};
export default function DuplicateDetectionDrawer({ open, onClose, onMerge }: DuplicateDetectionDrawerProps) {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [loading, setLoading] = useState(false);
const [duplicates, setDuplicates] = useState<DuplicatePair[]>([]);
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const fetchDuplicates = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<{ duplicates: DuplicatePair[] }>('/people/duplicates');
setDuplicates(data.duplicates);
setDismissedIds(new Set());
} catch {
message.error('Failed to detect duplicates');
setDuplicates([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
fetchDuplicates();
}
}, [open, fetchDuplicates]);
const handleDismiss = (pairKey: string) => {
setDismissedIds((prev) => new Set([...prev, pairKey]));
};
const getPairKey = (pair: DuplicatePair): string => {
return `${pair.personA.id}__${pair.personB.id}`;
};
const visibleDuplicates = duplicates.filter((pair) => !dismissedIds.has(getPairKey(pair)));
return (
<Drawer
title="Duplicate Detection"
open={open}
placement="right"
width={isMobile ? '100%' : 560}
onClose={onClose}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnHidden
>
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
Scanning for potential duplicates...
</Typography.Text>
</div>
) : visibleDuplicates.length === 0 ? (
<Empty
description={
duplicates.length > 0
? 'All duplicates have been dismissed or resolved.'
: 'No potential duplicates detected.'
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
Found {visibleDuplicates.length} potential duplicate pair{visibleDuplicates.length !== 1 ? 's' : ''}.
Review each pair and merge or dismiss.
</Typography.Text>
<List
dataSource={visibleDuplicates}
renderItem={(pair) => {
const pairKey = getPairKey(pair);
return (
<List.Item
style={{
padding: 16,
marginBottom: 12,
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<div style={{ width: '100%' }}>
{/* Match info */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<Space size={8}>
<Tag color={MATCH_TYPE_COLORS[pair.matchType] || 'default'}>
{pair.matchType.toUpperCase()} match
</Tag>
<Progress
type="circle"
percent={pair.confidence}
size={28}
strokeColor={pair.confidence >= 80 ? '#52c41a' : pair.confidence >= 50 ? '#faad14' : '#ff4d4f'}
format={(p) => <span style={{ fontSize: 9 }}>{p}%</span>}
/>
</Space>
<Space size={4}>
<Button
type="primary"
size="small"
icon={<MergeCellsOutlined />}
onClick={() => onMerge(pair.personA, pair.personB)}
>
Merge
</Button>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => handleDismiss(pairKey)}
>
Dismiss
</Button>
</Space>
</div>
{/* Side-by-side people */}
<div style={{ display: 'flex', gap: 12 }}>
<PersonSummary person={pair.personA} />
<div
style={{
display: 'flex',
alignItems: 'center',
color: 'rgba(255,255,255,0.25)',
fontSize: 18,
padding: '0 4px',
}}
>
=
</div>
<PersonSummary person={pair.personB} />
</div>
</div>
</List.Item>
);
}}
/>
</>
)}
</Drawer>
);
}
function PersonSummary({ person }: { person: UnifiedPerson }) {
return (
<div
style={{
flex: 1,
padding: 8,
background: 'rgba(255,255,255,0.03)',
borderRadius: 6,
minWidth: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Avatar size={24} style={{ backgroundColor: '#722ed1', fontSize: 10 }}>
{person.displayName?.[0]?.toUpperCase() || '?'}
</Avatar>
<Typography.Text strong style={{ fontSize: 13 }} ellipsis>
{person.displayName}
</Typography.Text>
</div>
<div style={{ fontSize: 11, color: '#888' }}>
{person.email && <div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.email}</div>}
{person.phone && <div>{person.phone}</div>}
<Tag
color={CONTACT_SOURCE_COLORS[person.source]}
style={{ fontSize: 9, margin: '4px 0 0', padding: '0 4px' }}
>
{CONTACT_SOURCE_LABELS[person.source]}
</Tag>
</div>
</div>
);
}

View File

@ -0,0 +1,189 @@
import { Row, Col, Progress, Typography } from 'antd';
import {
MailOutlined,
EnvironmentOutlined,
DollarOutlined,
PhoneOutlined,
PlaySquareOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { EngagementSummary } from '@/types/api';
interface EngagementSummaryPanelProps {
engagement: EngagementSummary;
}
interface StatItemProps {
icon: React.ReactNode;
label: string;
value: number | string;
color: string;
}
function StatItem({ icon, label, value, color }: StatItemProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<span style={{ color, fontSize: 16 }}>{icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
{label}
</Typography.Text>
<Typography.Text strong style={{ fontSize: 14 }}>
{value}
</Typography.Text>
</div>
</div>
);
}
export default function EngagementSummaryPanel({ engagement }: EngagementSummaryPanelProps) {
const hasInfluence = engagement.influence.emailsSent > 0 || engagement.influence.responsesSubmitted > 0;
const hasMap = engagement.map.shiftsSignedUp > 0 || engagement.map.canvassVisits > 0;
const hasPayments = engagement.payments.donationCount > 0;
const hasSms = engagement.sms.conversations > 0;
const hasMedia = engagement.media.videoViews > 0;
const hasAnyData = hasInfluence || hasMap || hasPayments || hasSms || hasMedia;
const score = engagement.overall.engagementScore;
return (
<div>
{/* Score and dates */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 12 }}>
<Progress
type="circle"
percent={score}
size={56}
strokeColor={score >= 70 ? '#52c41a' : score >= 40 ? '#faad14' : '#ff4d4f'}
format={(p) => <span style={{ fontSize: 14, fontWeight: 600 }}>{p}</span>}
/>
<div>
<Typography.Text strong style={{ display: 'block', fontSize: 14 }}>
Engagement Score
</Typography.Text>
<div style={{ display: 'flex', gap: 16, marginTop: 2 }}>
{engagement.overall.firstSeen && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
First seen: {dayjs(engagement.overall.firstSeen).format('MMM D, YYYY')}
</Typography.Text>
)}
{engagement.overall.lastSeen && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
Last seen: {dayjs(engagement.overall.lastSeen).format('MMM D, YYYY')}
</Typography.Text>
)}
</div>
</div>
</div>
{!hasAnyData ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
No engagement data recorded yet.
</Typography.Text>
) : (
<Row gutter={[8, 8]}>
{hasInfluence && (
<>
<Col xs={12}>
<StatItem
icon={<MailOutlined />}
label="Emails Sent"
value={engagement.influence.emailsSent}
color="#1890ff"
/>
</Col>
<Col xs={12}>
<StatItem
icon={<MailOutlined />}
label="Responses"
value={engagement.influence.responsesSubmitted}
color="#52c41a"
/>
</Col>
</>
)}
{hasMap && (
<>
<Col xs={12}>
<StatItem
icon={<EnvironmentOutlined />}
label="Shift Signups"
value={engagement.map.shiftsSignedUp}
color="#13c2c2"
/>
</Col>
<Col xs={12}>
<StatItem
icon={<EnvironmentOutlined />}
label="Canvass Visits"
value={engagement.map.canvassVisits}
color="#722ed1"
/>
</Col>
</>
)}
{hasPayments && (
<>
<Col xs={12}>
<StatItem
icon={<DollarOutlined />}
label="Donations"
value={engagement.payments.donationCount}
color="#fadb14"
/>
</Col>
<Col xs={12}>
<StatItem
icon={<DollarOutlined />}
label="Total Donated"
value={`$${engagement.payments.totalDonatedCAD.toFixed(2)}`}
color="#fadb14"
/>
</Col>
</>
)}
{hasSms && (
<>
<Col xs={12}>
<StatItem
icon={<PhoneOutlined />}
label="Conversations"
value={engagement.sms.conversations}
color="#fa8c16"
/>
</Col>
<Col xs={12}>
<StatItem
icon={<PhoneOutlined />}
label="SMS Received"
value={engagement.sms.messagesReceived}
color="#fa8c16"
/>
</Col>
</>
)}
{hasMedia && (
<Col xs={12}>
<StatItem
icon={<PlaySquareOutlined />}
label="Video Views"
value={engagement.media.videoViews}
color="#a0d911"
/>
</Col>
)}
</Row>
)}
</div>
);
}

View File

@ -0,0 +1,163 @@
import { useState, useEffect, useCallback } from 'react';
import { List, Avatar, Typography, Tag, Button, Spin, Empty, Space, message } from 'antd';
import { HomeOutlined, StarFilled, TeamOutlined } from '@ant-design/icons';
import type { HouseholdMember } from '@/types/api';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
import { api } from '@/lib/api';
interface HouseholdPanelProps {
personId: string;
addressId?: string;
}
interface HouseholdResponse {
locationId: string;
address: string;
members: HouseholdMember[];
}
export default function HouseholdPanel({ personId }: HouseholdPanelProps) {
const [loading, setLoading] = useState(true);
const [households, setHouseholds] = useState<HouseholdResponse[]>([]);
const [detectingHousehold, setDetectingHousehold] = useState(false);
const parsePersonId = (id: string): { type: string; sourceId: string } => {
const colonIdx = id.indexOf(':');
if (colonIdx === -1) return { type: 'contact', sourceId: id };
return { type: id.substring(0, colonIdx), sourceId: id.substring(colonIdx + 1) };
};
const fetchHousehold = useCallback(async () => {
setLoading(true);
try {
const { type, sourceId } = parsePersonId(personId);
const { data } = await api.get<{ households: HouseholdResponse[] }>(
`/people/${type}/${sourceId}/household`,
);
setHouseholds(data.households || []);
} catch {
setHouseholds([]);
} finally {
setLoading(false);
}
}, [personId]);
useEffect(() => {
fetchHousehold();
}, [fetchHousehold]);
const handleDetectHousehold = async (locationId: string) => {
setDetectingHousehold(true);
try {
await api.post(`/people/household/${locationId}/detect`);
message.success('Household connections created');
fetchHousehold();
} catch {
message.error('Failed to detect household connections');
} finally {
setDetectingHousehold(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
);
}
if (households.length === 0) {
return (
<Empty
description="No address data associated with this person."
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
);
}
return (
<div>
{households.map((household) => (
<div key={household.locationId} style={{ marginBottom: 20 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
padding: '8px 12px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<Space>
<HomeOutlined style={{ color: '#52c41a' }} />
<Typography.Text strong>{household.address}</Typography.Text>
</Space>
<Button
size="small"
icon={<TeamOutlined />}
onClick={() => handleDetectHousehold(household.locationId)}
loading={detectingHousehold}
>
Detect Connections
</Button>
</div>
{household.members.length === 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
No other members found at this address.
</Typography.Text>
) : (
<List
size="small"
dataSource={household.members}
renderItem={(member) => (
<List.Item>
<List.Item.Meta
avatar={
<Avatar size={28} style={{ backgroundColor: '#722ed1', fontSize: 11 }}>
{member.person.displayName?.[0]?.toUpperCase() || '?'}
</Avatar>
}
title={
<Space size={6}>
<span style={{ fontSize: 13 }}>{member.person.displayName}</span>
{member.unitNumber && (
<Tag style={{ fontSize: 10, margin: 0 }}>Unit {member.unitNumber}</Tag>
)}
{member.person.supportLevel && (
<Tag
color={SUPPORT_LEVEL_COLORS[member.person.supportLevel]}
style={{ fontSize: 10, margin: 0 }}
>
{SUPPORT_LEVEL_LABELS[member.person.supportLevel]}
</Tag>
)}
{member.person.isManaged && (
<StarFilled style={{ color: '#faad14', fontSize: 12 }} />
)}
{member.isPrimary && (
<Tag color="blue" style={{ fontSize: 10, margin: 0 }}>
Primary
</Tag>
)}
</Space>
}
description={
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{member.person.email || member.person.phone || 'No contact info'}
</Typography.Text>
}
/>
</List.Item>
)}
/>
)}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,306 @@
import { useState, useRef, useCallback } from 'react';
import {
Modal,
Select,
Typography,
Radio,
Tag,
Space,
Button,
Divider,
message,
} from 'antd';
import { SearchOutlined, SwapOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { Contact, UnifiedPerson, PeopleListResponse, MergeContactPayload } from '@/types/api';
import { SUPPORT_LEVEL_LABELS, CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS } from '@/types/api';
interface MergeContactModalProps {
open: boolean;
targetContact: Contact;
onClose: () => void;
onMerged: () => void;
}
type MergeField = 'displayName' | 'email' | 'phone' | 'supportLevel' | 'tags' | 'notes';
export default function MergeContactModal({ open, targetContact, onClose, onMerged }: MergeContactModalProps) {
const [searchResults, setSearchResults] = useState<UnifiedPerson[]>([]);
const [searching, setSearching] = useState(false);
const [sourcePerson, setSourcePerson] = useState<UnifiedPerson | null>(null);
const [fieldChoices, setFieldChoices] = useState<Record<MergeField, 'target' | 'source'>>({
displayName: 'target',
email: 'target',
phone: 'target',
supportLevel: 'target',
tags: 'target',
notes: 'target',
});
const [merging, setMerging] = useState(false);
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleSearch = useCallback((value: string) => {
clearTimeout(searchTimerRef.current);
if (!value.trim()) {
setSearchResults([]);
return;
}
searchTimerRef.current = setTimeout(async () => {
setSearching(true);
try {
const { data } = await api.get<PeopleListResponse>('/people', {
params: { search: value, limit: 8 },
});
// Filter out the target contact
setSearchResults(data.people.filter((p) => p.contactId !== targetContact.id));
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
}, 300);
}, [targetContact.id]);
const handleFieldChoice = (field: MergeField, value: 'target' | 'source') => {
setFieldChoices((prev) => ({ ...prev, [field]: value }));
};
const handleMerge = async () => {
if (!sourcePerson) {
message.warning('Please select a person to merge');
return;
}
const colonIdx = sourcePerson.id.indexOf(':');
const sourceType = colonIdx !== -1 ? sourcePerson.id.substring(0, colonIdx) : 'contact';
const sourceId = colonIdx !== -1 ? sourcePerson.id.substring(colonIdx + 1) : sourcePerson.id;
setMerging(true);
try {
const payload: MergeContactPayload = {
sourceType: sourceType as 'user' | 'addr' | 'contact',
sourceId,
keepFields: fieldChoices as Record<string, 'source' | 'target'>,
};
await api.post(`/people/contacts/${targetContact.id}/merge`, payload);
message.success('Contacts merged successfully');
onMerged();
} catch {
message.error('Failed to merge contacts');
} finally {
setMerging(false);
}
};
const handleClose = () => {
setSourcePerson(null);
setSearchResults([]);
setFieldChoices({
displayName: 'target',
email: 'target',
phone: 'target',
supportLevel: 'target',
tags: 'target',
notes: 'target',
});
onClose();
};
const getSourceValue = (field: MergeField): string => {
if (!sourcePerson) return '--';
switch (field) {
case 'displayName':
return sourcePerson.displayName || '--';
case 'email':
return sourcePerson.email || '--';
case 'phone':
return sourcePerson.phone || '--';
case 'supportLevel':
return sourcePerson.supportLevel ? SUPPORT_LEVEL_LABELS[sourcePerson.supportLevel] : '--';
case 'tags':
return sourcePerson.tags?.join(', ') || '--';
case 'notes':
return '(from source)';
default:
return '--';
}
};
const getTargetValue = (field: MergeField): string => {
switch (field) {
case 'displayName':
return targetContact.displayName || '--';
case 'email':
return targetContact.email || '--';
case 'phone':
return targetContact.phone || '--';
case 'supportLevel':
return targetContact.supportLevel ? SUPPORT_LEVEL_LABELS[targetContact.supportLevel] : '--';
case 'tags':
return targetContact.tags?.join(', ') || '--';
case 'notes':
return targetContact.notes || '--';
default:
return '--';
}
};
const mergeFields: { field: MergeField; label: string }[] = [
{ field: 'displayName', label: 'Display Name' },
{ field: 'email', label: 'Email' },
{ field: 'phone', label: 'Phone' },
{ field: 'supportLevel', label: 'Support Level' },
{ field: 'tags', label: 'Tags (union of both)' },
{ field: 'notes', label: 'Notes (concatenated)' },
];
return (
<Modal
title={
<Space>
<SwapOutlined />
<span>Merge Contacts</span>
</Space>
}
open={open}
onCancel={handleClose}
width={700}
footer={[
<Button key="cancel" onClick={handleClose}>
Cancel
</Button>,
<Button
key="merge"
type="primary"
danger
onClick={handleMerge}
loading={merging}
disabled={!sourcePerson}
>
Confirm Merge
</Button>,
]}
>
{/* Search for source person */}
<div style={{ marginBottom: 20 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Search for the person to merge INTO this contact:
</Typography.Text>
<Select
showSearch
placeholder="Search by name, email, or phone..."
filterOption={false}
onSearch={handleSearch}
loading={searching}
notFoundContent={searching ? 'Searching...' : 'No results'}
style={{ width: '100%' }}
value={sourcePerson?.id}
onChange={(value) => {
const person = searchResults.find((p) => p.id === value);
setSourcePerson(person || null);
}}
options={searchResults.map((p) => ({
value: p.id,
label: (
<div>
<span style={{ fontWeight: 500 }}>{p.displayName}</span>
{p.email && (
<span style={{ color: '#888', marginLeft: 8, fontSize: 12 }}>
{p.email}
</span>
)}
<Tag
color={CONTACT_SOURCE_COLORS[p.source]}
style={{ marginLeft: 8, fontSize: 10 }}
>
{CONTACT_SOURCE_LABELS[p.source]}
</Tag>
</div>
),
}))}
suffixIcon={<SearchOutlined />}
/>
</div>
{sourcePerson && (
<>
{/* Side-by-side header */}
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<div
style={{
flex: 1,
padding: 12,
background: 'rgba(82, 196, 26, 0.08)',
borderRadius: 8,
border: '1px solid rgba(82, 196, 26, 0.2)',
textAlign: 'center',
}}
>
<Typography.Text strong style={{ fontSize: 12, color: '#52c41a' }}>
TARGET (Keep)
</Typography.Text>
<div style={{ marginTop: 4 }}>
<Typography.Text strong>{targetContact.displayName}</Typography.Text>
</div>
</div>
<div
style={{
flex: 1,
padding: 12,
background: 'rgba(24, 144, 255, 0.08)',
borderRadius: 8,
border: '1px solid rgba(24, 144, 255, 0.2)',
textAlign: 'center',
}}
>
<Typography.Text strong style={{ fontSize: 12, color: '#1890ff' }}>
SOURCE (Merge In)
</Typography.Text>
<div style={{ marginTop: 4 }}>
<Typography.Text strong>{sourcePerson.displayName}</Typography.Text>
</div>
</div>
</div>
<Divider style={{ margin: '12px 0' }}>Field Selection</Divider>
{/* Field-by-field selection */}
{mergeFields.map(({ field, label }) => (
<div
key={field}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 12,
padding: '8px 0',
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}
>
<Typography.Text style={{ width: 130, fontSize: 13, flexShrink: 0 }}>
{label}
</Typography.Text>
<Radio.Group
value={fieldChoices[field]}
onChange={(e) => handleFieldChoice(field, e.target.value)}
size="small"
>
<Radio.Button value="target">
<span style={{ fontSize: 11 }}>{getTargetValue(field)}</span>
</Radio.Button>
<Radio.Button value="source">
<span style={{ fontSize: 11 }}>{getSourceValue(field)}</span>
</Radio.Button>
</Radio.Group>
</div>
))}
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 8 }}>
The source person will be marked as merged. Tags will be combined from both contacts.
Notes will be concatenated. All activity history will be preserved.
</Typography.Text>
</>
)}
</Modal>
);
}

View File

@ -0,0 +1,182 @@
import { useState, useEffect, useCallback } from 'react';
import { Timeline, Typography, Select, Button, Spin, Empty, Tag, Space } from 'antd';
import {
MailOutlined,
MessageOutlined,
ScheduleOutlined,
EnvironmentOutlined,
DollarOutlined,
ShoppingOutlined,
PhoneOutlined,
PlaySquareOutlined,
EditOutlined,
MergeCellsOutlined,
ClockCircleOutlined,
LoginOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { ContactActivityItem, ContactActivityResponse, ContactActivityType } from '@/types/api';
dayjs.extend(relativeTime);
const ACTIVITY_TYPE_CONFIG: Record<
ContactActivityType,
{ icon: React.ReactNode; color: string; label: string }
> = {
EMAIL_SENT: { icon: <MailOutlined />, color: '#1890ff', label: 'Email Sent' },
RESPONSE_SUBMITTED: { icon: <MessageOutlined />, color: '#52c41a', label: 'Response Submitted' },
SHIFT_SIGNUP: { icon: <ScheduleOutlined />, color: '#13c2c2', label: 'Shift Signup' },
CANVASS_VISIT: { icon: <EnvironmentOutlined />, color: '#722ed1', label: 'Canvass Visit' },
DONATION: { icon: <DollarOutlined />, color: '#fadb14', label: 'Donation' },
PURCHASE: { icon: <ShoppingOutlined />, color: '#fa8c16', label: 'Purchase' },
SMS_SENT: { icon: <PhoneOutlined />, color: '#2f54eb', label: 'SMS Sent' },
SMS_RECEIVED: { icon: <PhoneOutlined />, color: '#eb2f96', label: 'SMS Received' },
VIDEO_VIEW: { icon: <PlaySquareOutlined />, color: '#a0d911', label: 'Video View' },
NOTE_ADDED: { icon: <EditOutlined />, color: '#8c8c8c', label: 'Note Added' },
CONTACT_MERGED: { icon: <MergeCellsOutlined />, color: '#faad14', label: 'Contact Merged' },
PROFILE_SELF_EDIT: { icon: <EditOutlined />, color: '#597ef7', label: 'Profile Self-Edit' },
PROFILE_PHOTO_UPDATED: { icon: <EditOutlined />, color: '#36cfc9', label: 'Photo Updated' },
USER_LOGIN: { icon: <LoginOutlined />, color: '#595959', label: 'User Login' },
};
const activityTypeOptions = Object.entries(ACTIVITY_TYPE_CONFIG).map(([value, cfg]) => ({
value,
label: cfg.label,
}));
interface PersonActivityTimelineProps {
personId: string;
filterType?: string;
}
export default function PersonActivityTimeline({ personId, filterType: initialFilter }: PersonActivityTimelineProps) {
const [activities, setActivities] = useState<ContactActivityItem[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [filterType, setFilterType] = useState<string | undefined>(initialFilter);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const parsePersonId = (id: string): { type: string; sourceId: string } => {
const colonIdx = id.indexOf(':');
if (colonIdx === -1) return { type: 'contact', sourceId: id };
return { type: id.substring(0, colonIdx), sourceId: id.substring(colonIdx + 1) };
};
const fetchActivities = useCallback(
async (pageNum: number, append = false) => {
if (append) setLoadingMore(true);
else setLoading(true);
try {
const { type, sourceId } = parsePersonId(personId);
const { data } = await api.get<ContactActivityResponse>(
`/people/${type}/${sourceId}/activity`,
{
params: {
page: pageNum,
limit: 20,
type: filterType,
},
},
);
if (append) {
setActivities((prev) => [...prev, ...data.activities]);
} else {
setActivities(data.activities);
}
setPage(pageNum);
setHasMore(pageNum < data.pagination.totalPages);
} catch {
if (!append) setActivities([]);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[personId, filterType],
);
useEffect(() => {
fetchActivities(1);
}, [personId, filterType]); // eslint-disable-line react-hooks/exhaustive-deps
const handleLoadMore = () => {
fetchActivities(page + 1, true);
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
);
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Select
placeholder="Filter by type"
options={activityTypeOptions}
value={filterType}
onChange={setFilterType}
allowClear
style={{ width: 200 }}
/>
</div>
{activities.length === 0 ? (
<Empty description="No activity found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<>
<Timeline
items={activities.map((activity) => {
const config = ACTIVITY_TYPE_CONFIG[activity.type] || {
icon: <ClockCircleOutlined />,
color: '#8c8c8c',
label: activity.type,
};
return {
dot: <span style={{ color: config.color }}>{config.icon}</span>,
children: (
<div>
<Space size={6} style={{ marginBottom: 2 }}>
<Typography.Text strong style={{ fontSize: 13 }}>
{activity.title}
</Typography.Text>
<Tag style={{ fontSize: 10, margin: 0 }}>{config.label}</Tag>
</Space>
{activity.description && (
<Typography.Paragraph
type="secondary"
style={{ fontSize: 12, margin: 0 }}
ellipsis={{ rows: 2 }}
>
{activity.description}
</Typography.Paragraph>
)}
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(activity.occurredAt).fromNow()} ({dayjs(activity.occurredAt).format('MMM D, YYYY h:mm A')})
</Typography.Text>
</div>
),
};
})}
/>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button onClick={handleLoadMore} loading={loadingMore} size="small">
Load More
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,152 @@
import { Card, Tag, Typography, Avatar, Space, Dropdown, Progress } from 'antd';
import { MoreOutlined, StarFilled, EyeOutlined, UserAddOutlined, LinkOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { UnifiedPerson, ContactSource } from '@/types/api';
import {
CONTACT_SOURCE_LABELS,
CONTACT_SOURCE_COLORS,
SUPPORT_LEVEL_LABELS,
SUPPORT_LEVEL_COLORS,
} from '@/types/api';
dayjs.extend(relativeTime);
const SOURCE_AVATAR_COLORS: Record<ContactSource, string> = {
USER: '#1890ff',
ADDRESS_OCCUPANT: '#52c41a',
CAMPAIGN_SENDER: '#722ed1',
SHIFT_SIGNUP: '#13c2c2',
SMS_CONTACT: '#fa8c16',
DONATION: '#fadb14',
MANUAL: '#8c8c8c',
};
function getInitials(name: string): string {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
const first = parts[0];
const last = parts[parts.length - 1];
if (parts.length >= 2 && first && last) {
return ((first[0] || '') + (last[0] || '')).toUpperCase();
}
return (name[0] || '?').toUpperCase();
}
interface PersonCardProps {
person: UnifiedPerson;
onClick: () => void;
onManage: () => void;
}
export default function PersonCard({ person, onClick, onManage }: PersonCardProps) {
const menuItems: MenuProps['items'] = [
{ key: 'view', icon: <EyeOutlined />, label: 'View Details', onClick },
...(!person.isManaged
? [{ key: 'manage', icon: <UserAddOutlined />, label: 'Manage Contact', onClick: onManage }]
: []),
{ key: 'connect', icon: <LinkOutlined />, label: 'Add Connection', onClick },
];
return (
<Card
hoverable
size="small"
onClick={onClick}
style={{
borderColor: 'rgba(255,255,255,0.08)',
height: '100%',
}}
styles={{
body: { padding: 16 },
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<Space size={10}>
<Avatar
size={40}
style={{ backgroundColor: SOURCE_AVATAR_COLORS[person.source], fontWeight: 600 }}
>
{getInitials(person.displayName)}
</Avatar>
<div style={{ minWidth: 0 }}>
<Typography.Text strong style={{ display: 'block', lineHeight: 1.3 }} ellipsis>
{person.displayName}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 12, display: 'block' }}
ellipsis
>
{person.email || person.phone || 'No contact info'}
</Typography.Text>
</div>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{person.isManaged && <StarFilled style={{ color: '#faad14', fontSize: 14 }} />}
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
<MoreOutlined
style={{ fontSize: 16, cursor: 'pointer', padding: 4 }}
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
<Tag color={CONTACT_SOURCE_COLORS[person.source]} style={{ margin: 0, fontSize: 11 }}>
{CONTACT_SOURCE_LABELS[person.source]}
</Tag>
{person.supportLevel && (
<Tag
color={SUPPORT_LEVEL_COLORS[person.supportLevel]}
style={{ margin: 0, fontSize: 11, color: '#fff', borderColor: 'transparent' }}
>
{SUPPORT_LEVEL_LABELS[person.supportLevel]}
</Tag>
)}
</div>
{person.tags && person.tags.length > 0 && (
<div style={{ marginBottom: 8 }}>
<Space size={2} wrap>
{person.tags.slice(0, 3).map((t) => (
<Tag key={t} style={{ fontSize: 10, margin: 0 }}>
{t}
</Tag>
))}
{person.tags.length > 3 && (
<Tag style={{ fontSize: 10, margin: 0 }}>+{person.tags.length - 3}</Tag>
)}
</Space>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{person.engagementScore != null ? (
<Progress
type="circle"
percent={person.engagementScore}
size={32}
strokeColor={
person.engagementScore >= 70
? '#52c41a'
: person.engagementScore >= 40
? '#faad14'
: '#ff4d4f'
}
format={(p) => <span style={{ fontSize: 10 }}>{p}</span>}
/>
) : (
<div />
)}
{person.lastActivity && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(person.lastActivity).fromNow()}
</Typography.Text>
)}
</div>
</Card>
);
}

View File

@ -0,0 +1,903 @@
import {
Drawer,
Tabs,
Descriptions,
Tag,
Button,
Input,
Select,
Switch,
Space,
Typography,
message,
Spin,
Avatar,
Grid,
List,
Popconfirm,
Tooltip,
Divider,
Alert,
} from 'antd';
import { useState, useEffect, useCallback } from 'react';
import {
StarFilled,
StarOutlined,
UserOutlined,
EnvironmentOutlined,
MailOutlined,
ScheduleOutlined,
DollarOutlined,
PhoneOutlined,
LinkOutlined,
DeleteOutlined,
MergeCellsOutlined,
EyeOutlined,
UserAddOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type {
PersonDetail,
UpdateContactPayload,
ContactConnection,
ContactSource,
SupportLevel,
CrmTag,
} from '@/types/api';
import {
CONTACT_SOURCE_LABELS,
CONTACT_SOURCE_COLORS,
SUPPORT_LEVEL_LABELS,
SUPPORT_LEVEL_COLORS,
CONNECTION_TYPE_LABELS,
CONNECTION_TYPE_COLORS,
} from '@/types/api';
import PersonActivityTimeline from './PersonActivityTimeline';
import EngagementSummaryPanel from './EngagementSummaryPanel';
import HouseholdPanel from './HouseholdPanel';
import ConnectionForm from './ConnectionForm';
import MergeContactModal from './MergeContactModal';
import ProfileLinkActions from './ProfileLinkActions';
import ProfilePreviewModal from './ProfilePreviewModal';
import CreateUserFromContactModal from './CreateUserFromContactModal';
import UserAccountStatusPanel from './UserAccountStatusPanel';
import ContactAddressPanel from './ContactAddressPanel';
import ContactEmailPanel from './ContactEmailPanel';
import ContactPhonePanel from './ContactPhonePanel';
import VideoCallModal from './VideoCallModal';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
const supportLevelOptions: { value: SupportLevel; label: string }[] = Object.entries(SUPPORT_LEVEL_LABELS).map(
([value, label]) => ({ value: value as SupportLevel, label }),
);
const SOURCE_AVATAR_COLORS: Record<ContactSource, string> = {
USER: '#1890ff',
ADDRESS_OCCUPANT: '#52c41a',
CAMPAIGN_SENDER: '#722ed1',
SHIFT_SIGNUP: '#13c2c2',
SMS_CONTACT: '#fa8c16',
DONATION: '#fadb14',
MANUAL: '#8c8c8c',
};
function getInitials(name: string): string {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
const first = parts[0];
const last = parts[parts.length - 1];
if (parts.length >= 2 && first && last) {
return ((first[0] || '') + (last[0] || '')).toUpperCase();
}
return (name[0] || '?').toUpperCase();
}
interface PersonDetailDrawerProps {
open: boolean;
personId: string | null;
onClose: () => void;
onRefresh: () => void;
}
export default function PersonDetailDrawer({ open, personId, onClose, onRefresh }: PersonDetailDrawerProps) {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [detail, setDetail] = useState<PersonDetail | null>(null);
const [connections, setConnections] = useState<ContactConnection[]>([]);
const [registeredTags, setRegisteredTags] = useState<CrmTag[]>([]);
const [showConnectionForm, setShowConnectionForm] = useState(false);
const [showMergeModal, setShowMergeModal] = useState(false);
const [showProfilePreview, setShowProfilePreview] = useState(false);
const [showCreateUserModal, setShowCreateUserModal] = useState(false);
const [showVideoCallModal, setShowVideoCallModal] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
const { settings: siteSettings } = useSettingsStore();
const { user: authUser } = useAuthStore();
const canStartCall = siteSettings?.enableMeet && authUser?.role !== 'TEMP';
// Editable fields (for managed contacts)
const [editDisplayName, setEditDisplayName] = useState('');
const [editFirstName, setEditFirstName] = useState('');
const [editLastName, setEditLastName] = useState('');
const [editTags, setEditTags] = useState<string[]>([]);
const [editNotes, setEditNotes] = useState('');
const [editSupportLevel, setEditSupportLevel] = useState<SupportLevel | null>(null);
const [editEmailOptOut, setEditEmailOptOut] = useState(false);
const [editSmsOptOut, setEditSmsOptOut] = useState(false);
const [editDoNotContact, setEditDoNotContact] = useState(false);
const [editSignRequested, setEditSignRequested] = useState(false);
const isNew = personId === 'new';
const parsePersonId = (id: string): { type: string; sourceId: string } => {
const colonIdx = id.indexOf(':');
if (colonIdx === -1) return { type: 'contact', sourceId: id };
return { type: id.substring(0, colonIdx), sourceId: id.substring(colonIdx + 1) };
};
const fetchDetail = useCallback(async () => {
if (!personId || isNew) return;
setLoading(true);
try {
const { type, sourceId } = parsePersonId(personId);
const { data } = await api.get<PersonDetail>(`/people/${type}/${sourceId}`);
setDetail(data);
// Populate editable fields
const c = data.contact;
if (c) {
setEditDisplayName(c.displayName || '');
setEditFirstName(c.firstName || '');
setEditLastName(c.lastName || '');
setEditTags(c.tags || []);
setEditNotes(c.notes || '');
setEditSupportLevel(c.supportLevel);
setEditEmailOptOut(c.emailOptOut);
setEditSmsOptOut(c.smsOptOut);
setEditDoNotContact(c.doNotContact);
setEditSignRequested(c.signRequested);
} else {
setEditDisplayName(data.person.displayName || '');
setEditTags(data.person.tags || []);
setEditSupportLevel(data.person.supportLevel);
setEditFirstName('');
setEditLastName('');
setEditNotes('');
setEditEmailOptOut(false);
setEditSmsOptOut(false);
setEditDoNotContact(false);
setEditSignRequested(false);
}
} catch {
message.error('Failed to load person details');
} finally {
setLoading(false);
}
}, [personId, isNew]);
const fetchConnections = useCallback(async () => {
if (!personId || isNew || !detail?.contact?.id) return;
try {
const { data } = await api.get<{ connections: ContactConnection[] }>(
`/people/contacts/${detail.contact.id}/connections`,
);
setConnections(data.connections);
} catch {
setConnections([]);
}
}, [personId, isNew, detail?.contact?.id]);
useEffect(() => {
if (open) {
// Fetch registered tags for autocomplete
api.get<{ tags: CrmTag[] }>('/people/tags')
.then(({ data }) => setRegisteredTags(data.tags))
.catch(() => { /* non-critical */ });
}
if (open && personId && !isNew) {
setActiveTab('overview');
fetchDetail();
}
}, [open, personId]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (activeTab === 'connections' && detail?.contact?.id) {
fetchConnections();
}
}, [activeTab, detail?.contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleManage = async () => {
if (!personId || !detail) return;
try {
const { type, sourceId } = parsePersonId(personId);
await api.post('/people/manage', {
sourceType: type,
sourceId,
displayName: detail.person.displayName,
email: detail.person.email,
phone: detail.person.phone,
});
message.success('Contact is now managed');
fetchDetail();
onRefresh();
} catch {
message.error('Failed to manage contact');
}
};
const handleSave = async () => {
if (!detail?.contact?.id) return;
setSaving(true);
try {
const payload: UpdateContactPayload = {
displayName: editDisplayName,
firstName: editFirstName || undefined,
lastName: editLastName || undefined,
tags: editTags,
notes: editNotes || undefined,
supportLevel: editSupportLevel,
signRequested: editSignRequested,
emailOptOut: editEmailOptOut,
smsOptOut: editSmsOptOut,
doNotContact: editDoNotContact,
};
await api.put(`/people/contacts/${detail.contact.id}`, payload);
message.success('Contact updated');
fetchDetail();
onRefresh();
} catch {
message.error('Failed to update contact');
} finally {
setSaving(false);
}
};
const handleRemoveConnection = async (connectionId: string) => {
if (!detail?.contact?.id) return;
try {
await api.delete(`/people/contacts/${detail.contact.id}/connections/${connectionId}`);
message.success('Connection removed');
fetchConnections();
} catch {
message.error('Failed to remove connection');
}
};
const person = detail?.person;
const contact = detail?.contact;
const isManaged = person?.isManaged ?? false;
// Navigate tab items
const navigateLinks = [];
if (person?.userId) {
navigateLinks.push({ label: 'View User Profile', icon: <UserOutlined />, path: '/app/users' });
}
if (person?.addressId) {
navigateLinks.push({ label: 'View on Map', icon: <EnvironmentOutlined />, path: '/map' });
}
if (person?.email) {
navigateLinks.push({ label: 'View Campaign Emails', icon: <MailOutlined />, path: '/app/campaigns' });
navigateLinks.push({ label: 'View Shift Signups', icon: <ScheduleOutlined />, path: '/app/map/shifts' });
navigateLinks.push({ label: 'View Donations', icon: <DollarOutlined />, path: '/app/payments/donations' });
}
if (person?.phone) {
navigateLinks.push({ label: 'View SMS Conversations', icon: <PhoneOutlined />, path: '/app/sms/conversations' });
}
const tabItems = [
{
key: 'overview',
label: 'Overview',
children: loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
) : !person ? (
<Typography.Text type="secondary">No data available</Typography.Text>
) : (
<div>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
marginBottom: 20,
}}
>
<Avatar
size={56}
style={{ backgroundColor: SOURCE_AVATAR_COLORS[person.source], fontWeight: 600, fontSize: 20 }}
>
{getInitials(person.displayName)}
</Avatar>
<div style={{ flex: 1 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
{person.displayName}
</Typography.Title>
<Space size={6} style={{ marginTop: 4 }}>
<Tag color={CONTACT_SOURCE_COLORS[person.source]}>
{CONTACT_SOURCE_LABELS[person.source]}
</Tag>
{!isManaged && (
<Button size="small" icon={<StarOutlined />} onClick={handleManage}>
Manage
</Button>
)}
{isManaged && <StarFilled style={{ color: '#faad14' }} />}
</Space>
</div>
<Space>
{canStartCall && person && (
<Button
icon={<VideoCameraOutlined />}
size="small"
type="primary"
ghost
onClick={() => setShowVideoCallModal(true)}
>
Video Call
</Button>
)}
{isManaged && contact && (
<>
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => setShowProfilePreview(true)}
>
Preview
</Button>
<Button
icon={<MergeCellsOutlined />}
size="small"
onClick={() => setShowMergeModal(true)}
>
Merge
</Button>
</>
)}
</Space>
</div>
{/* Engagement Summary */}
{detail?.engagement && (
<div style={{ marginBottom: 20 }}>
<EngagementSummaryPanel engagement={detail.engagement} />
</div>
)}
{/* Profile Link Actions (managed contacts only) */}
{isManaged && contact && (
<div style={{ marginBottom: 16 }}>
<ProfileLinkActions contact={contact} onRefresh={() => { fetchDetail(); onRefresh(); }} />
</div>
)}
<Divider style={{ margin: '16px 0' }} />
{/* Editable fields for managed contacts */}
{isManaged && contact ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Display Name
</Typography.Text>
<Input value={editDisplayName} onChange={(e) => setEditDisplayName(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
First Name
</Typography.Text>
<Input value={editFirstName} onChange={(e) => setEditFirstName(e.target.value)} />
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Last Name
</Typography.Text>
<Input value={editLastName} onChange={(e) => setEditLastName(e.target.value)} />
</div>
</div>
<Divider style={{ margin: '8px 0' }}>Emails</Divider>
<ContactEmailPanel contactId={contact.id} />
<Divider style={{ margin: '8px 0' }}>Phones</Divider>
<ContactPhonePanel contactId={contact.id} />
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Tags
</Typography.Text>
<Select
mode="tags"
value={editTags}
onChange={setEditTags}
style={{ width: '100%' }}
placeholder="Add tags..."
tokenSeparators={[',']}
options={registeredTags.map((t) => ({
value: t.name,
label: (
<Space size={6}>
{t.color && <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: t.color }} />}
<span>{t.name}</span>
{t.contactCount > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>({t.contactCount})</span>}
</Space>
),
}))}
/>
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Notes
</Typography.Text>
<Input.TextArea
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
rows={3}
placeholder="Notes about this contact..."
/>
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Support Level
</Typography.Text>
<Select
options={supportLevelOptions}
value={editSupportLevel}
onChange={setEditSupportLevel}
allowClear
style={{ width: '100%' }}
placeholder="Not set"
/>
</div>
<Divider style={{ margin: '8px 0' }}>Addresses</Divider>
<ContactAddressPanel contactId={contact.id} />
<Divider style={{ margin: '8px 0' }}>Consent & Preferences</Divider>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text style={{ fontSize: 13 }}>Email Opt-Out</Typography.Text>
<Switch checked={editEmailOptOut} onChange={setEditEmailOptOut} size="small" />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text style={{ fontSize: 13 }}>SMS Opt-Out</Typography.Text>
<Switch checked={editSmsOptOut} onChange={setEditSmsOptOut} size="small" />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text style={{ fontSize: 13 }}>Do Not Contact</Typography.Text>
<Switch checked={editDoNotContact} onChange={setEditDoNotContact} size="small" />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text style={{ fontSize: 13 }}>Sign Requested</Typography.Text>
<Switch checked={editSignRequested} onChange={setEditSignRequested} size="small" />
</div>
{/* User Account Section */}
<Divider style={{ margin: '8px 0' }}>User Account</Divider>
{person?.userStatus && person.userStatus !== 'ACTIVE' && (
<Alert
type="warning"
message={`User account is ${person.userStatus.replace(/_/g, ' ')}`}
style={{ marginBottom: 8 }}
showIcon
/>
)}
{contact?.userId ? (
<UserAccountStatusPanel userId={contact.userId} />
) : (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
This contact does not have a user account.
</Typography.Text>
<Button
icon={<UserAddOutlined />}
disabled={!contact?.email}
onClick={() => setShowCreateUserModal(true)}
>
Create User Account
</Button>
{!contact?.email && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 4 }}>
An email address is required to create a user account
</div>
)}
</div>
)}
<Button type="primary" onClick={handleSave} loading={saving} style={{ marginTop: 12 }}>
Save Changes
</Button>
</div>
) : (
/* Read-only display for unmanaged */
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="Email">{person?.email || '--'}</Descriptions.Item>
<Descriptions.Item label="Phone">{person?.phone || '--'}</Descriptions.Item>
<Descriptions.Item label="Support Level">
{person?.supportLevel ? (
<Tag color={SUPPORT_LEVEL_COLORS[person.supportLevel]}>
{SUPPORT_LEVEL_LABELS[person.supportLevel]}
</Tag>
) : (
'--'
)}
</Descriptions.Item>
<Descriptions.Item label="Tags">
{person?.tags && person.tags.length > 0 ? (
<Space size={2} wrap>
{person.tags.map((t) => (
<Tag key={t}>{t}</Tag>
))}
</Space>
) : (
'--'
)}
</Descriptions.Item>
<Descriptions.Item label="Engagement Score">
{person?.engagementScore != null ? `${person.engagementScore}%` : '--'}
</Descriptions.Item>
</Descriptions>
)}
</div>
),
},
{
key: 'activity',
label: 'Activity',
children: personId && !isNew ? <PersonActivityTimeline personId={personId} /> : null,
},
{
key: 'connections',
label: 'Connections',
children: (
<div>
{!isManaged ? (
<Typography.Text type="secondary">
Manage this contact to add connections.
</Typography.Text>
) : (
<>
<div style={{ marginBottom: 16 }}>
<Button
icon={<LinkOutlined />}
onClick={() => setShowConnectionForm(true)}
disabled={showConnectionForm}
>
Add Connection
</Button>
</div>
{showConnectionForm && contact && (
<div
style={{
marginBottom: 16,
padding: 12,
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<ConnectionForm
contactId={contact.id}
onCreated={() => {
setShowConnectionForm(false);
fetchConnections();
}}
onCancel={() => setShowConnectionForm(false)}
/>
</div>
)}
{connections.length === 0 ? (
<Typography.Text type="secondary">No connections yet.</Typography.Text>
) : (
<List
dataSource={connections}
renderItem={(conn) => {
const isFrom = conn.fromContactId === contact?.id;
const otherContact = isFrom ? conn.toContact : conn.fromContact;
return (
<List.Item
actions={[
<Popconfirm
key="remove"
title="Remove this connection?"
onConfirm={() => handleRemoveConnection(conn.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>,
]}
>
<List.Item.Meta
avatar={
<Avatar size={32} style={{ backgroundColor: '#722ed1' }}>
{otherContact ? getInitials(otherContact.displayName) : '?'}
</Avatar>
}
title={
<Space size={6}>
<span>{otherContact?.displayName || 'Unknown'}</span>
<Tag color={CONNECTION_TYPE_COLORS[conn.type]} style={{ margin: 0, fontSize: 11 }}>
{conn.label || CONNECTION_TYPE_LABELS[conn.type]}
</Tag>
{conn.isBidirectional && (
<Tooltip title="Bidirectional">
<LinkOutlined style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }} />
</Tooltip>
)}
</Space>
}
description={conn.notes || undefined}
/>
</List.Item>
);
}}
/>
)}
</>
)}
</div>
),
},
{
key: 'household',
label: 'Household',
children: personId && !isNew ? <HouseholdPanel personId={personId} /> : null,
},
{
key: 'navigate',
label: 'Navigate',
children: (
<div>
{navigateLinks.length === 0 ? (
<Typography.Text type="secondary">No related modules found.</Typography.Text>
) : (
<List
dataSource={navigateLinks}
renderItem={(item) => (
<List.Item>
<Button
type="link"
icon={item.icon}
onClick={() => {
window.location.href = item.path;
}}
style={{ padding: 0 }}
>
{item.label}
</Button>
</List.Item>
)}
/>
)}
</div>
),
},
];
return (
<>
<Drawer
title={
isNew
? 'Add Contact'
: person
? person.displayName
: 'Person Details'
}
open={open}
placement="right"
width={isMobile ? '100%' : 640}
mask={false}
destroyOnHidden
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={onClose}
>
{isNew ? (
<NewContactForm
registeredTags={registeredTags}
onCreated={() => {
onClose();
onRefresh();
}}
/>
) : (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
size="small"
/>
)}
</Drawer>
{/* Merge Modal */}
{contact && (
<MergeContactModal
open={showMergeModal}
targetContact={contact}
onClose={() => setShowMergeModal(false)}
onMerged={() => {
setShowMergeModal(false);
fetchDetail();
onRefresh();
}}
/>
)}
{/* Profile Preview Modal */}
{contact && (
<ProfilePreviewModal
open={showProfilePreview}
contactId={contact.id}
profileToken={contact.profileToken}
onClose={() => setShowProfilePreview(false)}
/>
)}
{/* Create User from Contact Modal */}
{contact && contact.email && (
<CreateUserFromContactModal
open={showCreateUserModal}
contactId={contact.id}
contactEmail={contact.email}
contactName={contact.displayName}
onClose={() => setShowCreateUserModal(false)}
onCreated={() => {
setShowCreateUserModal(false);
fetchDetail();
onRefresh();
}}
/>
)}
{/* Video Call Modal */}
<VideoCallModal
open={showVideoCallModal}
onClose={() => setShowVideoCallModal(false)}
personName={person?.displayName || 'Contact'}
/>
</>
);
}
/** Inline new contact form */
function NewContactForm({ onCreated, registeredTags }: { onCreated: () => void; registeredTags: CrmTag[] }) {
const [displayName, setDisplayName] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [supportLevel, setSupportLevel] = useState<SupportLevel | undefined>();
const [saving, setSaving] = useState(false);
const handleCreate = async () => {
if (!displayName.trim()) {
message.warning('Display name is required');
return;
}
setSaving(true);
try {
await api.post('/people/contacts', {
displayName: displayName.trim(),
firstName: firstName || undefined,
lastName: lastName || undefined,
email: email || undefined,
phone: phone || undefined,
tags,
notes: notes || undefined,
supportLevel,
});
message.success('Contact created');
onCreated();
} catch {
message.error('Failed to create contact');
} finally {
setSaving(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Display Name *
</Typography.Text>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Jane Doe"
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
First Name
</Typography.Text>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Last Name
</Typography.Text>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Email
</Typography.Text>
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="jane@example.com" />
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Phone
</Typography.Text>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+1 555 000 0000" />
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Tags
</Typography.Text>
<Select
mode="tags"
value={tags}
onChange={setTags}
style={{ width: '100%' }}
placeholder="Add tags..."
tokenSeparators={[',']}
options={registeredTags.map((t) => ({
value: t.name,
label: (
<Space size={6}>
{t.color && <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: t.color }} />}
<span>{t.name}</span>
</Space>
),
}))}
/>
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Notes
</Typography.Text>
<Input.TextArea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Support Level
</Typography.Text>
<Select
options={Object.entries(SUPPORT_LEVEL_LABELS).map(([v, l]) => ({
value: v as SupportLevel,
label: l,
}))}
value={supportLevel}
onChange={setSupportLevel}
allowClear
style={{ width: '100%' }}
placeholder="Not set"
/>
</div>
<Button type="primary" onClick={handleCreate} loading={saving} style={{ marginTop: 8 }}>
Create Contact
</Button>
</div>
);
}

View File

@ -0,0 +1,504 @@
import { useState, useMemo } from 'react';
import {
Button,
Space,
Typography,
Popconfirm,
Tag,
message,
Tooltip,
Modal,
Select,
Switch,
Input,
Form,
} from 'antd';
import {
LinkOutlined,
CopyOutlined,
MailOutlined,
DeleteOutlined,
QrcodeOutlined,
CheckCircleOutlined,
ReloadOutlined,
SettingOutlined,
LockOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { Contact, ProfileLinkResponse } from '@/types/api';
// Use relative URL — VITE_API_URL is a Docker-internal hostname the browser cannot resolve
const API_BASE = '';
const EXPIRATION_OPTIONS = [
{ value: '24h', label: '24 hours' },
{ value: '7d', label: '7 days' },
{ value: '30d', label: '30 days' },
{ value: '90d', label: '90 days' },
{ value: '1y', label: '1 year' },
{ value: 'never', label: 'Never' },
];
interface ProfileLinkActionsProps {
contact: Contact;
onRefresh: () => void;
}
type ModalMode = 'generate' | 'rotate' | 'settings';
function getExpiryStatus(expiresAt: string | null): {
color: 'green' | 'orange' | 'red';
label: string;
} {
if (!expiresAt) {
return { color: 'green', label: 'Active' };
}
const now = new Date();
const expiry = new Date(expiresAt);
const diffMs = expiry.getTime() - now.getTime();
if (diffMs <= 0) {
return { color: 'red', label: 'Expired' };
}
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
if (diffMs < twentyFourHoursMs) {
return { color: 'orange', label: 'Expiring Soon' };
}
return { color: 'green', label: 'Active' };
}
function formatExpiryDate(expiresAt: string | null): string {
if (!expiresAt) return 'Never expires';
const d = new Date(expiresAt);
return `Expires: ${d.toLocaleDateString()} ${d.toLocaleTimeString()}`;
}
export default function ProfileLinkActions({ contact, onRefresh }: ProfileLinkActionsProps) {
const [generating, setGenerating] = useState(false);
const [sending, setSending] = useState(false);
const [revoking, setRevoking] = useState(false);
const [saving, setSaving] = useState(false);
const [linkData, setLinkData] = useState<ProfileLinkResponse | null>(null);
const [showQr, setShowQr] = useState(false);
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<ModalMode>('generate');
// Modal form state
const [expiresIn, setExpiresIn] = useState<string>('never');
const [passwordEnabled, setPasswordEnabled] = useState(false);
const [password, setPassword] = useState('');
const [removePassword, setRemovePassword] = useState(false);
const hasToken = !!contact.profileToken;
const buildProfileUrl = (token: string) => {
return `${window.location.origin}/profile/${token}`;
};
const profileUrl = hasToken ? buildProfileUrl(contact.profileToken!) : null;
const expiryStatus = useMemo(
() => (hasToken ? getExpiryStatus(contact.profileTokenExpiresAt) : null),
[hasToken, contact.profileTokenExpiresAt],
);
const resetModalForm = () => {
setExpiresIn('never');
setPasswordEnabled(false);
setPassword('');
setRemovePassword(false);
};
const openGenerateModal = () => {
resetModalForm();
setModalMode('generate');
setModalOpen(true);
};
const openRotateModal = () => {
resetModalForm();
setModalMode('rotate');
setModalOpen(true);
};
const openSettingsModal = () => {
setModalMode('settings');
setPasswordEnabled(false);
setPassword('');
setRemovePassword(false);
// Default to 'never' for settings — the admin can pick a new value
setExpiresIn('never');
setModalOpen(true);
};
const handleModalConfirm = async () => {
if (passwordEnabled && password.length > 0 && password.length < 4) {
message.warning('Password must be at least 4 characters');
return;
}
if (modalMode === 'settings') {
// PUT to update settings
setSaving(true);
try {
const body: Record<string, unknown> = { expiresIn };
if (passwordEnabled && password) {
body.password = password;
}
if (removePassword) {
body.removePassword = true;
}
const { data } = await api.put<ProfileLinkResponse>(
`/people/contacts/${contact.id}/profile-link`,
body,
);
setLinkData(data);
message.success('Profile link settings updated');
setModalOpen(false);
onRefresh();
} catch {
message.error('Failed to update profile link settings');
} finally {
setSaving(false);
}
} else {
// POST to generate or rotate
setGenerating(true);
try {
const body: Record<string, unknown> = { expiresIn };
if (passwordEnabled && password) {
body.password = password;
}
const { data } = await api.post<ProfileLinkResponse>(
`/people/contacts/${contact.id}/profile-link`,
body,
);
setLinkData(data);
message.success(
modalMode === 'rotate' ? 'Profile link rotated with new token' : 'Profile link generated',
);
setModalOpen(false);
onRefresh();
} catch {
message.error('Failed to generate profile link');
} finally {
setGenerating(false);
}
}
};
const handleCopy = async () => {
const url = linkData?.url || profileUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
message.success('Link copied to clipboard');
} catch {
message.error('Failed to copy link');
}
};
const handleSendEmail = async () => {
if (!contact.email) {
message.warning('Contact has no email address');
return;
}
setSending(true);
try {
await api.post(`/people/contacts/${contact.id}/send-profile-link`);
message.success(`Profile link sent to ${contact.email}`);
} catch {
message.error('Failed to send profile link');
} finally {
setSending(false);
}
};
const handleRevoke = async () => {
setRevoking(true);
try {
await api.delete(`/people/contacts/${contact.id}/profile-link`);
setLinkData(null);
setShowQr(false);
message.success('Profile link revoked');
onRefresh();
} catch {
message.error('Failed to revoke profile link');
} finally {
setRevoking(false);
}
};
const qrUrl = profileUrl
? `${API_BASE}/api/qr?data=${encodeURIComponent(profileUrl)}&size=200`
: null;
const isPasswordProtected = linkData?.hasPassword ?? contact.hasProfilePassword;
const modalTitle =
modalMode === 'generate'
? 'Generate Profile Link'
: modalMode === 'rotate'
? 'Rotate Profile Link'
: 'Profile Link Settings';
const modalOkText =
modalMode === 'generate'
? 'Generate'
: modalMode === 'rotate'
? 'Rotate & Generate'
: 'Save Settings';
const passwordInvalid = passwordEnabled && password.length > 0 && password.length < 4;
return (
<div
style={{
padding: 12,
borderRadius: 8,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
}}
>
{/* Header row with status */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<LinkOutlined style={{ fontSize: 14, color: 'rgba(255,255,255,0.45)' }} />
<Typography.Text strong style={{ fontSize: 13 }}>
Profile Link
</Typography.Text>
{hasToken && expiryStatus ? (
<Tooltip title={formatExpiryDate(contact.profileTokenExpiresAt)}>
<Tag
color={expiryStatus.color}
icon={<CheckCircleOutlined />}
style={{ margin: 0, fontSize: 11, cursor: 'default' }}
>
{expiryStatus.label}
</Tag>
</Tooltip>
) : (
<Tag color="default" style={{ margin: 0, fontSize: 11 }}>
Not Generated
</Tag>
)}
{hasToken && isPasswordProtected && (
<Tooltip title="Password protected">
<Tag
icon={<LockOutlined />}
color="blue"
style={{ margin: 0, fontSize: 11, cursor: 'default' }}
>
Protected
</Tag>
</Tooltip>
)}
{hasToken && contact.profileTokenExpiresAt && (
<Tooltip title={formatExpiryDate(contact.profileTokenExpiresAt)}>
<ClockCircleOutlined
style={{ fontSize: 12, color: 'rgba(255,255,255,0.35)', cursor: 'default' }}
/>
</Tooltip>
)}
</div>
{/* Action buttons */}
<Space size={6} wrap>
{!hasToken ? (
<Button
size="small"
icon={<LinkOutlined />}
loading={generating}
onClick={openGenerateModal}
type="primary"
>
Generate Link
</Button>
) : (
<>
<Tooltip title="Copy profile link to clipboard">
<Button size="small" icon={<CopyOutlined />} onClick={handleCopy}>
Copy Link
</Button>
</Tooltip>
{contact.email && (
<Tooltip title={`Send link to ${contact.email}`}>
<Button
size="small"
icon={<MailOutlined />}
loading={sending}
onClick={handleSendEmail}
>
Send Email
</Button>
</Tooltip>
)}
<Tooltip title="Show QR code">
<Button
size="small"
icon={<QrcodeOutlined />}
onClick={() => setShowQr(!showQr)}
type={showQr ? 'primary' : 'default'}
>
QR
</Button>
</Tooltip>
<Tooltip title="Link settings (expiration, password)">
<Button
size="small"
icon={<SettingOutlined />}
onClick={openSettingsModal}
/>
</Tooltip>
<Tooltip title="Generate new token (invalidates old link)">
<Button
size="small"
icon={<ReloadOutlined />}
loading={generating}
onClick={openRotateModal}
/>
</Tooltip>
<Popconfirm
title="Revoke this profile link?"
description="The contact will no longer be able to access their profile page."
onConfirm={handleRevoke}
okText="Revoke"
okButtonProps={{ danger: true }}
>
<Button size="small" icon={<DeleteOutlined />} danger loading={revoking} />
</Popconfirm>
</>
)}
</Space>
{/* QR Code Preview */}
{showQr && qrUrl && (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<img
src={qrUrl}
alt="Profile QR Code"
style={{ borderRadius: 8, background: 'white', padding: 8 }}
width={200}
height={200}
/>
<Typography.Text
type="secondary"
style={{ display: 'block', fontSize: 11, marginTop: 4 }}
>
Scan to open profile
</Typography.Text>
</div>
)}
{/* Generate / Rotate / Settings Modal */}
<Modal
title={modalTitle}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleModalConfirm}
okText={modalOkText}
okButtonProps={{
loading: modalMode === 'settings' ? saving : generating,
disabled: passwordInvalid,
}}
destroyOnClose
>
{modalMode === 'rotate' && (
<Typography.Paragraph type="warning" style={{ marginBottom: 16 }}>
This will generate a new token and invalidate the existing link. Anyone with the old link
will no longer be able to access the profile.
</Typography.Paragraph>
)}
<Form layout="vertical">
<Form.Item label="Link Expiration" style={{ marginBottom: 16 }}>
<Select
value={expiresIn}
onChange={setExpiresIn}
options={EXPIRATION_OPTIONS}
style={{ width: '100%' }}
/>
</Form.Item>
{modalMode === 'settings' && isPasswordProtected && !passwordEnabled && (
<Form.Item style={{ marginBottom: 16 }}>
<Space>
<Typography.Text type="secondary">
<LockOutlined /> Currently password protected
</Typography.Text>
<Button
size="small"
danger
onClick={() => {
setRemovePassword(true);
setPasswordEnabled(false);
setPassword('');
}}
disabled={removePassword}
>
{removePassword ? 'Password will be removed' : 'Remove Password'}
</Button>
{removePassword && (
<Button
size="small"
onClick={() => setRemovePassword(false)}
>
Undo
</Button>
)}
</Space>
</Form.Item>
)}
<Form.Item
label={
<Space>
<span>
{modalMode === 'settings' && isPasswordProtected
? 'Change Password'
: 'Password Protection'}
</span>
<Switch
size="small"
checked={passwordEnabled}
onChange={(checked) => {
setPasswordEnabled(checked);
if (checked) {
setRemovePassword(false);
}
if (!checked) {
setPassword('');
}
}}
/>
</Space>
}
style={{ marginBottom: 0 }}
help={
passwordInvalid
? 'Password must be at least 4 characters'
: passwordEnabled
? 'Visitors will need this password to view the profile'
: undefined
}
validateStatus={passwordInvalid ? 'error' : undefined}
>
{passwordEnabled && (
<Input.Password
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password (min 4 characters)"
style={{ marginTop: 8 }}
/>
)}
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1,183 @@
import { Modal, Spin, Typography, Tag, Space, Button, Avatar, Progress } from 'antd';
import { useState, useEffect } from 'react';
import { LinkOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { ContactProfile, SupportLevel } from '@/types/api';
import { CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS, SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
interface ProfilePreviewModalProps {
open: boolean;
contactId: string;
profileToken: string | null;
onClose: () => void;
}
export default function ProfilePreviewModal({ open, contactId, profileToken, onClose }: ProfilePreviewModalProps) {
const [loading, setLoading] = useState(false);
const [profile, setProfile] = useState<ContactProfile | null>(null);
useEffect(() => {
if (!open || !contactId) return;
setLoading(true);
api.get<ContactProfile>(`/people/contacts/${contactId}/profile-preview`)
.then(({ data }) => setProfile(data))
.catch(() => setProfile(null))
.finally(() => setLoading(false));
}, [open, contactId]);
const photoUrl = `/api/people/contacts/${contactId}/profile-photo`;
return (
<Modal
title="Profile Preview"
open={open}
onCancel={onClose}
footer={
profileToken ? (
<Button
type="primary"
icon={<LinkOutlined />}
onClick={() => window.open(`/profile/${profileToken}`, '_blank')}
>
Open Public Profile
</Button>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
No profile token generated yet
</Typography.Text>
)
}
width={720}
destroyOnHidden
>
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
) : !profile ? (
<Typography.Text type="secondary">Profile data not available.</Typography.Text>
) : (
<div>
{/* Cover photo banner */}
{profile.coverPhotoPath && (
<div
style={{
height: 160,
borderRadius: 8,
overflow: 'hidden',
marginBottom: 16,
background: `url(${photoUrl}) center/cover no-repeat`,
backgroundColor: '#1b2838',
}}
/>
)}
{/* Avatar + name + engagement */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
<div style={{ position: 'relative' }}>
<Avatar size={64} style={{ backgroundColor: '#1890ff', fontWeight: 600, fontSize: 24 }}>
{profile.displayName?.charAt(0)?.toUpperCase() || '?'}
</Avatar>
{profile.engagementScore > 0 && (
<Progress
type="circle"
size={76}
percent={profile.engagementScore}
showInfo={false}
strokeWidth={4}
strokeColor="#52c41a"
style={{ position: 'absolute', top: -6, left: -6 }}
/>
)}
</div>
<div style={{ flex: 1 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
{profile.displayName}
</Typography.Title>
<Space size={6} style={{ marginTop: 4 }}>
<Tag color={CONTACT_SOURCE_COLORS[profile.primarySource]}>
{CONTACT_SOURCE_LABELS[profile.primarySource]}
</Tag>
{profile.supportLevel && (
<Tag color={SUPPORT_LEVEL_COLORS[profile.supportLevel as SupportLevel]}>
{SUPPORT_LEVEL_LABELS[profile.supportLevel as SupportLevel]}
</Tag>
)}
{profile.engagementScore > 0 && (
<Tag color="green">{profile.engagementScore}% engaged</Tag>
)}
</Space>
</div>
</div>
{/* Tags */}
{profile.tags && profile.tags.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Tags</Typography.Text>
<div style={{ marginTop: 4 }}>
<Space size={4} wrap>
{profile.tags.map((t) => <Tag key={t}>{t}</Tag>)}
</Space>
</div>
</div>
)}
{/* Engagement stats */}
{profile.engagement && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))',
gap: 8,
}}
>
{[
{ label: 'Emails Sent', value: profile.engagement.emailsSent },
{ label: 'Responses', value: profile.engagement.responsesSubmitted },
{ label: 'Shifts Signed Up', value: profile.engagement.shiftsSignedUp },
{ label: 'Canvass Visits', value: profile.engagement.canvassVisits },
{ label: 'Donations', value: profile.engagement.donationCount },
{ label: 'Video Views', value: profile.engagement.videoViews },
].map((stat) => (
<div
key={stat.label}
style={{
padding: '8px 12px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<div style={{ fontSize: 20, fontWeight: 600 }}>{stat.value}</div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>{stat.label}</div>
</div>
))}
</div>
)}
{/* Contact info */}
<div style={{ marginTop: 16, display: 'flex', gap: 24, fontSize: 13 }}>
{profile.email && (
<div>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>Email</Typography.Text>
<div>{profile.email}</div>
</div>
)}
{profile.phone && (
<div>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>Phone</Typography.Text>
<div>{profile.phone}</div>
</div>
)}
</div>
{/* Opt-out flags */}
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
{profile.emailOptOut && <Tag color="red">Email Opt-Out</Tag>}
{profile.smsOptOut && <Tag color="red">SMS Opt-Out</Tag>}
</div>
</div>
)}
</Modal>
);
}

View File

@ -0,0 +1,443 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Tabs,
Table,
Button,
Input,
Space,
Tag,
Popconfirm,
Switch,
message,
Empty,
Typography,
ColorPicker,
Badge,
Tooltip,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
SyncOutlined,
CheckOutlined,
CloseOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { CrmTag, UnregisteredTag } from '@/types/api';
interface TagManagementDrawerProps {
open: boolean;
onClose: () => void;
onTagsChanged: () => void;
}
export default function TagManagementDrawer({ open, onClose, onTagsChanged }: TagManagementDrawerProps) {
const [tags, setTags] = useState<CrmTag[]>([]);
const [unregistered, setUnregistered] = useState<UnregisteredTag[]>([]);
const [loading, setLoading] = useState(false);
const [unregLoading, setUnregLoading] = useState(false);
// Create form
const [newName, setNewName] = useState('');
const [newDescription, setNewDescription] = useState('');
const [newColor, setNewColor] = useState('#1890ff');
const [newSyncToListmonk, setNewSyncToListmonk] = useState(false);
const [creating, setCreating] = useState(false);
// Inline edit
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editColor, setEditColor] = useState('');
const [editSaving, setEditSaving] = useState(false);
const fetchTags = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<{ tags: CrmTag[] }>('/people/tags');
setTags(data.tags);
} catch {
message.error('Failed to load tags');
} finally {
setLoading(false);
}
}, []);
const fetchUnregistered = useCallback(async () => {
setUnregLoading(true);
try {
const { data } = await api.get<{ tags: UnregisteredTag[] }>('/people/tags/unregistered');
setUnregistered(data.tags);
} catch {
message.error('Failed to load unregistered tags');
} finally {
setUnregLoading(false);
}
}, []);
useEffect(() => {
if (open) {
fetchTags();
fetchUnregistered();
}
}, [open, fetchTags, fetchUnregistered]);
const handleCreate = async () => {
if (!newName.trim()) return;
setCreating(true);
try {
await api.post('/people/tags', {
name: newName.trim(),
description: newDescription || undefined,
color: newColor || undefined,
syncToListmonk: newSyncToListmonk,
});
message.success(`Tag "${newName}" created`);
setNewName('');
setNewDescription('');
setNewColor('#1890ff');
setNewSyncToListmonk(false);
fetchTags();
fetchUnregistered();
onTagsChanged();
} catch {
message.error('Failed to create tag');
} finally {
setCreating(false);
}
};
const startEdit = (tag: CrmTag) => {
setEditingId(tag.id);
setEditName(tag.name);
setEditDescription(tag.description || '');
setEditColor(tag.color || '#1890ff');
};
const handleSaveEdit = async () => {
if (!editingId || !editName.trim()) return;
setEditSaving(true);
try {
await api.put(`/people/tags/${editingId}`, {
name: editName.trim(),
description: editDescription || null,
color: editColor || null,
});
message.success('Tag updated');
setEditingId(null);
fetchTags();
onTagsChanged();
} catch {
message.error('Failed to update tag');
} finally {
setEditSaving(false);
}
};
const handleDelete = async (id: string, name: string, removeFromContacts: boolean) => {
try {
await api.delete(`/people/tags/${id}`, {
params: { removeFromContacts, deleteListmonkList: true },
});
message.success(`Tag "${name}" deleted`);
fetchTags();
fetchUnregistered();
onTagsChanged();
} catch {
message.error('Failed to delete tag');
}
};
const handleSync = async (id: string) => {
try {
const { data } = await api.post<{ total: number; synced: number; failed: number }>(
`/people/tags/${id}/sync`,
);
message.success(`Synced ${data.synced}/${data.total} contacts to Listmonk`);
} catch {
message.error('Failed to sync tag');
}
};
const handleRegister = async (name: string) => {
try {
await api.post('/people/tags', { name });
message.success(`Tag "${name}" registered`);
fetchTags();
fetchUnregistered();
onTagsChanged();
} catch {
message.error('Failed to register tag');
}
};
const handleRegisterAll = async () => {
try {
const { data } = await api.post<{ registered: number }>('/people/tags/register-all', {});
message.success(`Registered ${data.registered} tags`);
fetchTags();
fetchUnregistered();
onTagsChanged();
} catch {
message.error('Failed to register tags');
}
};
const tagColumns: ColumnsType<CrmTag> = [
{
title: 'Tag',
key: 'name',
render: (_: unknown, record: CrmTag) => {
if (editingId === record.id) {
return (
<Space>
<ColorPicker
size="small"
value={editColor}
onChange={(_, hex) => setEditColor(hex)}
/>
<Input
size="small"
value={editName}
onChange={(e) => setEditName(e.target.value)}
style={{ width: 120 }}
onPressEnter={handleSaveEdit}
/>
</Space>
);
}
return (
<Space size={6}>
{record.color && (
<Badge color={record.color} />
)}
<span>{record.name}</span>
</Space>
);
},
},
{
title: 'Description',
key: 'description',
ellipsis: true,
render: (_: unknown, record: CrmTag) => {
if (editingId === record.id) {
return (
<Input
size="small"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description..."
/>
);
}
return record.description || <Typography.Text type="secondary">--</Typography.Text>;
},
},
{
title: 'Contacts',
dataIndex: 'contactCount',
key: 'contactCount',
width: 80,
align: 'center',
},
{
title: 'Listmonk',
key: 'listmonk',
width: 80,
align: 'center',
render: (_: unknown, record: CrmTag) =>
record.listmonkListId ? (
<Tooltip title={`List ID: ${record.listmonkListId}`}>
<Tag color="green">Synced</Tag>
</Tooltip>
) : (
<Tag>--</Tag>
),
},
{
title: '',
key: 'actions',
width: 120,
render: (_: unknown, record: CrmTag) => {
if (editingId === record.id) {
return (
<Space size={4}>
<Button
type="text"
size="small"
icon={<CheckOutlined />}
loading={editSaving}
onClick={handleSaveEdit}
/>
<Button
type="text"
size="small"
icon={<CloseOutlined />}
onClick={() => setEditingId(null)}
/>
</Space>
);
}
return (
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => startEdit(record)} />
{record.listmonkListId && (
<Button type="text" size="small" icon={<SyncOutlined />} onClick={() => handleSync(record.id)} />
)}
<Popconfirm
title="Delete this tag?"
description="Also remove from all contacts?"
onConfirm={() => handleDelete(record.id, record.name, true)}
onCancel={() => handleDelete(record.id, record.name, false)}
okText="Remove from contacts"
cancelText="Keep on contacts"
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
);
},
},
];
const unregColumns: ColumnsType<UnregisteredTag> = [
{
title: 'Tag Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Used By',
dataIndex: 'count',
key: 'count',
width: 80,
render: (count: number) => `${count} contacts`,
},
{
title: '',
key: 'actions',
width: 80,
render: (_: unknown, record: UnregisteredTag) => (
<Button size="small" onClick={() => handleRegister(record.name)}>
Register
</Button>
),
},
];
return (
<Drawer
title="Manage Tags"
open={open}
onClose={onClose}
width={640}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
>
<Tabs
items={[
{
key: 'registered',
label: `Registered (${tags.length})`,
children: (
<div>
{/* Create form */}
<div
style={{
display: 'flex',
gap: 8,
marginBottom: 16,
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<ColorPicker
size="small"
value={newColor}
onChange={(_, hex) => setNewColor(hex)}
/>
<Input
size="small"
placeholder="Tag name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
style={{ width: 140 }}
onPressEnter={handleCreate}
/>
<Input
size="small"
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
style={{ width: 180 }}
/>
<Space size={4}>
<Switch
size="small"
checked={newSyncToListmonk}
onChange={setNewSyncToListmonk}
/>
<Typography.Text style={{ fontSize: 12 }}>Listmonk</Typography.Text>
</Space>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={handleCreate}
loading={creating}
disabled={!newName.trim()}
>
Add
</Button>
</div>
<Table<CrmTag>
columns={tagColumns}
dataSource={tags}
rowKey="id"
loading={loading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="No registered tags" /> }}
/>
</div>
),
},
{
key: 'discover',
label: `Discover (${unregistered.length})`,
children: (
<div>
{unregistered.length > 0 && (
<div style={{ marginBottom: 12 }}>
<Button size="small" onClick={handleRegisterAll}>
Register All ({unregistered.length})
</Button>
</div>
)}
<Table<UnregisteredTag>
columns={unregColumns}
dataSource={unregistered}
rowKey="name"
loading={unregLoading}
pagination={false}
size="small"
locale={{
emptyText: (
<Empty description="All tags in use are registered" />
),
}}
/>
</div>
),
},
]}
/>
</Drawer>
);
}

View File

@ -0,0 +1,82 @@
import { Tag, Space, Button, Spin, Typography } from 'antd';
import { useState, useEffect } from 'react';
import { UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { User, UserRole, UserStatus } from '@/types/api';
const roleColors: Record<UserRole, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'volcano',
MAP_ADMIN: 'orange',
USER: 'blue',
TEMP: 'default',
};
const statusColors: Record<UserStatus, string> = {
ACTIVE: 'green',
INACTIVE: 'default',
SUSPENDED: 'red',
EXPIRED: 'orange',
PENDING_VERIFICATION: 'purple',
PENDING_APPROVAL: 'gold',
};
interface UserAccountStatusPanelProps {
userId: string;
}
export default function UserAccountStatusPanel({ userId }: UserAccountStatusPanelProps) {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
setLoading(true);
api.get<User>(`/users/${userId}`)
.then(({ data }) => setUser(data))
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spin size="small" />;
if (!user) return <Typography.Text type="secondary">User account not found.</Typography.Text>;
const roles = Array.isArray(user.roles) && user.roles.length > 0 ? user.roles : [user.role];
return (
<div
style={{
padding: '10px 12px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<Space size={6}>
<Tag color={statusColors[user.status]}>{user.status.replace(/_/g, ' ')}</Tag>
{roles.map((r) => (
<Tag key={r} color={roleColors[r]} style={{ margin: 0 }}>{r.replace(/_/g, ' ')}</Tag>
))}
</Space>
<Button
size="small"
icon={<UserOutlined />}
onClick={() => navigate('/app/users')}
>
Edit User
</Button>
</div>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>
<span>{user.email}</span>
{user.lastLoginAt && (
<span style={{ marginLeft: 12 }}>
Last login: {dayjs(user.lastLoginAt).format('MMM D, YYYY h:mm A')}
</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,134 @@
import { useState, useEffect } from 'react';
import { Modal, Button, Typography, Spin, Result, message, Space } from 'antd';
import { VideoCameraOutlined, CopyOutlined, LoginOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
const { Text, Paragraph } = Typography;
interface VideoCallModalProps {
open: boolean;
onClose: () => void;
personName: string;
}
interface CreatedMeeting {
id: string;
slug: string;
title: string;
jitsiRoom: string;
}
export default function VideoCallModal({ open, onClose, personName }: VideoCallModalProps) {
const [creating, setCreating] = useState(false);
const [meeting, setMeeting] = useState<CreatedMeeting | null>(null);
const [error, setError] = useState<string | null>(null);
const [joining, setJoining] = useState(false);
useEffect(() => {
if (!open) return;
setCreating(true);
setMeeting(null);
setError(null);
api.post<CreatedMeeting>('/jitsi/meetings', {
title: `Call with ${personName}`,
})
.then(({ data }) => setMeeting(data))
.catch((err) => {
setError(err.response?.data?.error || 'Failed to create meeting');
})
.finally(() => setCreating(false));
}, [open, personName]);
const guestLink = meeting ? `${window.location.origin}/meet/${meeting.slug}` : '';
const handleCopyLink = () => {
navigator.clipboard.writeText(guestLink).then(
() => message.success('Guest link copied'),
() => message.error('Failed to copy'),
);
};
const handleJoinAsHost = async () => {
if (!meeting) return;
setJoining(true);
try {
const { data } = await api.post<{ token: string; jitsiRoom: string; domain: string }>(
`/jitsi/meetings/${meeting.slug}/token`,
);
window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank');
} catch (err: any) {
message.error(err.response?.data?.error || 'Failed to get moderator token');
} finally {
setJoining(false);
}
};
return (
<Modal
title={<><VideoCameraOutlined style={{ marginRight: 8 }} />Video Call</>}
open={open}
onCancel={onClose}
footer={null}
width={440}
destroyOnClose
>
{creating ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin size="large" />
<div style={{ marginTop: 12 }}>
<Text type="secondary">Creating meeting room...</Text>
</div>
</div>
) : error ? (
<Result
status="error"
title="Could not create meeting"
subTitle={error}
extra={<Button onClick={onClose}>Close</Button>}
/>
) : meeting ? (
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
Share the guest link below with <Text strong>{personName}</Text> to invite them to the call.
</Text>
<div style={{
padding: '12px 16px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
marginBottom: 16,
}}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Guest Join Link</Text>
<Paragraph
copyable={{ text: guestLink, tooltips: ['Copy', 'Copied'] }}
style={{ margin: 0, fontSize: 13, wordBreak: 'break-all' }}
>
{guestLink}
</Paragraph>
</div>
<Space style={{ width: '100%' }} direction="vertical" size={8}>
<Button
type="primary"
icon={<LoginOutlined />}
block
loading={joining}
onClick={handleJoinAsHost}
>
Join as Host
</Button>
<Button
icon={<CopyOutlined />}
block
onClick={handleCopyLink}
>
Copy Guest Link
</Button>
</Space>
</div>
) : null}
</Modal>
);
}

View File

@ -0,0 +1,111 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Typography, Spin, Empty, theme } from 'antd';
import {
SendOutlined,
ScheduleOutlined,
PlayCircleOutlined,
MessageOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const { Text } = Typography;
interface ActivityItem {
type: string;
title: string;
description: string | null;
link: string;
timestamp: string;
}
const TYPE_ICONS: Record<string, React.ReactNode> = {
campaign_published: <SendOutlined />,
shift_created: <ScheduleOutlined />,
media_published: <PlayCircleOutlined />,
response_approved: <MessageOutlined />,
};
const TYPE_COLORS: Record<string, string> = {
campaign_published: '#3498db',
shift_created: '#27ae60',
media_published: '#9b59b6',
response_approved: '#e67e22',
};
interface ActivityFeedProps {
limit?: number;
compact?: boolean;
}
export default function ActivityFeed({ limit = 10, compact = false }: ActivityFeedProps) {
const [items, setItems] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const { token } = theme.useToken();
useEffect(() => {
axios.get<ActivityItem[]>('/api/activity/public', { params: { limit } })
.then(({ data }) => setItems(data))
.catch(() => {})
.finally(() => setLoading(false));
}, [limit]);
if (loading) {
return <div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>;
}
if (items.length === 0) {
return <Empty description="No recent activity" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: compact ? 8 : 12 }}>
{items.map((item, idx) => (
<Link
key={`${item.type}-${idx}`}
to={item.link}
style={{ textDecoration: 'none', display: 'flex', alignItems: 'flex-start', gap: 12 }}
>
<div
style={{
width: compact ? 28 : 32,
height: compact ? 28 : 32,
borderRadius: '50%',
background: `${TYPE_COLORS[item.type] || token.colorPrimary}20`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
color: TYPE_COLORS[item.type] || token.colorPrimary,
fontSize: compact ? 12 : 14,
}}
>
{TYPE_ICONS[item.type] || <ClockCircleOutlined />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text
style={{
display: 'block',
color: 'rgba(255,255,255,0.85)',
fontSize: compact ? 13 : 14,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.title}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(item.timestamp).fromNow()}
</Text>
</div>
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,31 @@
import { Typography } from 'antd';
import { Link } from 'react-router-dom';
import { RightOutlined } from '@ant-design/icons';
const { Title } = Typography;
interface HomepageSectionProps {
title: string;
viewAllLink?: string;
viewAllLabel?: string;
children: React.ReactNode;
}
export default function HomepageSection({ title, viewAllLink, viewAllLabel = 'View all', children }: HomepageSectionProps) {
return (
<section style={{ marginBottom: 40 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0, color: '#fff' }}>{title}</Title>
{viewAllLink && (
<Link
to={viewAllLink}
style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}
>
{viewAllLabel} <RightOutlined style={{ fontSize: 10 }} />
</Link>
)}
</div>
{children}
</section>
);
}

View File

@ -0,0 +1,68 @@
import { useState } from 'react';
import { Input, Button, Typography, message, Space } from 'antd';
import { MailOutlined, CheckCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
const { Text } = Typography;
export default function NewsletterSignup() {
const { settings } = useSettingsStore();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
// Only show if newsletter/listmonk is enabled
if (settings?.enableNewsletter === false) return null;
const handleSubscribe = async () => {
if (!email.trim()) {
message.warning('Please enter your email');
return;
}
setLoading(true);
try {
await axios.post('/api/newsletter/subscribe', { email: email.trim() });
setSuccess(true);
setEmail('');
} catch (err: any) {
const msg = err?.response?.data?.error?.message || 'Failed to subscribe';
message.error(msg);
} finally {
setLoading(false);
}
};
if (success) {
return (
<div style={{ textAlign: 'center', padding: '16px 0' }}>
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 20, marginRight: 8 }} />
<Text style={{ color: 'rgba(255,255,255,0.75)' }}>
Check your email to confirm your subscription
</Text>
</div>
);
}
return (
<div style={{ textAlign: 'center', padding: '16px 0' }}>
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 8, fontSize: 13 }}>
Stay updated with the latest news
</Text>
<Space.Compact style={{ maxWidth: 360, width: '100%' }}>
<Input
prefix={<MailOutlined style={{ color: 'rgba(255,255,255,0.3)' }} />}
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onPressEnter={handleSubscribe}
disabled={loading}
style={{ flex: 1 }}
/>
<Button type="primary" onClick={handleSubscribe} loading={loading}>
Subscribe
</Button>
</Space.Compact>
</div>
);
}

View File

@ -0,0 +1,115 @@
import { Typography, Tag, Space, Grid } from 'antd';
import {
SendOutlined,
ScheduleOutlined,
EnvironmentOutlined,
TeamOutlined,
MailOutlined,
} from '@ant-design/icons';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
const { Text } = Typography;
interface RelatedCampaign {
id: string;
slug: string;
title: string;
description: string | null;
emailCount: number;
}
interface RelatedShift {
id: string;
title: string;
startTime: string;
location: string | null;
currentVolunteers: number;
maxVolunteers: number | null;
}
interface RelatedContentProps {
campaigns?: RelatedCampaign[];
shifts?: RelatedShift[];
}
export default function RelatedContent({ campaigns = [], shifts = [] }: RelatedContentProps) {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
if (campaigns.length === 0 && shifts.length === 0) return null;
const cardStyle: React.CSSProperties = {
minWidth: isMobile ? 220 : 240,
maxWidth: 280,
padding: '12px 14px',
borderRadius: 8,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
};
return (
<div style={{ marginTop: 32 }}>
{campaigns.length > 0 && (
<div style={{ marginBottom: 24 }}>
<Text strong style={{ color: 'rgba(255,255,255,0.75)', fontSize: 14, display: 'block', marginBottom: 12 }}>
<SendOutlined style={{ marginRight: 6 }} />Related Campaigns
</Text>
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', paddingBottom: 4 }}>
{campaigns.map(c => (
<Link key={c.id} to={`/campaign/${c.slug}`} style={{ textDecoration: 'none' }}>
<div style={cardStyle}>
<Text strong style={{ color: '#fff', fontSize: 14, display: 'block' }}>{c.title}</Text>
{c.description && (
<Text style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12, display: 'block', marginTop: 4 }}>
{c.description}
</Text>
)}
<Tag icon={<MailOutlined />} color="blue" style={{ marginTop: 8 }}>{c.emailCount} emails</Tag>
</div>
</Link>
))}
</div>
</div>
)}
{shifts.length > 0 && (
<div>
<Text strong style={{ color: 'rgba(255,255,255,0.75)', fontSize: 14, display: 'block', marginBottom: 12 }}>
<ScheduleOutlined style={{ marginRight: 6 }} />Upcoming Shifts
</Text>
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', paddingBottom: 4 }}>
{shifts.map(s => {
const start = dayjs(s.startTime);
return (
<Link key={s.id} to="/shifts" style={{ textDecoration: 'none' }}>
<div style={cardStyle}>
<Text strong style={{ color: '#fff', fontSize: 14, display: 'block' }}>{s.title}</Text>
<Space direction="vertical" size={2} style={{ marginTop: 6 }}>
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12 }}>
<ScheduleOutlined style={{ marginRight: 4 }} />
{start.format('ddd, MMM D')} at {start.format('h:mm A')}
</Text>
{s.location && (
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12 }}>
<EnvironmentOutlined style={{ marginRight: 4 }} />
{s.location}
</Text>
)}
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12 }}>
<TeamOutlined style={{ marginRight: 4 }} />
{s.currentVolunteers} signed up
{s.maxVolunteers && ` / ${s.maxVolunteers}`}
</Text>
</Space>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,215 @@
import { useState } from 'react';
import {
Modal,
Form,
Input,
Button,
Result,
message,
Grid,
Typography,
} from 'antd';
import {
ScheduleOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
CheckCircleOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { Link } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
const { Text } = Typography;
interface ShiftData {
id: string;
title: string;
date: string;
startTime: string;
endTime: string;
location: string | null;
meeting?: { id: string; slug: string; isActive: boolean } | null;
}
interface ShiftSignupModalProps {
shift: ShiftData | null;
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export default function ShiftSignupModal({ shift, open, onClose, onSuccess }: ShiftSignupModalProps) {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [successShift, setSuccessShift] = useState<ShiftData | null>(null);
const handleSignup = async (values: { name: string; email: string; phone?: string }) => {
if (!shift) return;
setSubmitting(true);
try {
await axios.post(`/api/map/shifts/public/${shift.id}/signup`, {
name: values.name,
email: values.email,
phone: values.phone || undefined,
});
setSuccessShift(shift);
onClose();
form.resetFields();
onSuccess?.();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to sign up';
message.error(msg);
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
form.resetFields();
onClose();
};
return (
<>
{/* Signup Form Modal */}
<Modal
title={
<span>
<ScheduleOutlined style={{ marginRight: 8 }} />
Sign Up {shift?.title}
</span>
}
open={open}
onCancel={handleClose}
footer={null}
width={isMobile ? '100%' : 440}
style={{ maxWidth: '95vw' }}
destroyOnClose
>
{shift && (
<div style={{
padding: '12px 16px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
marginBottom: 16,
}}>
<div>
<ScheduleOutlined style={{ marginRight: 8 }} />
<Text>{dayjs(shift.date).format('dddd, MMMM D, YYYY')}</Text>
</div>
<div>
<ClockCircleOutlined style={{ marginRight: 8 }} />
<Text>{shift.startTime} {shift.endTime}</Text>
</div>
{shift.location && (
<div>
<EnvironmentOutlined style={{ marginRight: 8 }} />
<Text>{shift.location}</Text>
</div>
)}
</div>
)}
<Form form={form} layout="vertical" onFinish={handleSignup}>
<Form.Item
name="name"
label="Your Name"
rules={[{ required: true, message: 'Name is required' }]}
>
<Input placeholder="Jane Doe" autoFocus />
</Form.Item>
<Form.Item
name="email"
label="Email Address"
rules={[
{ required: true, message: 'Email is required' },
{ type: 'email', message: 'Enter a valid email' },
]}
>
<Input placeholder="jane@example.com" />
</Form.Item>
<Form.Item name="phone" label="Phone (optional)">
<Input placeholder="555-123-4567" />
</Form.Item>
<div style={{ textAlign: 'right' }}>
<Button onClick={handleClose} style={{ marginRight: 8 }}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Sign Up
</Button>
</div>
</Form>
</Modal>
{/* Success Modal */}
<Modal
open={!!successShift}
onCancel={() => setSuccessShift(null)}
footer={
<Button type="primary" onClick={() => setSuccessShift(null)}>
Done
</Button>
}
width={isMobile ? '100%' : 440}
style={{ maxWidth: '95vw' }}
>
{successShift && (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="You're signed up!"
subTitle="A confirmation email has been sent with your shift details."
extra={
<div style={{
textAlign: 'left',
padding: '16px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
}}>
<div style={{ marginBottom: 4 }}>
<Text strong>{successShift.title}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<ScheduleOutlined style={{ marginRight: 8 }} />
<Text>{dayjs(successShift.date).format('dddd, MMMM D, YYYY')}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<ClockCircleOutlined style={{ marginRight: 8 }} />
<Text>{successShift.startTime} {successShift.endTime}</Text>
</div>
{successShift.location && (
<div>
<EnvironmentOutlined style={{ marginRight: 8 }} />
<Text>{successShift.location}</Text>
</div>
)}
{successShift.meeting?.isActive && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Button
type="primary"
ghost
block
icon={<VideoCameraOutlined />}
href={`/meet/${successShift.meeting.slug}`}
target="_blank"
>
Join Video Briefing
</Button>
</div>
)}
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
<Link to="/login" style={{ color: 'inherit', textDecoration: 'underline' }}>Create an account</Link> to access GPS-guided canvassing and track your volunteer activity.
</Text>
</div>
</div>
}
/>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,98 @@
import { useNavigate } from 'react-router-dom';
import { Card, Typography, theme } from 'antd';
import { ScheduleOutlined, MailOutlined, EnvironmentOutlined, MessageOutlined } from '@ant-design/icons';
import UserAvatar from './UserAvatar';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const TYPE_CONFIG: Record<string, { icon: React.ReactNode; color: string; getLink?: (meta: Record<string, unknown>) => string }> = {
shift_signup: {
icon: <ScheduleOutlined />,
color: '#52c41a',
getLink: () => `/shifts`,
},
campaign_email: {
icon: <MailOutlined />,
color: '#1890ff',
getLink: (meta) => `/campaigns/${meta.campaignSlug as string}`,
},
canvass_session: {
icon: <EnvironmentOutlined />,
color: '#fa8c16',
},
response_submitted: {
icon: <MessageOutlined />,
color: '#722ed1',
getLink: (meta) => `/campaigns/${meta.campaignId as string}`,
},
};
interface FeedItem {
id: string;
type: string;
userId: string;
userName: string | null;
userEmail: string;
title: string;
description: string;
metadata: Record<string, unknown>;
timestamp: string;
}
interface FeedCardProps {
item: FeedItem;
}
export default function FeedCard({ item }: FeedCardProps) {
const navigate = useNavigate();
const { token } = theme.useToken();
const config = TYPE_CONFIG[item.type] || { icon: null, color: token.colorPrimary };
const handleClick = () => {
if (config.getLink) {
navigate(config.getLink(item.metadata));
}
};
const handleAvatarClick = (e: React.MouseEvent) => {
e.stopPropagation();
navigate(`/volunteer/profile/${item.userId}`);
};
return (
<Card
size="small"
style={{
marginBottom: 8,
cursor: config.getLink ? 'pointer' : undefined,
borderLeft: `3px solid ${config.color}`,
}}
onClick={handleClick}
>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<div onClick={handleAvatarClick} style={{ cursor: 'pointer', flexShrink: 0 }}>
<UserAvatar userId={item.userId} name={item.userName} email={item.userEmail} size={36} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: config.color, fontSize: 14 }}>{config.icon}</span>
<Typography.Text strong style={{ fontSize: 13 }}>
{item.userName || item.userEmail}
</Typography.Text>
</div>
<Typography.Text style={{ fontSize: 13, display: 'block' }}>
{item.title}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{item.description}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(item.timestamp).fromNow()}
</Typography.Text>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import { Button, Dropdown, message, Spin } from 'antd';
import {
UserAddOutlined,
UserDeleteOutlined,
CheckOutlined,
CloseOutlined,
StopOutlined,
EllipsisOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useSocialStore } from '@/stores/social.store';
import { api } from '@/lib/api';
import type { FriendshipStatusResponse } from '@/types/social';
interface FriendButtonProps {
userId: string;
status?: FriendshipStatusResponse;
onStatusChange?: () => void;
size?: 'small' | 'middle' | 'large';
}
export default function FriendButton({ userId, status: statusProp, onStatusChange, size = 'middle' }: FriendButtonProps) {
const [loading, setLoading] = useState(false);
const [fetchedStatus, setFetchedStatus] = useState<FriendshipStatusResponse | null>(null);
const { sendFriendRequest, acceptRequest, declineRequest, cancelRequest, unfriend, blockUser } = useSocialStore();
// Self-fetch status when not provided by parent
useEffect(() => {
if (statusProp) return;
let cancelled = false;
api.get(`/social/friends/status/${userId}`).then(({ data }) => {
if (!cancelled) setFetchedStatus(data);
}).catch(() => {
if (!cancelled) setFetchedStatus({ status: 'none' } as FriendshipStatusResponse);
});
return () => { cancelled = true; };
}, [userId, statusProp]);
const status = statusProp ?? fetchedStatus;
if (!status) return <Spin size="small" />;
const handleAction = async (action: () => Promise<void>, successMsg: string) => {
setLoading(true);
try {
await action();
message.success(successMsg);
onStatusChange?.();
// Re-fetch status if self-managed
if (!statusProp) {
api.get(`/social/friends/status/${userId}`).then(({ data }) => setFetchedStatus(data)).catch(() => {});
}
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Action failed');
} finally {
setLoading(false);
}
};
// No friendship
if (status.status === 'none') {
return (
<Button
type="primary"
icon={<UserAddOutlined />}
size={size}
loading={loading}
onClick={() => handleAction(() => sendFriendRequest(userId), 'Friend request sent')}
>
Add Friend
</Button>
);
}
// Pending — we sent the request
if (status.status === 'pending' && status.direction === 'sent') {
return (
<Button
icon={<CloseOutlined />}
size={size}
loading={loading}
onClick={() => handleAction(() => cancelRequest(status.friendshipId!), 'Request cancelled')}
>
Cancel Request
</Button>
);
}
// Pending — they sent us a request
if (status.status === 'pending' && status.direction === 'received') {
return (
<Button.Group>
<Button
type="primary"
icon={<CheckOutlined />}
size={size}
loading={loading}
onClick={() => handleAction(() => acceptRequest(status.friendshipId!), 'Friend request accepted')}
>
Accept
</Button>
<Button
icon={<CloseOutlined />}
size={size}
loading={loading}
onClick={() => handleAction(() => declineRequest(status.friendshipId!), 'Request declined')}
>
Decline
</Button>
</Button.Group>
);
}
// Accepted — friends
if (status.status === 'accepted') {
const menuItems: MenuProps['items'] = [
{ key: 'unfriend', icon: <UserDeleteOutlined />, label: 'Unfriend', danger: true,
onClick: () => handleAction(() => unfriend(userId), 'Unfriended') },
{ key: 'block', icon: <StopOutlined />, label: 'Block', danger: true,
onClick: () => handleAction(() => blockUser(userId), 'User blocked') },
];
return (
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
<Button icon={<CheckOutlined />} size={size}>
Friends <EllipsisOutlined />
</Button>
</Dropdown>
);
}
// Blocked
if (status.status === 'blocked') {
return (
<Button disabled size={size} icon={<StopOutlined />}>
Blocked
</Button>
);
}
return null;
}

View File

@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Button, Typography, message } from 'antd';
import { UserAddOutlined, CloseOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useSocialStore } from '@/stores/social.store';
import UserAvatar from './UserAvatar';
interface Suggestion {
userId: string;
name: string | null;
email: string;
role: string;
reason: string;
score: number;
}
interface FriendSuggestionsProps {
limit?: number;
}
export default function FriendSuggestions({ limit = 5 }: FriendSuggestionsProps) {
const navigate = useNavigate();
const { sendFriendRequest } = useSocialStore();
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
loadSuggestions();
}, []);
const loadSuggestions = async () => {
try {
const { data } = await api.get('/social/suggestions', { params: { limit } });
setSuggestions(data.suggestions);
} catch {} finally {
setLoading(false);
}
};
const handleAdd = async (userId: string) => {
setActionLoading(userId);
try {
await sendFriendRequest(userId);
message.success('Friend request sent');
setSuggestions((prev) => prev.filter((s) => s.userId !== userId));
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to send request');
} finally {
setActionLoading(null);
}
};
const handleDismiss = async (userId: string) => {
try {
await api.post(`/social/suggestions/${userId}/dismiss`);
setSuggestions((prev) => prev.filter((s) => s.userId !== userId));
} catch {}
};
if (loading || suggestions.length === 0) return null;
return (
<Card size="small" title="People you may know" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', overflowX: 'auto', gap: 10, padding: '4px 0' }}>
{suggestions.map((s) => (
<div
key={s.userId}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: 110,
maxWidth: 110,
textAlign: 'center',
position: 'relative',
}}
>
<Button
type="text"
size="small"
icon={<CloseOutlined />}
style={{ position: 'absolute', top: -6, right: -6, fontSize: 10, opacity: 0.5 }}
onClick={() => handleDismiss(s.userId)}
/>
<div
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/volunteer/profile/${s.userId}`)}
>
<UserAvatar userId={s.userId} name={s.name} email={s.email} size={44} />
</div>
<Typography.Text
ellipsis
style={{ fontSize: 12, fontWeight: 500, marginTop: 4, maxWidth: 100 }}
>
{s.name || s.email}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 10, lineHeight: '14px' }}>
{s.reason}
</Typography.Text>
<Button
type="primary"
size="small"
icon={<UserAddOutlined />}
loading={actionLoading === s.userId}
onClick={() => handleAdd(s.userId)}
style={{ marginTop: 4, fontSize: 11 }}
>
Add
</Button>
</div>
))}
</div>
</Card>
);
}

View File

@ -0,0 +1,62 @@
import { useState, useEffect } from 'react';
import { Tooltip, Avatar } from 'antd';
import { TeamOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import UserAvatar from './UserAvatar';
interface FriendInfo {
id: string;
name: string | null;
email: string;
}
interface Props {
shiftId: string | number;
}
export default function FriendsAttendingBadge({ shiftId }: Props) {
const { isAuthenticated } = useAuthStore();
const { settings } = useSettingsStore();
const [friends, setFriends] = useState<FriendInfo[]>([]);
useEffect(() => {
if (!isAuthenticated || !settings?.enableSocial) return;
api.get(`/social/integration/shifts/${shiftId}/friends`)
.then(({ data }) => setFriends(data.friends || []))
.catch(() => {});
}, [shiftId, isAuthenticated, settings?.enableSocial]);
if (!isAuthenticated || !settings?.enableSocial || friends.length === 0) return null;
const displayFriends = friends.slice(0, 3);
const remaining = friends.length - displayFriends.length;
return (
<Tooltip
title={
friends.length <= 5
? friends.map((f) => f.name || f.email).join(', ')
: `${friends.slice(0, 5).map((f) => f.name || f.email).join(', ')} and ${friends.length - 5} more`
}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 }}>
<TeamOutlined style={{ color: '#52c41a', fontSize: 13 }} />
<Avatar.Group size="small" max={{ count: 3 }}>
{displayFriends.map((f) => (
<UserAvatar key={f.id} userId={f.id} name={f.name || f.email} size={24} />
))}
{remaining > 0 && (
<Avatar size={24} style={{ backgroundColor: '#52c41a', fontSize: 11 }}>
+{remaining}
</Avatar>
)}
</Avatar.Group>
<span style={{ color: '#52c41a', fontSize: 12, fontWeight: 500 }}>
{friends.length} friend{friends.length !== 1 ? 's' : ''} attending
</span>
</div>
</Tooltip>
);
}

View File

@ -0,0 +1,62 @@
import { useState, useEffect } from 'react';
import { Tooltip, Avatar } from 'antd';
import { HeartOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import UserAvatar from './UserAvatar';
interface FriendInfo {
id: string;
name: string | null;
email: string;
}
interface Props {
campaignId: string;
}
export default function FriendsCampaignBadge({ campaignId }: Props) {
const { isAuthenticated } = useAuthStore();
const { settings } = useSettingsStore();
const [friends, setFriends] = useState<FriendInfo[]>([]);
useEffect(() => {
if (!isAuthenticated || !settings?.enableSocial) return;
api.get(`/social/integration/campaigns/${campaignId}/friends`)
.then(({ data }) => setFriends(data.friends || []))
.catch(() => {});
}, [campaignId, isAuthenticated, settings?.enableSocial]);
if (!isAuthenticated || !settings?.enableSocial || friends.length === 0) return null;
const displayFriends = friends.slice(0, 3);
const remaining = friends.length - displayFriends.length;
return (
<Tooltip
title={
friends.length <= 5
? friends.map((f) => f.name || f.email).join(', ')
: `${friends.slice(0, 5).map((f) => f.name || f.email).join(', ')} and ${friends.length - 5} more`
}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 }}>
<HeartOutlined style={{ color: '#eb2f96', fontSize: 13 }} />
<Avatar.Group size="small" max={{ count: 3 }}>
{displayFriends.map((f) => (
<UserAvatar key={f.id} userId={f.id} name={f.name || f.email} size={24} />
))}
{remaining > 0 && (
<Avatar size={24} style={{ backgroundColor: '#eb2f96', fontSize: 11 }}>
+{remaining}
</Avatar>
)}
</Avatar.Group>
<span style={{ color: '#eb2f96', fontSize: 12, fontWeight: 500 }}>
{friends.length} friend{friends.length !== 1 ? 's' : ''} participated
</span>
</div>
</Tooltip>
);
}

View File

@ -0,0 +1,121 @@
import { useState, useEffect } from 'react';
import { Badge, Tooltip, List, Typography } from 'antd';
import { TeamOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const { Text } = Typography;
interface MapFriend {
id: string;
name: string | null;
email: string;
cutName: string;
cutId: number;
startedAt: string;
}
interface Props {
/** Callback when user clicks a friend (e.g. to pan to their area) */
onFriendClick?: (friend: MapFriend) => void;
}
/**
* Floating panel showing friends currently canvassing.
* Designed to overlay on the volunteer map.
*/
export default function FriendsOnMap({ onFriendClick }: Props) {
const { isAuthenticated } = useAuthStore();
const { settings } = useSettingsStore();
const [friends, setFriends] = useState<MapFriend[]>([]);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
if (!isAuthenticated || !settings?.enableSocial) return;
const fetch = () => {
api.get('/social/integration/map/friends')
.then(({ data }) => setFriends(data.friends || []))
.catch(() => {});
};
fetch();
// Refresh every 60 seconds
const interval = setInterval(fetch, 60_000);
return () => clearInterval(interval);
}, [isAuthenticated, settings?.enableSocial]);
if (!isAuthenticated || !settings?.enableSocial || friends.length === 0) return null;
return (
<div
style={{
position: 'absolute',
top: 10,
right: 10,
zIndex: 1000,
background: 'rgba(13, 27, 42, 0.92)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.12)',
minWidth: collapsed ? 'auto' : 220,
maxWidth: 280,
backdropFilter: 'blur(8px)',
overflow: 'hidden',
}}
>
<div
onClick={() => setCollapsed((c) => !c)}
style={{
padding: '8px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
borderBottom: collapsed ? 'none' : '1px solid rgba(255,255,255,0.08)',
}}
>
<Badge count={friends.length} size="small" color="#52c41a">
<TeamOutlined style={{ color: '#52c41a', fontSize: 16 }} />
</Badge>
<Text style={{ color: '#fff', fontSize: 13, fontWeight: 500 }}>
Friends Canvassing
</Text>
</div>
{!collapsed && (
<List
size="small"
dataSource={friends}
style={{ maxHeight: 200, overflow: 'auto' }}
renderItem={(friend) => (
<List.Item
style={{
padding: '6px 12px',
cursor: onFriendClick ? 'pointer' : 'default',
borderBottom: '1px solid rgba(255,255,255,0.04)',
}}
onClick={() => onFriendClick?.(friend)}
>
<div>
<Tooltip title={friend.email}>
<Text style={{ color: '#fff', fontSize: 12, fontWeight: 500 }}>
{friend.name || friend.email}
</Text>
</Tooltip>
<br />
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>
{friend.cutName} · started {dayjs(friend.startedAt).fromNow()}
</Text>
</div>
</List.Item>
)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { Card, Avatar, Typography, Tag, Badge } from 'antd';
import { TeamOutlined, ScheduleOutlined, SendOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import type { SocialGroupSummary } from '@/types/social';
import dayjs from 'dayjs';
const { Text } = Typography;
const TYPE_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
SHIFT_TEAM: { color: 'green', label: 'Shift Team', icon: <ScheduleOutlined /> },
CAMPAIGN_TEAM: { color: 'blue', label: 'Campaign Team', icon: <SendOutlined /> },
CUSTOM: { color: 'purple', label: 'Group', icon: <TeamOutlined /> },
};
interface Props {
group: SocialGroupSummary;
}
export default function GroupCard({ group }: Props) {
const config = TYPE_CONFIG[group.type] ?? TYPE_CONFIG['CUSTOM']!;
return (
<Link to={`/volunteer/groups/${group.id}`} style={{ textDecoration: 'none' }}>
<Card
hoverable
size="small"
style={{ marginBottom: 8 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Badge dot={group.hasActiveCall} status="processing" offset={[-4, 4]}>
<Avatar
icon={config.icon}
style={{ backgroundColor: `var(--ant-color-${config.color})`, flexShrink: 0 }}
/>
</Badge>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong ellipsis style={{ display: 'block' }}>{group.name}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color={config.color} style={{ margin: 0, fontSize: 11 }}>{config.label}</Tag>
{group.hasActiveCall && (
<Tag color="red" icon={<VideoCameraOutlined />} style={{ margin: 0, fontSize: 11 }}>Live</Tag>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{group.memberCount} member{group.memberCount !== 1 ? 's' : ''}
</Text>
</div>
</div>
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>
{dayjs(group.joinedAt).format('MMM D')}
</Text>
</div>
</Card>
</Link>
);
}

View File

@ -0,0 +1,55 @@
import { useState } from 'react';
import { Button, App } from 'antd';
import { MessageOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useSettingsStore } from '@/stores/settings.store';
import { useChatWidgetStore } from '@/stores/chat-widget.store';
interface Props {
friendId: string;
isFriend: boolean;
size?: 'small' | 'middle' | 'large';
}
/**
* DM button that opens a Rocket.Chat direct message with a friend.
* Only visible when: (1) RC enabled/online, (2) users are friends, (3) social enabled.
*/
export default function MessageButton({ friendId, isFriend, size = 'middle' }: Props) {
const { message } = App.useApp();
const { settings } = useSettingsStore();
const rcEnabled = useChatWidgetStore((s) => s.rcEnabled);
const rcOnline = useChatWidgetStore((s) => s.rcOnline);
const openChannel = useChatWidgetStore((s) => s.openChannel);
const [loading, setLoading] = useState(false);
// Hide if RC not enabled/online or not friends or social not enabled
if (!isFriend || !settings?.enableSocial) return null;
if (!rcEnabled || !rcOnline) return null;
const handleClick = async () => {
setLoading(true);
try {
const { data } = await api.post(`/social/profile/${friendId}/dm`);
// Open chat panel for the DM room
openChannel(data.roomId);
message.success('Opening direct message...');
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to open direct message';
message.error(msg);
} finally {
setLoading(false);
}
};
return (
<Button
icon={<MessageOutlined />}
onClick={handleClick}
loading={loading}
size={size}
>
Message
</Button>
);
}

View File

@ -0,0 +1,138 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Button, Dropdown, List, Typography, Empty, Space, theme } from 'antd';
import { BellOutlined, CheckOutlined } from '@ant-design/icons';
import { useSocialStore } from '@/stores/social.store';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const TYPE_ICONS: Record<string, string> = {
friend_request: '\uD83E\uDD1D',
friend_accepted: '\uD83C\uDF89',
poke: '\uD83D\uDC4B',
comment: '\uD83D\uDCAC',
upload_approved: '\u2705',
upload_rejected: '\u274C',
achievement: '\uD83C\uDFC6',
system: '\u2139\uFE0F',
};
export default function NotificationBell() {
const navigate = useNavigate();
const { token } = theme.useToken();
const { unreadNotificationCount, notifications, fetchUnreadCount, fetchNotifications, markNotificationRead, markAllNotificationsRead } = useSocialStore();
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
// Poll for unread count — 2 min fallback (SSE handles real-time delivery)
useEffect(() => {
fetchUnreadCount();
pollRef.current = setInterval(fetchUnreadCount, 120_000);
return () => clearInterval(pollRef.current);
}, [fetchUnreadCount]);
const handleOpenChange = (open: boolean) => {
if (open) {
fetchNotifications(1);
}
};
const items = notifications.slice(0, 10);
const dropdownContent = (
<div style={{
background: token.colorBgElevated,
borderRadius: 8,
border: `1px solid ${token.colorBorder}`,
width: 340,
maxHeight: 420,
overflow: 'auto',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderBottom: `1px solid ${token.colorBorder}`,
}}>
<Typography.Text strong>Notifications</Typography.Text>
{unreadNotificationCount > 0 && (
<Button
type="link"
size="small"
icon={<CheckOutlined />}
onClick={(e) => { e.stopPropagation(); markAllNotificationsRead(); }}
>
Mark all read
</Button>
)}
</div>
{items.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No notifications" style={{ padding: '20px 0' }} />
) : (
<List
dataSource={items}
renderItem={(n) => (
<List.Item
style={{
padding: '8px 12px',
cursor: 'pointer',
background: n.isRead ? undefined : 'rgba(52, 152, 219, 0.08)',
}}
onClick={() => {
if (!n.isRead) markNotificationRead(n.id);
}}
>
<Space>
<span style={{ fontSize: 18 }}>{TYPE_ICONS[n.type] || '\uD83D\uDD14'}</span>
<div>
<Typography.Text strong={!n.isRead} style={{ fontSize: 13, display: 'block' }}>
{n.title}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{n.message}
</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(n.createdAt).fromNow()}
</Typography.Text>
</div>
</Space>
</List.Item>
)}
/>
)}
<div
style={{
textAlign: 'center',
padding: '8px',
borderTop: `1px solid ${token.colorBorder}`,
cursor: 'pointer',
}}
onClick={() => navigate('/volunteer/notifications')}
>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>See all notifications</Typography.Text>
</div>
</div>
);
return (
<Dropdown
dropdownRender={() => dropdownContent}
trigger={['click']}
placement="bottomRight"
onOpenChange={handleOpenChange}
>
<Badge count={unreadNotificationCount} size="small" offset={[-2, 2]}>
<Button
type="text"
size="small"
icon={<BellOutlined style={{ color: '#fff', fontSize: 16 }} />}
/>
</Badge>
</Dropdown>
);
}

View File

@ -0,0 +1,29 @@
import { useSocialStore } from '@/stores/social.store';
interface OnlineIndicatorProps {
userId: string;
size?: number;
style?: React.CSSProperties;
}
/** Green dot showing online status for a user (based on SSE presence data) */
export default function OnlineIndicator({ userId, size = 10, style }: OnlineIndicatorProps) {
const isOnline = useSocialStore((s) => s.onlineFriends.some((f) => f.userId === userId));
if (!isOnline) return null;
return (
<span
style={{
display: 'inline-block',
width: size,
height: size,
borderRadius: '50%',
backgroundColor: '#52c41a',
border: '2px solid #1b2838',
...style,
}}
title="Online"
/>
);
}

View File

@ -0,0 +1,73 @@
import { useState, useEffect } from 'react';
import { Button, message, Tooltip } from 'antd';
import { ThunderboltOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
interface PokeButtonProps {
userId: string;
isFriend: boolean;
size?: 'small' | 'middle' | 'large';
}
export default function PokeButton({ userId, isFriend, size = 'small' }: PokeButtonProps) {
const [loading, setLoading] = useState(false);
const [cooldownSeconds, setCooldownSeconds] = useState<number | null>(null);
useEffect(() => {
if (isFriend) {
checkCooldown();
}
}, [userId, isFriend]);
// Tick down cooldown every minute
useEffect(() => {
if (cooldownSeconds === null || cooldownSeconds <= 0) return;
const timer = setInterval(() => {
setCooldownSeconds((prev) => {
if (prev === null || prev <= 60) return null;
return prev - 60;
});
}, 60_000);
return () => clearInterval(timer);
}, [cooldownSeconds]);
const checkCooldown = async () => {
try {
const { data } = await api.get(`/social/pokes/cooldown/${userId}`);
setCooldownSeconds(data.onCooldown ? data.ttlSeconds : null);
} catch {}
};
const handlePoke = async () => {
setLoading(true);
try {
await api.post('/social/pokes', { userId });
message.success('Poke sent!');
setCooldownSeconds(24 * 60 * 60);
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to send poke');
} finally {
setLoading(false);
}
};
if (!isFriend) return null;
const onCooldown = cooldownSeconds !== null && cooldownSeconds > 0;
const hoursLeft = onCooldown ? Math.ceil(cooldownSeconds! / 3600) : 0;
return (
<Tooltip title={onCooldown ? `Can poke again in ~${hoursLeft}h` : 'Send a poke!'}>
<Button
type="default"
size={size}
icon={<ThunderboltOutlined />}
loading={loading}
disabled={onCooldown}
onClick={handlePoke}
>
{onCooldown ? `${hoursLeft}h` : 'Poke'}
</Button>
</Tooltip>
);
}

View File

@ -0,0 +1,169 @@
import { useState, useEffect, useCallback } from 'react';
import { Modal, Input, Select, Typography, message, Space } from 'antd';
import { VideoCameraOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { mediaApi } from '@/lib/media-api';
import type { FriendListItem } from '@/types/social';
interface RecommendVideoModalProps {
open: boolean;
onClose: () => void;
/** Pre-selected video ID (when recommending from media viewer) */
preselectedVideoId?: number;
/** Pre-selected friend ID (when recommending from profile) */
preselectedFriendId?: string;
}
interface VideoOption {
id: number;
title: string | null;
filename: string;
}
export default function RecommendVideoModal({
open,
onClose,
preselectedVideoId,
preselectedFriendId,
}: RecommendVideoModalProps) {
const [friends, setFriends] = useState<FriendListItem[]>([]);
const [videos, setVideos] = useState<VideoOption[]>([]);
const [selectedFriendId, setSelectedFriendId] = useState<string | undefined>(preselectedFriendId);
const [selectedVideoId, setSelectedVideoId] = useState<number | undefined>(preselectedVideoId);
const [messageText, setMessageText] = useState('');
const [sending, setSending] = useState(false);
const [loadingFriends, setLoadingFriends] = useState(false);
const [loadingVideos, setLoadingVideos] = useState(false);
useEffect(() => {
if (open) {
loadFriends();
loadVideos('');
setSelectedFriendId(preselectedFriendId);
setSelectedVideoId(preselectedVideoId);
setMessageText('');
}
}, [open, preselectedFriendId, preselectedVideoId]);
const loadFriends = async () => {
setLoadingFriends(true);
try {
const { data } = await api.get('/social/friends', { params: { limit: 100 } });
setFriends(data.friends || []);
} catch {} finally {
setLoadingFriends(false);
}
};
const loadVideos = useCallback(async (search: string) => {
setLoadingVideos(true);
try {
const { data } = await mediaApi.get('/videos', {
params: { search: search || undefined, limit: 50, shared: true },
});
setVideos(
(data.videos || data.data || []).map((v: any) => ({
id: v.id,
title: v.title,
filename: v.filename,
})),
);
} catch {
// Fallback: if media API not available, clear list
setVideos([]);
} finally {
setLoadingVideos(false);
}
}, []);
const handleSend = async () => {
if (!selectedFriendId || !selectedVideoId) {
message.warning('Please select a friend and a video');
return;
}
setSending(true);
try {
await api.post('/social/recommendations', {
userId: selectedFriendId,
mediaId: selectedVideoId,
message: messageText.trim() || undefined,
});
message.success('Recommendation sent!');
onClose();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to send recommendation');
} finally {
setSending(false);
}
};
return (
<Modal
title="Recommend a Video"
open={open}
onOk={handleSend}
onCancel={onClose}
okText="Send"
confirmLoading={sending}
okButtonProps={{ disabled: !selectedFriendId || !selectedVideoId }}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 4 }}>
Send to
</Typography.Text>
<Select
showSearch
placeholder="Select a friend"
value={selectedFriendId}
onChange={setSelectedFriendId}
loading={loadingFriends}
style={{ width: '100%' }}
optionFilterProp="label"
options={friends.map((f) => ({
value: f.user.id,
label: f.user.name || f.user.email,
}))}
/>
</div>
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 4 }}>
Video
</Typography.Text>
<Select
showSearch
placeholder="Search for a video..."
value={selectedVideoId}
onChange={setSelectedVideoId}
loading={loadingVideos}
style={{ width: '100%' }}
filterOption={false}
onSearch={loadVideos}
suffixIcon={<VideoCameraOutlined />}
options={videos.map((v) => ({
value: v.id,
label: v.title || v.filename,
}))}
notFoundContent={loadingVideos ? 'Searching...' : 'No videos found'}
/>
</div>
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 4 }}>
Message (optional)
</Typography.Text>
<Input.TextArea
placeholder="Why you're recommending this..."
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
maxLength={500}
rows={3}
showCount
/>
</div>
</Space>
</Modal>
);
}

View File

@ -0,0 +1,283 @@
import { useMemo, useCallback } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Node,
type Edge,
} from '@xyflow/react';
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
type SimulationNodeDatum,
type SimulationLinkDatum,
} from 'd3-force';
import '@xyflow/react/dist/style.css';
import { Spin, Empty } from 'antd';
import { SocialUserNode, type SocialUserNodeData } from './SocialUserNode';
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: '#f5222d',
INFLUENCE_ADMIN: '#1890ff',
MAP_ADMIN: '#52c41a',
USER: '#722ed1',
TEMP: '#faad14',
};
const nodeTypes = { socialUser: SocialUserNode };
export interface GraphNodeData {
id: string;
name: string | null;
email: string;
role: string;
isOnline: boolean;
friendCount: number;
achievementCount: number;
groups: string[];
}
export interface GraphEdgeData {
source: string;
target: string;
status: string;
acceptedAt: string | null;
}
export interface GraphData {
nodes: GraphNodeData[];
edges: GraphEdgeData[];
}
type LayoutMode = 'force' | 'radial';
interface SocialNetworkGraphProps {
graphData: GraphData | null;
loading: boolean;
layoutMode: LayoutMode;
centerUserId?: string;
onNodeClick: (userId: string) => void;
}
/** Force-directed layout using d3-force (synchronous tick) */
function computeForceLayout(
nodes: GraphNodeData[],
edges: GraphEdgeData[],
): Map<string, { x: number; y: number }> {
interface SimNode extends SimulationNodeDatum {
id: string;
}
const simNodes: SimNode[] = nodes.map((n) => ({ id: n.id }));
const nodeIndex = new Map(simNodes.map((n, i) => [n.id, i]));
const simLinks: SimulationLinkDatum<SimNode>[] = edges
.filter((e) => e.status === 'accepted' && nodeIndex.has(e.source) && nodeIndex.has(e.target))
.map((e) => ({ source: nodeIndex.get(e.source)!, target: nodeIndex.get(e.target)! }));
const simulation = forceSimulation<SimNode>(simNodes)
.force('link', forceLink<SimNode, SimulationLinkDatum<SimNode>>(simLinks).distance(120))
.force('charge', forceManyBody().strength(-300))
.force('center', forceCenter(0, 0))
.force('collide', forceCollide(60))
.stop();
// Run 300 ticks synchronously
for (let i = 0; i < 300; i++) simulation.tick();
const positions = new Map<string, { x: number; y: number }>();
for (const node of simNodes) {
positions.set(node.id, { x: node.x ?? 0, y: node.y ?? 0 });
}
return positions;
}
/** Radial layout centered on a user using BFS */
function computeRadialLayout(
nodes: GraphNodeData[],
edges: GraphEdgeData[],
centerId: string,
): Map<string, { x: number; y: number }> {
// Build adjacency (accepted edges only)
const adjacency = new Map<string, string[]>();
for (const edge of edges) {
if (edge.status !== 'accepted') continue;
adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target]);
adjacency.set(edge.target, [...(adjacency.get(edge.target) ?? []), edge.source]);
}
// BFS from center
const distances = new Map<string, number>();
const queue: string[] = [centerId];
distances.set(centerId, 0);
while (queue.length > 0) {
const current = queue.shift()!;
const dist = distances.get(current)!;
const neighbors = adjacency.get(current) ?? [];
for (const n of neighbors) {
if (!distances.has(n)) {
distances.set(n, dist + 1);
queue.push(n);
}
}
}
// Group nodes by ring distance
const rings = new Map<number, string[]>();
const unconnected: string[] = [];
for (const node of nodes) {
const dist = distances.get(node.id);
if (dist !== undefined) {
const ring = rings.get(dist) ?? [];
ring.push(node.id);
rings.set(dist, ring);
} else {
unconnected.push(node.id);
}
}
const positions = new Map<string, { x: number; y: number }>();
const ringRadius = 200;
// Place center
positions.set(centerId, { x: 0, y: 0 });
// Place each ring
const maxRing = Math.max(...Array.from(rings.keys()));
for (let r = 1; r <= maxRing; r++) {
const nodesInRing = rings.get(r) ?? [];
const radius = r * ringRadius;
nodesInRing.forEach((nodeId, i) => {
const angle = (2 * Math.PI * i) / nodesInRing.length - Math.PI / 2;
positions.set(nodeId, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
});
}
// Place unconnected nodes in outer ring
if (unconnected.length > 0) {
const outerRadius = (maxRing + 1) * ringRadius;
unconnected.forEach((nodeId, i) => {
const angle = (2 * Math.PI * i) / unconnected.length - Math.PI / 2;
positions.set(nodeId, {
x: Math.cos(angle) * outerRadius,
y: Math.sin(angle) * outerRadius,
});
});
}
return positions;
}
export default function SocialNetworkGraph({
graphData,
loading,
layoutMode,
centerUserId,
onNodeClick,
}: SocialNetworkGraphProps) {
// Compute layout positions
const { flowNodes, flowEdges } = useMemo(() => {
if (!graphData || graphData.nodes.length === 0) return { flowNodes: [], flowEdges: [] };
let positions: Map<string, { x: number; y: number }>;
if (layoutMode === 'radial' && centerUserId) {
positions = computeRadialLayout(graphData.nodes, graphData.edges, centerUserId);
} else {
positions = computeForceLayout(graphData.nodes, graphData.edges);
}
const nodes: Node[] = graphData.nodes.map((n) => {
const pos = positions.get(n.id) ?? { x: 0, y: 0 };
return {
id: n.id,
type: 'socialUser',
position: pos,
data: {
name: n.name,
email: n.email,
role: n.role,
isOnline: n.isOnline,
friendCount: n.friendCount,
achievementCount: n.achievementCount,
groups: n.groups,
} satisfies SocialUserNodeData,
};
});
const nodeSet = new Set(graphData.nodes.map((n) => n.id));
const edges: Edge[] = graphData.edges
.filter((e) => nodeSet.has(e.source) && nodeSet.has(e.target))
.map((e, i) => ({
id: `edge-${i}`,
source: e.source,
target: e.target,
style: {
stroke: e.status === 'accepted' ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.1)',
strokeWidth: e.status === 'accepted' ? 1.5 : 1,
strokeDasharray: e.status === 'pending' ? '5,5' : undefined,
},
}));
return { flowNodes: nodes, flowEdges: edges };
}, [graphData, layoutMode, centerUserId]);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
onNodeClick(node.id);
},
[onNodeClick],
);
if (loading && !graphData) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
if (!graphData || graphData.nodes.length === 0) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Empty description="No users found." image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
return (
<div style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
fitView
minZoom={0.1}
maxZoom={3}
proOptions={{ hideAttribution: true }}
>
<Background color="rgba(255,255,255,0.03)" gap={30} />
<Controls
showInteractive={false}
style={{ background: 'rgba(30,20,50,0.9)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}
/>
<MiniMap
nodeColor={(node) => {
const role = (node.data as SocialUserNodeData)?.role;
return ROLE_COLORS[role] || '#722ed1';
}}
maskColor="rgba(0,0,0,0.6)"
style={{ background: 'rgba(20,15,40,0.9)', borderRadius: 6 }}
/>
</ReactFlow>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { memo } from 'react';
import { Avatar } from 'antd';
import type { NodeProps } from '@xyflow/react';
const ROLE_BG: Record<string, string> = {
SUPER_ADMIN: '#f5222d',
INFLUENCE_ADMIN: '#1890ff',
MAP_ADMIN: '#52c41a',
USER: '#722ed1',
TEMP: '#faad14',
};
function getInitials(name: string | null, email: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return ((parts[0]?.[0] || '') + (parts[parts.length - 1]?.[0] || '')).toUpperCase();
return (name[0] || '?').toUpperCase();
}
return (email[0] || '?').toUpperCase();
}
export interface SocialUserNodeData {
name: string | null;
email: string;
role: string;
isOnline: boolean;
friendCount: number;
achievementCount: number;
groups: string[];
[key: string]: unknown;
}
function SocialUserNodeComponent({ data }: NodeProps) {
const d = data as SocialUserNodeData;
// Size avatar by friend count: min 28, max 56
const avatarSize = Math.min(56, Math.max(28, 28 + (d.friendCount ?? 0) * 2));
const bgColor = ROLE_BG[d.role] || '#722ed1';
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
padding: 4,
}}
>
<div
style={{
borderRadius: '50%',
border: d.isOnline ? '3px solid #52c41a' : '2px solid rgba(255,255,255,0.2)',
padding: 2,
}}
>
<Avatar
size={avatarSize}
style={{
backgroundColor: bgColor,
fontWeight: 600,
fontSize: Math.max(11, avatarSize * 0.35),
}}
>
{getInitials(d.name, d.email)}
</Avatar>
</div>
<div
style={{
fontSize: 10,
color: '#fff',
maxWidth: 80,
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '14px',
}}
>
{d.name || d.email.split('@')[0]}
</div>
</div>
);
}
export const SocialUserNode = memo(SocialUserNodeComponent);

View File

@ -0,0 +1,64 @@
import { Avatar } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import OnlineIndicator from './OnlineIndicator';
/** Generate consistent color from string hash */
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 65%, 45%)`;
}
function getInitials(name: string | null | undefined, email?: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase();
return parts[0]!.slice(0, 2).toUpperCase();
}
if (email) return email[0]!.toUpperCase();
return '?';
}
interface UserAvatarProps {
userId: string;
name?: string | null;
email?: string;
size?: number;
style?: React.CSSProperties;
showOnline?: boolean;
}
export default function UserAvatar({ userId, name, email, size = 32, style, showOnline = false }: UserAvatarProps) {
const initials = getInitials(name, email);
const bgColor = stringToColor(userId);
const avatar = (
<Avatar
size={size}
style={{ backgroundColor: bgColor, fontWeight: 600, fontSize: size * 0.4, ...style }}
icon={!name && !email ? <UserOutlined /> : undefined}
>
{(name || email) && initials}
</Avatar>
);
if (!showOnline) return avatar;
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
{avatar}
<OnlineIndicator
userId={userId}
size={Math.max(8, size * 0.3)}
style={{
position: 'absolute',
bottom: 0,
right: 0,
}}
/>
</div>
);
}

View File

@ -0,0 +1,14 @@
import { useEffect } from 'react';
import { useSettingsStore } from '@/stores/settings.store';
/** Update the browser tab title. Appends org name as suffix. */
export function useDocumentTitle(title: string | null | undefined) {
const { settings } = useSettingsStore();
const orgName = settings?.organizationName || 'Changemaker Lite';
useEffect(() => {
if (title) {
document.title = `${title} | ${orgName}`;
}
}, [title, orgName]);
}

View File

@ -0,0 +1,27 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
import type { GalleryAd } from '@/types/gallery-ads';
/**
* Fetches active ads targeted to a specific page placement.
* Returns empty array if gallery ads are disabled or on error.
*/
export function usePageAds(placement: string): GalleryAd[] {
const [ads, setAds] = useState<GalleryAd[]>([]);
const { settings } = useSettingsStore();
useEffect(() => {
if (!settings?.enableGalleryAds) {
setAds([]);
return;
}
axios
.get<GalleryAd[]>('/api/gallery-ads', { params: { placement } })
.then(({ data }) => setAds(data))
.catch(() => setAds([]));
}, [placement, settings?.enableGalleryAds]);
return ads;
}

View File

@ -0,0 +1,21 @@
import { useLocalStorage } from './useLocalStorage';
const STORAGE_KEY = 'user_postal_code';
/**
* Persist and normalize postal code across public pages.
* Normalizes: uppercase, strip spaces.
*/
export function usePostalCode() {
const [postalCode, setRaw] = useLocalStorage(STORAGE_KEY, '');
const setPostalCode = (value: string) => {
setRaw(value.replace(/\s/g, '').toUpperCase());
};
return {
postalCode,
setPostalCode,
hasPostalCode: postalCode.length > 0,
};
}

126
admin/src/hooks/useSSE.ts Normal file
View File

@ -0,0 +1,126 @@
import { useEffect, useRef, useCallback } from 'react';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSocialStore } from '@/stores/social.store';
/**
* SSE hook establishes EventSource connection for real-time social events.
* Auto-reconnects with exponential backoff. Passes JWT via query param
* since EventSource doesn't support custom headers.
*/
export function useSSE() {
const esRef = useRef<EventSource | null>(null);
const retryRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const retryCount = useRef(0);
const maxRetryDelay = 30_000; // 30s max
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const enableSocial = useSettingsStore((s) => s.settings?.enableSocial);
const handleNotification = useCallback((event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
// Increment unread count in store
useSocialStore.setState((state) => ({
unreadNotificationCount: state.unreadNotificationCount + 1,
}));
// If the notification dropdown was recently opened, prepend to list
const currentNotifications = useSocialStore.getState().notifications;
if (currentNotifications.length > 0) {
useSocialStore.setState((state) => ({
notifications: [{ ...data, isRead: false }, ...state.notifications].slice(0, 20),
}));
}
} catch {}
}, []);
const handlePresence = useCallback((event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
useSocialStore.setState((state) => {
if (data.isOnline) {
// Add to online friends (avoid duplicates)
const exists = state.onlineFriends.some((f) => f.userId === data.userId);
if (exists) return state;
return {
onlineFriends: [...state.onlineFriends, {
userId: data.userId,
name: data.name,
email: data.email,
}],
};
} else {
// Remove from online friends
return {
onlineFriends: state.onlineFriends.filter((f) => f.userId !== data.userId),
};
}
});
} catch {}
}, []);
const handleFriendRequest = useCallback((_event: MessageEvent) => {
// Refresh pending received when we get a friend request SSE
useSocialStore.getState().fetchPendingReceived();
}, []);
const handleFriendAccepted = useCallback((_event: MessageEvent) => {
// Refresh friends list when a request is accepted
useSocialStore.getState().fetchFriends();
}, []);
const connect = useCallback(() => {
if (!accessToken || !enableSocial) return;
// Close existing connection if any
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
const url = `/api/social/sse?token=${encodeURIComponent(accessToken)}`;
const es = new EventSource(url);
esRef.current = es;
es.addEventListener('connected', () => {
retryCount.current = 0; // Reset backoff on successful connect
});
es.addEventListener('notification', handleNotification);
es.addEventListener('presence', handlePresence);
es.addEventListener('friend_request', handleFriendRequest);
es.addEventListener('friend_accepted', handleFriendAccepted);
es.addEventListener('poke', handleNotification); // Pokes also increment notification count
es.onerror = () => {
es.close();
esRef.current = null;
// Exponential backoff: 1s, 2s, 4s, 8s, ... up to 30s
const delay = Math.min(1000 * 2 ** retryCount.current, maxRetryDelay);
retryCount.current++;
retryRef.current = setTimeout(connect, delay);
};
}, [accessToken, enableSocial, handleNotification, handlePresence, handleFriendRequest, handleFriendAccepted]);
useEffect(() => {
if (!isAuthenticated || !enableSocial) return;
connect();
// Fetch initial online friends list
useSocialStore.getState().fetchOnlineFriends();
return () => {
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
if (retryRef.current) {
clearTimeout(retryRef.current);
}
};
}, [isAuthenticated, enableSocial, connect]);
}

View File

@ -3,7 +3,7 @@
*
* All URLs route through nginx which strips X-Frame-Options headers:
* - Real domain (app.cmlite.org) //db.cmlite.org (subdomain via nginx port 80)
* - Localhost dev //localhost:EMBED_PORT (dedicated nginx proxy ports)
* - Localhost/IP dev //hostname:EMBED_PORT (dedicated nginx proxy ports)
*/
export function buildServiceUrl(
subdomain: string,
@ -11,10 +11,45 @@ export function buildServiceUrl(
embedPort: number,
): string {
const hostname = window.location.hostname;
// Real domain (contains a dot) — use subdomain via nginx
if (hostname.includes('.')) {
// Use subdomain routing only when accessing via the configured domain
// (avoids IP addresses like 0.0.0.0/127.0.0.1 triggering subdomain logic)
if (hostname.endsWith(domain)) {
return `//${subdomain}.${domain}`;
}
// Localhost dev — use dedicated nginx embed proxy port
// Localhost or IP access — use dedicated nginx embed proxy port
return `//${hostname}:${embedPort}`;
}
/**
* Build the "Home" URL pointing to the MkDocs static site.
*
* - Real domain (app.cmlite.org) //cmlite.org (root domain serves MkDocs)
* - Localhost/IP dev //hostname:MKDOCS_SITE_SERVER_PORT (default 4004)
*
* Uses VITE_DOMAIN env var (set by docker-compose) to detect whether the
* browser is on a real domain. This avoids needing an API call in every layout.
*/
export function buildHomeUrl(): string {
const hostname = window.location.hostname;
const domain = import.meta.env.VITE_DOMAIN as string | undefined;
if (domain && hostname.endsWith(domain)) {
return `//${domain}`;
}
// Localhost or IP access — use the MkDocs site server port
const sitePort = import.meta.env.VITE_MKDOCS_SITE_PORT || '4004';
return `//${hostname}:${sitePort}`;
}
/**
* Resolve special `$token` nav paths to environment-aware URLs.
*
* - `$landing` MkDocs root (buildHomeUrl())
* - `$docs` MkDocs /docs/ section
* - anything else passthrough unchanged
*/
export function resolveNavUrl(path: string): string {
if (path === '$landing') return buildHomeUrl();
if (path === '$docs') return buildHomeUrl() + '/docs/';
return path;
}

View File

@ -6,7 +6,7 @@ import {
Select,
Tag,
Space,
Modal,
Drawer,
Form,
Switch,
Popconfirm,
@ -492,21 +492,12 @@ export default function CampaignsPage() {
const navigate = useNavigate();
const headerActions = useMemo(() => (
<Space>
<Button
icon={<MailOutlined />}
onClick={() => navigate('/app/email-queue')}
>
Email Queue
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
Create Campaign
</Button>
</Space>
<Button
icon={<MailOutlined />}
onClick={() => navigate('/app/email-queue')}
>
Email Queue
</Button>
), [navigate]);
useEffect(() => {
@ -514,83 +505,115 @@ export default function CampaignsPage() {
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
const drawerOpen = createModalOpen || editModalOpen;
const drawerWidth = isMobile ? 0 : 640;
return (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search by title or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Status"
options={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search by title or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Status"
options={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
<Col flex="auto" style={{ textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
Create Campaign
</Button>
</Col>
</Row>
<Table<Campaign>
columns={columns}
dataSource={campaigns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} campaigns`,
}}
onChange={handleTableChange}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
/>
<Table<Campaign>
columns={columns}
dataSource={campaigns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} campaigns`,
}}
onChange={handleTableChange}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
/>
</div>
{/* Create Modal */}
<Modal
{/* Create Drawer */}
<Drawer
title="Create Campaign"
open={createModalOpen}
destroyOnHidden
width={isMobile ? '95vw' : 640}
onCancel={() => {
mask={false}
width={isMobile ? '100%' : 640}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setCreateModalOpen(false);
createForm.resetFields();
setCreateSelectedVideo(null);
}}
onOk={() => createForm.submit()}
okText="Create"
extra={
<Space>
<Button onClick={() => { setCreateModalOpen(false); createForm.resetFields(); setCreateSelectedVideo(null); }}>
Cancel
</Button>
<Button type="primary" onClick={() => createForm.submit()}>
Create
</Button>
</Space>
}
>
<Form form={createForm} onFinish={handleCreate} layout="vertical">
{campaignFormFields}
</Form>
</Modal>
</Drawer>
{/* Edit Modal */}
<Modal
{/* Edit Drawer */}
<Drawer
title="Edit Campaign"
open={editModalOpen}
destroyOnHidden
width={isMobile ? '95vw' : 640}
onCancel={() => {
mask={false}
width={isMobile ? '100%' : 640}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setEditModalOpen(false);
setEditingCampaign(null);
editForm.resetFields();
setEditSelectedVideo(null);
}}
onOk={() => editForm.submit()}
okText="Save"
extra={
<Space>
<Button onClick={() => { setEditModalOpen(false); setEditingCampaign(null); editForm.resetFields(); setEditSelectedVideo(null); }}>
Cancel
</Button>
<Button type="primary" onClick={() => editForm.submit()}>
Save
</Button>
</Space>
}
>
<Form form={editForm} onFinish={handleEdit} layout="vertical">
{campaignFormFields}
</Form>
</Modal>
</Drawer>
{/* Emails Drawer */}
<CampaignEmailsDrawer

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Table,
Button,
@ -6,7 +6,6 @@ import {
Select,
Tag,
Space,
Modal,
Form,
Switch,
Popconfirm,
@ -67,13 +66,13 @@ export default function CutsPage() {
const [activeTab, setActiveTab] = useState('table');
// Create modal
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
const [pendingGeojson, setPendingGeojson] = useState<string | null>(null);
const [pendingBounds, setPendingBounds] = useState<string | null>(null);
const [createForm] = Form.useForm();
// Import modal
const [importModalOpen, setImportModalOpen] = useState(false);
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
const [importing, setImporting] = useState(false);
// Edit drawer
@ -127,7 +126,7 @@ export default function CutsPage() {
bounds: pendingBounds,
});
message.success('Cut created');
setCreateModalOpen(false);
setCreateDrawerOpen(false);
createForm.resetFields();
setPendingGeojson(null);
setPendingBounds(null);
@ -198,7 +197,7 @@ export default function CutsPage() {
if (data.failed > 0) {
message.warning(`${data.failed} features failed to import`);
}
setImportModalOpen(false);
setImportDrawerOpen(false);
fetchCuts({ page: 1 });
} catch {
message.error('Import failed');
@ -239,7 +238,7 @@ export default function CutsPage() {
setPendingBounds(bounds);
createForm.resetFields();
createForm.setFieldsValue({ color: '#3388ff', opacity: 0.3 });
setCreateModalOpen(true);
setCreateDrawerOpen(true);
};
const columns: ColumnsType<Cut> = [
@ -402,120 +401,125 @@ export default function CutsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space>
<Segmented
value={activeTab}
onChange={(val) => setActiveTab(val as string)}
options={[
{ value: 'table', icon: <TableOutlined />, label: 'Table' },
{ value: 'map', icon: <EnvironmentOutlined />, label: 'Map' },
]}
size="middle"
/>
<Button icon={<ExportOutlined />} onClick={handleExportAll}>
Export GeoJSON
</Button>
<Button icon={<ImportOutlined />} onClick={() => setImportModalOpen(true)}>
Import GeoJSON
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setActiveTab('map')}
>
Draw New Cut
</Button>
</Space>
), [activeTab]);
useEffect(() => {
setPageHeader({
title: 'Cuts',
actions: headerActions,
fullBleed: activeTab === 'map'
});
return () => setPageHeader(null);
}, [setPageHeader, headerActions, activeTab]);
}, [setPageHeader, activeTab]);
const anyDrawerOpen = createDrawerOpen || importDrawerOpen || editDrawerOpen;
const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 500 : importDrawerOpen ? 500 : editDrawerOpen ? 480 : 0);
return (
<>
{activeTab === 'table' ? (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search name or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={6} md={4}>
<Select
placeholder="Category"
options={categoryOptions}
value={categoryFilter}
onChange={setCategoryFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Table<Cut>
columns={columns}
dataSource={cuts}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content' }}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} cuts`,
}}
onChange={handleTableChange}
onRow={(record) => ({
onClick: () => openEdit(record),
style: { cursor: 'pointer' },
})}
<div style={{ marginRight: anyDrawerOpen ? activeDrawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
{/* Action bar */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Segmented
value={activeTab}
onChange={(val) => setActiveTab(val as string)}
options={[
{ value: 'table', icon: <TableOutlined />, label: 'Table' },
{ value: 'map', icon: <EnvironmentOutlined />, label: 'Map' },
]}
size="middle"
/>
</>
) : isMobile ? (
<Result
status="warning"
title="Desktop Required"
subTitle="Cut drawing requires a mouse for precise polygon editing. Please use a desktop browser for the map editor. The table view above is fully usable on mobile."
extra={
<Button type="primary" onClick={() => setActiveTab('table')}>
Switch to Table
</Button>
}
/>
) : (
<CutEditorMap
cuts={cuts}
onFinishDraw={handleFinishDraw}
/>
)}
<Space>
<Button icon={<ExportOutlined />} onClick={handleExportAll}>Export GeoJSON</Button>
<Button icon={<ImportOutlined />} onClick={() => setImportDrawerOpen(true)}>Import GeoJSON</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setActiveTab('map')}>Draw New Cut</Button>
</Space>
</Row>
{/* Create Modal */}
<Modal
{activeTab === 'table' ? (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search name or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={6} md={4}>
<Select
placeholder="Category"
options={categoryOptions}
value={categoryFilter}
onChange={setCategoryFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Table<Cut>
columns={columns}
dataSource={cuts}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content' }}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} cuts`,
}}
onChange={handleTableChange}
onRow={(record) => ({
onClick: () => openEdit(record),
style: { cursor: 'pointer' },
})}
/>
</>
) : isMobile ? (
<Result
status="warning"
title="Desktop Required"
subTitle="Cut drawing requires a mouse for precise polygon editing. Please use a desktop browser for the map editor. The table view above is fully usable on mobile."
extra={
<Button type="primary" onClick={() => setActiveTab('table')}>
Switch to Table
</Button>
}
/>
) : (
<CutEditorMap
cuts={cuts}
onFinishDraw={handleFinishDraw}
/>
)}
</div>
{/* Create Drawer */}
<Drawer
title="Create Cut"
open={createModalOpen}
open={createDrawerOpen}
destroyOnHidden
width={isMobile ? '95vw' : 500}
onCancel={() => {
setCreateModalOpen(false);
mask={false}
width={isMobile ? '100%' : 500}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setCreateDrawerOpen(false);
createForm.resetFields();
setPendingGeojson(null);
setPendingBounds(null);
}}
onOk={() => createForm.submit()}
okText="Create"
extra={
<Space>
<Button onClick={() => { setCreateDrawerOpen(false); createForm.resetFields(); setPendingGeojson(null); setPendingBounds(null); }}>
Cancel
</Button>
<Button type="primary" onClick={() => createForm.submit()}>
Create
</Button>
</Space>
}
>
<Form
form={createForm}
@ -525,16 +529,22 @@ export default function CutsPage() {
>
{cutFormFields}
</Form>
</Modal>
</Drawer>
{/* Import GeoJSON Modal */}
<Modal
{/* Import GeoJSON Drawer */}
<Drawer
title="Import GeoJSON"
open={importModalOpen}
open={importDrawerOpen}
destroyOnHidden
width={isMobile ? '95vw' : 500}
onCancel={() => setImportModalOpen(false)}
footer={null}
mask={false}
width={isMobile ? '100%' : 500}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => setImportDrawerOpen(false)}
extra={
<Button onClick={() => setImportDrawerOpen(false)}>
Close
</Button>
}
>
<Typography.Paragraph type="secondary">
Upload a GeoJSON file containing Polygon or MultiPolygon features.
@ -553,22 +563,30 @@ export default function CutsPage() {
</p>
<p>{importing ? 'Importing...' : 'Click or drag a .geojson file here'}</p>
</Upload.Dragger>
</Modal>
</Drawer>
{/* Edit Drawer */}
<Drawer
title="Edit Cut"
open={editDrawerOpen}
destroyOnHidden
mask={false}
width={isMobile ? '100%' : 480}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setEditDrawerOpen(false);
setEditingCut(null);
editForm.resetFields();
}}
extra={
<Button type="primary" onClick={() => editForm.submit()}>
Save
</Button>
<Space>
<Button onClick={() => { setEditDrawerOpen(false); setEditingCut(null); editForm.resetFields(); }}>
Cancel
</Button>
<Button type="primary" onClick={() => editForm.submit()}>
Save
</Button>
</Space>
}
>
<Form form={editForm} onFinish={handleEdit} layout="vertical">

View File

@ -10,7 +10,7 @@ import {
EnvironmentOutlined,
MailOutlined,
VideoCameraOutlined,
CalendarOutlined,
ScheduleOutlined,
ReloadOutlined,
PlusOutlined,
UploadOutlined,
@ -35,6 +35,9 @@ import {
CloseCircleFilled,
MinusCircleFilled,
HomeOutlined,
LockOutlined,
MessageOutlined,
CalendarOutlined,
} from '@ant-design/icons';
import {
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
@ -62,6 +65,9 @@ import RecentSignupsCard from '@/components/dashboard/RecentSignupsCard';
import NewsletterStatsCard from '@/components/dashboard/NewsletterStatsCard';
import DonationSummaryCard from '@/components/dashboard/DonationSummaryCard';
import SystemAlertsCard from '@/components/dashboard/SystemAlertsCard';
import GiteaActivityCard from '@/components/dashboard/GiteaActivityCard';
import VaultwardenAdoptionCard from '@/components/dashboard/VaultwardenAdoptionCard';
import UpcomingMeetingsCard from '@/components/dashboard/UpcomingMeetingsCard';
import CutCampaignAnalyticsCard from '@/components/canvass/CutCampaignAnalyticsCard';
import { buildServiceUrl } from '@/lib/service-url';
import type {
@ -88,8 +94,7 @@ if (typeof document !== 'undefined' && !document.getElementById(PULSE_STYLE_ID))
.svc-badge-online .ant-badge-status-dot {
animation: dashboard-pulse 2s infinite;
}
.db-mi { min-width: 0; display: flex; flex-direction: column; }
.db-mi > * { flex: 1; }
.db-mi { break-inside: avoid; margin-bottom: 16px; }
`;
document.head.appendChild(style);
}
@ -250,13 +255,7 @@ export default function DashboardPage() {
const showChat = settings?.enableChat !== false;
const showNewsletter = settings?.enableNewsletter !== false;
const showPayments = settings?.enablePayments !== false;
// Grid span helper: 12-col on lg, 2-col on md, 1-col on xs
const gs = (lg: number, md?: number): React.CSSProperties | undefined => {
if (screens.lg) return { gridColumn: `span ${lg}` };
if (screens.md && md) return { gridColumn: `span ${md}` };
return undefined;
};
const showMeet = settings?.enableMeet !== false;
const geocodePct = summary && summary.locations.total > 0
? Math.round((summary.locations.geocoded / summary.locations.total) * 100) : 0;
@ -308,7 +307,7 @@ export default function DashboardPage() {
<div>
{/* === Welcome Banner === */}
<Card
style={{ marginBottom: 12, background: 'linear-gradient(135deg, #1890ff 0%, #722ed1 100%)', border: 'none' }}
style={{ marginBottom: 16, background: 'linear-gradient(135deg, #1890ff 0%, #722ed1 100%)', border: 'none' }}
styles={{ body: { padding: '10px 16px' } }}
>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
@ -423,7 +422,7 @@ export default function DashboardPage() {
{/* === Status Bar (weather + stats + pending actions + connectivity) === */}
{summary && (
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '10px 16px' } }}>
<Card size="small" style={{ marginBottom: 16 }} styles={{ body: { padding: '10px 16px' } }}>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
<Flex gap={0} wrap="wrap" align="center">
{weather && (
@ -439,7 +438,7 @@ export default function DashboardPage() {
{showMap && <QuickStat icon={<EnvironmentOutlined />} color="#722ed1" value={summary.locations.total.toLocaleString()} label={`${geocodePct}% geo`} onClick={() => navigate('/app/map')} />}
{showInfluence && <QuickStat icon={<MailOutlined />} color="#faad14" value={summary.emails.sent} label="sent" onClick={() => navigate('/app/email-queue')} />}
{showMedia && <QuickStat icon={<VideoCameraOutlined />} color="#13c2c2" value={summary.videos.published} label={`of ${summary.videos.total}`} onClick={() => navigate('/app/media/library')} />}
{showMap && <QuickStat icon={<CalendarOutlined />} color="#eb2f96" value={summary.shifts.upcoming} label={`${summary.shifts.open} open`} onClick={() => navigate('/app/map/shifts')} />}
{showMap && <QuickStat icon={<ScheduleOutlined />} color="#eb2f96" value={summary.shifts.upcoming} label={`${summary.shifts.open} open`} onClick={() => navigate('/app/map/shifts')} />}
{/* Pending action tags */}
{summary.responses.pending > 0 && (
<Tag color="orange" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/responses')}>
@ -478,7 +477,7 @@ export default function DashboardPage() {
{queue && (queue.waiting > 0 || queue.active > 0 || queue.failed > 0) && (
<Card
size="small"
style={{ marginBottom: 12, borderLeft: `4px solid ${queue.failed > 0 ? '#ff4d4f' : queue.paused ? '#faad14' : '#1890ff'}` }}
style={{ marginBottom: 16, borderLeft: `4px solid ${queue.failed > 0 ? '#ff4d4f' : queue.paused ? '#faad14' : '#1890ff'}` }}
styles={{ body: { padding: '6px 16px' } }}
>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
@ -504,9 +503,9 @@ export default function DashboardPage() {
)}
{/* === Dashboard Cards (masonry layout for dense packing) === */}
<div style={{ display: 'grid', gridTemplateColumns: screens.lg ? 'repeat(12, 1fr)' : screens.md ? 'repeat(2, 1fr)' : '1fr', gap: 12, gridAutoFlow: 'dense' }}>
<div style={{ columnCount: screens.lg ? 3 : screens.md ? 2 : 1, columnGap: 16 }}>
{showInfluence && (
<div className="db-mi" style={gs(4)}>
<div className="db-mi">
<Card
title={
<Flex align="center" gap={6}>
@ -553,7 +552,7 @@ export default function DashboardPage() {
)}
{showMap && (
<div className="db-mi" style={gs(4)}>
<div className="db-mi">
<Card
title={
<Flex align="center" gap={6}>
@ -590,7 +589,7 @@ export default function DashboardPage() {
</div>
)}
<div className="db-mi" style={gs(4)}>
<div className="db-mi">
<Card
title={
<Flex align="center" gap={6}>
@ -633,67 +632,82 @@ export default function DashboardPage() {
</Card>
</div>
<div className="db-mi" style={gs(5, 2)}>
<div className="db-mi">
<ActivityFeedCard />
</div>
{showEvents && (
<div className="db-mi" style={gs(3)}>
<div className="db-mi">
<TodayEventsCard />
</div>
)}
<div className="db-mi" style={gs(4)}>
<div className="db-mi">
<DocsAnalyticsCard />
</div>
{showChat && (
<div className="db-mi" style={gs(5, 2)}>
<div className="db-mi">
<ChatNotifierCard />
</div>
)}
{showMap && (
<div className="db-mi" style={gs(4)}>
<div className="db-mi">
<UpcomingShiftsCard />
</div>
)}
{showMeet && (
<div className="db-mi">
<UpcomingMeetingsCard />
</div>
)}
{showInfluence && (
<div className="db-mi" style={gs(3)}>
<div className="db-mi">
<CampaignEffectivenessCard />
</div>
)}
{showMap && (
<div className="db-mi" style={gs(3)}>
<div className="db-mi">
<RecentSignupsCard />
</div>
)}
{showMedia && (
<div className="db-mi" style={gs(5)}>
<div className="db-mi">
<TopVideosCard />
</div>
)}
{showMedia && (
<div className="db-mi" style={gs(4)}>
<div className="db-mi">
<RecentCommentsCard />
</div>
)}
{showNewsletter && isSuperAdmin && (
<div className="db-mi" style={gs(4)}>
<div className="db-mi">
<NewsletterStatsCard />
</div>
)}
{showPayments && isSuperAdmin && (
<div className="db-mi" style={gs(3)}>
<div className="db-mi">
<DonationSummaryCard />
</div>
)}
{isSuperAdmin && (
<div className="db-mi" style={gs(5)}>
<div className="db-mi">
<SystemAlertsCard />
</div>
)}
{isSuperAdmin && (
<div className="db-mi">
<GiteaActivityCard />
</div>
)}
{isSuperAdmin && (
<div className="db-mi">
<VaultwardenAdoptionCard />
</div>
)}
</div>
{/* === Canvass Progress (full-width table) === */}
{showMap && (
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Row gutter={[16, 16]} style={{ marginTop: 16, marginBottom: 16 }}>
<Col xs={24}>
<CutCampaignAnalyticsCard />
</Col>
@ -705,7 +719,7 @@ export default function DashboardPage() {
<>
{/* === Time-Series Charts (Traffic + Latency) === */}
{timeSeries && screens.md && (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} lg={12}>
<Card
title={<><BarChartOutlined style={{ marginRight: 6 }} />Request Traffic (1h)</>}
@ -728,7 +742,7 @@ export default function DashboardPage() {
)}
{/* System Info + Service Health */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
{/* Hardware card with circular gauges + mini system chart */}
<Col xs={24} lg={8}>
<Card title={<><DesktopOutlined style={{ marginRight: 6 }} />System</>} size="small" style={{ height: '100%' }}>
@ -857,7 +871,7 @@ export default function DashboardPage() {
{/* API Performance with status code donut + route bar chart */}
{apiMetrics && (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} lg={12}>
<Card
title={<><ApiOutlined style={{ marginRight: 6 }} />API Performance</>}
@ -989,6 +1003,10 @@ const SERVICE_LABELS: Record<string, string> = {
miniqr: 'Mini QR',
excalidraw: 'Excalidraw',
homepage: 'Homepage',
vaultwarden: 'Vaultwarden',
rocketchat: 'Rocket.Chat',
gancio: 'Gancio',
jitsi: 'Jitsi Meet',
};
const SERVICE_ICONS: Record<string, React.ReactNode> = {
@ -999,6 +1017,10 @@ const SERVICE_ICONS: Record<string, React.ReactNode> = {
miniqr: <QrcodeOutlined />,
excalidraw: <FileTextOutlined />,
homepage: <HomeOutlined />,
vaultwarden: <LockOutlined />,
rocketchat: <MessageOutlined />,
gancio: <CalendarOutlined />,
jitsi: <VideoCameraOutlined />,
};
// --- Quick Stat chip (for status bar) ---

View File

@ -41,24 +41,10 @@ export default function DataQualityDashboardPage() {
return () => clearInterval(interval);
}, [loadStats]);
// Page header with refresh button
useEffect(() => {
setPageHeader({
title: 'Data Quality Dashboard',
actions: (
<Button
icon={<ReloadOutlined />}
onClick={() => {
setLoading(true);
loadStats();
}}
>
Refresh
</Button>
),
});
setPageHeader({ title: 'Data Quality Dashboard' });
return () => setPageHeader(null);
}, [setPageHeader, loadStats]);
}, [setPageHeader]);
if (loading) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
@ -68,6 +54,18 @@ export default function DataQualityDashboardPage() {
return (
<div style={{ padding: screens.md ? 24 : 16 }}>
<div style={{ marginBottom: 16 }}>
<Button
icon={<ReloadOutlined />}
onClick={() => {
setLoading(true);
loadStats();
}}
>
Refresh
</Button>
</div>
{/* Overview Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}>

View File

@ -0,0 +1,441 @@
import { useState, useEffect, useCallback } from 'react';
import {
Row, Col, Card, Statistic, Table, Button, App, Tag, Space, Tabs, Popconfirm,
Form, Input, Switch, Alert, Divider,
} from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
MessageOutlined,
FileTextOutlined,
ReloadOutlined,
SettingOutlined,
SaveOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import { useSettingsStore } from '@/stores/settings.store';
import type { AppOutletContext } from '@/types/api';
interface DocsCommentItem {
id: string;
pagePath: string;
authorName: string;
authorEmail?: string;
body: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED';
reviewedAt?: string;
reviewedBy?: string;
createdAt: string;
}
interface ModerationResponse {
items: DocsCommentItem[];
total: number;
page: number;
pageSize: number;
}
interface CommentStats {
pending: number;
approved: number;
rejected: number;
totalPages: number;
}
const STATUS_COLORS: Record<string, string> = {
PENDING: 'orange',
APPROVED: 'green',
REJECTED: 'red',
};
// --- Settings Tab ---
function SettingsTab() {
const { message } = App.useApp();
const { fetchAdminSettings } = useSettingsStore();
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [setupLoading, setSetupLoading] = useState(false);
const [enabled, setEnabled] = useState(false);
useEffect(() => {
api.get('/settings/admin')
.then(({ data }) => {
form.setFieldsValue({
enableDocsComments: data.enableDocsComments ?? false,
giteaApiToken: data.giteaApiToken ?? '',
giteaCommentsRepoOwner: data.giteaCommentsRepoOwner ?? '',
giteaCommentsRepoName: data.giteaCommentsRepoName || 'docs-comments',
giteaOauthClientId: data.giteaOauthClientId ?? '',
giteaOauthClientSecret: data.giteaOauthClientSecret ?? '',
});
setEnabled(data.enableDocsComments ?? false);
})
.catch(() => message.error('Failed to load settings'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSave = async () => {
setSaving(true);
try {
const values = await form.validateFields();
await api.put('/settings', values);
message.success('Docs comments settings saved');
fetchAdminSettings();
} catch {
message.error('Failed to save settings');
} finally {
setSaving(false);
}
};
const handleSetup = async () => {
setSetupLoading(true);
try {
// Save settings first so the API can read them
const values = await form.validateFields();
await api.put('/settings', values);
const { data } = await api.post('/docs-comments/setup');
message.success(`Gitea repo "${data.repo}" ready with labels: ${data.labels.join(', ')}`);
fetchAdminSettings();
} catch {
message.error('Setup failed. Make sure comments are enabled and the API token + repo owner are set.');
} finally {
setSetupLoading(false);
}
};
if (loading) return null;
return (
<Card size="small">
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Gitea-backed comments for documentation pages"
description="Comments are stored as Gitea issues. Anonymous comments go through a moderation queue. Authenticated users comment via Gitea OAuth2 login."
/>
<Form form={form} layout="vertical" style={{ maxWidth: 600 }}>
<Form.Item name="enableDocsComments" label="Enable Docs Comments" valuePropName="checked">
<Switch onChange={(val) => setEnabled(val)} />
</Form.Item>
{enabled && (
<>
<Divider orientation="left" plain>Gitea Connection</Divider>
<Form.Item
name="giteaApiToken"
label="API Token"
extra="Personal Access Token with repo write scope. Create in Gitea > Settings > Applications."
rules={[{ required: true, message: 'API token is required' }]}
>
<Input.Password placeholder="Gitea Personal Access Token" />
</Form.Item>
<Form.Item
name="giteaCommentsRepoOwner"
label="Repository Owner"
extra="Gitea username that will own the docs-comments repository."
rules={[{ required: true, message: 'Repo owner is required' }]}
>
<Input placeholder="e.g. admin" />
</Form.Item>
<Form.Item
name="giteaCommentsRepoName"
label="Repository Name"
extra="Will be auto-created when you run Setup."
>
<Input placeholder="docs-comments" />
</Form.Item>
<Divider orientation="left" plain>OAuth2 Login (Optional)</Divider>
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="Optional — enables 'Sign in with Gitea' on docs pages"
description={
<>
Create an OAuth2 Application in Gitea &gt; Settings &gt; Applications. Set redirect URIs to:
<br /><code>https://{'<your-domain>'}/comments/callback/</code>
<br /><code>http://localhost:4003/comments/callback/</code>
</>
}
/>
<Form.Item name="giteaOauthClientId" label="OAuth Client ID">
<Input placeholder="OAuth2 Application Client ID" />
</Form.Item>
<Form.Item name="giteaOauthClientSecret" label="OAuth Client Secret">
<Input.Password placeholder="OAuth2 Application Client Secret" />
</Form.Item>
</>
)}
<Divider />
<Space>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
Save Settings
</Button>
{enabled && (
<Popconfirm
title="Setup Gitea Comments?"
description="This will save settings, then create the docs-comments repository and labels in Gitea."
onConfirm={handleSetup}
>
<Button icon={<ThunderboltOutlined />} loading={setupLoading}>
Save &amp; Setup Repo
</Button>
</Popconfirm>
)}
</Space>
</Form>
</Card>
);
}
// --- Main Page ---
export default function DocsCommentsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const [activeTab, setActiveTab] = useState('moderation');
const [stats, setStats] = useState<CommentStats | null>(null);
const [comments, setComments] = useState<DocsCommentItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [statusFilter, setStatusFilter] = useState<string | undefined>('PENDING');
const [loading, setLoading] = useState(false);
const [moderating, setModerating] = useState<Record<string, boolean>>({});
const fetchStats = useCallback(() => {
api.get('/docs-comments/stats').then(({ data }) => setStats(data)).catch(() => {});
}, []);
const fetchComments = useCallback(() => {
setLoading(true);
const params: Record<string, string | number> = { page, pageSize };
if (statusFilter) params.status = statusFilter;
api
.get('/docs-comments/moderation', { params })
.then(({ data }: { data: ModerationResponse }) => {
setComments(data.items);
setTotal(data.total);
})
.catch(() => message.error('Failed to load comments'))
.finally(() => setLoading(false));
}, [page, pageSize, statusFilter, message]);
useEffect(() => {
setPageHeader({ title: 'Docs Comments' });
fetchStats();
return () => setPageHeader(null);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (activeTab === 'moderation') fetchComments();
}, [fetchComments, activeTab]);
const handleModerate = async (id: string, action: 'approve' | 'reject') => {
setModerating((prev) => ({ ...prev, [id]: true }));
try {
await api.post(`/docs-comments/moderation/${id}`, { action });
message.success(`Comment ${action}d`);
fetchComments();
fetchStats();
} catch {
message.error(`Failed to ${action} comment`);
} finally {
setModerating((prev) => ({ ...prev, [id]: false }));
}
};
const columns = [
{
title: 'Page',
dataIndex: 'pagePath',
key: 'pagePath',
ellipsis: true,
width: 200,
render: (path: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }} title={path}>
{path}
</span>
),
},
{
title: 'Author',
dataIndex: 'authorName',
key: 'authorName',
width: 140,
render: (name: string, record: DocsCommentItem) => (
<span title={record.authorEmail || undefined}>{name}</span>
),
},
{
title: 'Comment',
dataIndex: 'body',
key: 'body',
ellipsis: true,
render: (body: string) => (
<span style={{ fontSize: 13 }}>{body.slice(0, 200)}{body.length > 200 ? '...' : ''}</span>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => (
<Tag color={STATUS_COLORS[status]}>{status}</Tag>
),
},
{
title: 'Date',
dataIndex: 'createdAt',
key: 'createdAt',
width: 140,
render: (date: string) => new Date(date).toLocaleDateString(),
},
{
title: 'Actions',
key: 'actions',
width: 180,
render: (_: unknown, record: DocsCommentItem) => {
if (record.status !== 'PENDING') {
return <Tag>{record.status === 'APPROVED' ? 'Approved' : 'Rejected'}</Tag>;
}
return (
<Space size="small">
<Button
type="primary"
size="small"
icon={<CheckCircleOutlined />}
loading={moderating[record.id]}
onClick={() => handleModerate(record.id, 'approve')}
>
Approve
</Button>
<Popconfirm
title="Reject this comment?"
description="The comment will be removed from Gitea."
onConfirm={() => handleModerate(record.id, 'reject')}
>
<Button
danger
size="small"
icon={<CloseCircleOutlined />}
loading={moderating[record.id]}
>
Reject
</Button>
</Popconfirm>
</Space>
);
},
},
];
const moderationContent = (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="Pending"
value={stats?.pending ?? 0}
prefix={<ClockCircleOutlined />}
valueStyle={stats?.pending ? { color: '#faad14' } : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Approved" value={stats?.approved ?? 0} prefix={<CheckCircleOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Rejected" value={stats?.rejected ?? 0} prefix={<CloseCircleOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="Pages" value={stats?.totalPages ?? 0} prefix={<FileTextOutlined />} />
</Card>
</Col>
</Row>
<Card
size="small"
extra={
<Button size="small" icon={<ReloadOutlined />} onClick={() => { fetchComments(); fetchStats(); }}>
Refresh
</Button>
}
>
<Tabs
activeKey={statusFilter || 'ALL'}
onChange={(key) => {
setStatusFilter(key === 'ALL' ? undefined : key);
setPage(1);
}}
items={[
{ key: 'PENDING', label: <><ClockCircleOutlined /> Pending{stats?.pending ? ` (${stats.pending})` : ''}</> },
{ key: 'APPROVED', label: <><CheckCircleOutlined /> Approved</> },
{ key: 'REJECTED', label: <><CloseCircleOutlined /> Rejected</> },
{ key: 'ALL', label: <><MessageOutlined /> All</> },
]}
/>
<Table
dataSource={comments}
columns={columns}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
pageSize,
total,
onChange: (p) => setPage(p),
showSizeChanger: false,
showTotal: (t) => `${t} comments`,
}}
/>
</Card>
</>
);
return (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'moderation',
label: <><MessageOutlined /> Moderation{stats?.pending ? ` (${stats.pending})` : ''}</>,
children: moderationContent,
},
{
key: 'settings',
label: <><SettingOutlined /> Settings</>,
children: <SettingsTab />,
},
]}
/>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, useLocation } from 'react-router-dom';
import {
Button,
Space,
@ -15,6 +15,7 @@ import {
Dropdown,
Menu,
Typography,
Segmented,
} from 'antd';
import type { TreeDataNode } from 'antd';
import type { MenuProps } from 'antd';
@ -55,6 +56,8 @@ import {
HeartOutlined,
CrownOutlined,
ShoppingCartOutlined,
MobileOutlined,
DesktopOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
@ -66,13 +69,21 @@ import type { FileNode, ServicesConfig } from '@/types/api';
import type { AppOutletContext } from '@/components/AppLayout';
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
import type { Video as PickerVideo } from '@/components/media/VideoPickerModal';
import { PhotoPickerModal } from '@/components/media/PhotoPickerModal';
import type { Photo as PickerPhoto } from '@/components/media/PhotoPickerModal';
import { PhotoInsertModal } from '@/components/media/PhotoInsertModal';
import type { PhotoInsertResult } from '@/components/media/PhotoInsertModal';
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
import { generatePhotoCardHtml } from '@/utils/photoCardHtml';
import { DonateInsertModal } from '@/components/payments/DonateInsertModal';
import type { DonateInsertResult } from '@/components/payments/DonateInsertModal';
import { ProductInsertModal } from '@/components/payments/ProductInsertModal';
import type { ProductInsertResult } from '@/components/payments/ProductInsertModal';
import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal';
type LayoutMode = 'split' | 'editor' | 'preview';
type PreviewMode = 'desktop' | 'mobile';
const LAYOUT_STORAGE_KEY = 'docs-editor-layout';
const DIVIDER_STORAGE_KEY = 'docs-editor-split';
@ -140,7 +151,17 @@ function invalidateTreeCache(): void {
}
// URL Preview Bar Component (shows full clickable URLs above iframe)
const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config: ServicesConfig | null }) => {
const URLPreviewBar = ({
filePath,
config,
previewMode,
onPreviewModeChange,
}: {
filePath: string | null;
config: ServicesConfig | null;
previewMode: PreviewMode;
onPreviewModeChange: (mode: PreviewMode) => void;
}) => {
const { token } = theme.useToken();
// Only show for markdown files
@ -188,32 +209,45 @@ const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config:
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 8px',
background: token.colorBgElevated,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
gap: 12,
flexShrink: 0,
overflow: 'hidden',
}}
>
{productionUrl && (
<Tooltip title={productionUrl} mouseEnterDelay={0.3}>
<span style={urlLinkStyle} onClick={() => openUrl(productionUrl)}>
<ExportOutlined style={{ fontSize: 10, flexShrink: 0 }} />
{productionUrl}
</span>
</Tooltip>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, overflow: 'hidden', minWidth: 0 }}>
{productionUrl && (
<Tooltip title={productionUrl} mouseEnterDelay={0.3}>
<span style={urlLinkStyle} onClick={() => openUrl(productionUrl)}>
<ExportOutlined style={{ fontSize: 10, flexShrink: 0 }} />
{productionUrl}
</span>
</Tooltip>
)}
{/* Only show localhost URL when on localhost (on real domain they resolve the same) */}
{!isRealDomain && localhostUrl && (
<Tooltip title={localhostUrl} mouseEnterDelay={0.3}>
<span style={urlLinkStyle} onClick={() => openUrl(localhostUrl)}>
<ExportOutlined style={{ fontSize: 10, flexShrink: 0 }} />
{localhostUrl}
</span>
</Tooltip>
)}
{/* Only show localhost URL when on localhost (on real domain they resolve the same) */}
{!isRealDomain && localhostUrl && (
<Tooltip title={localhostUrl} mouseEnterDelay={0.3}>
<span style={urlLinkStyle} onClick={() => openUrl(localhostUrl)}>
<ExportOutlined style={{ fontSize: 10, flexShrink: 0 }} />
{localhostUrl}
</span>
</Tooltip>
)}
</div>
<Segmented
size="small"
value={previewMode}
onChange={(val) => onPreviewModeChange(val as PreviewMode)}
options={[
{ value: 'desktop', icon: <DesktopOutlined /> },
{ value: 'mobile', icon: <MobileOutlined /> },
]}
style={{ flexShrink: 0 }}
/>
</div>
);
};
@ -320,9 +354,11 @@ const SNIPPETS: MkDocsSnippet[] = [
{ id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' },
{ id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' },
{ id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' },
{ id: 'photo-insert', label: 'Photo', group: 'insert', type: 'insert', template: '' },
{ id: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
];
@ -509,6 +545,7 @@ function applySnippet(
export default function DocsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const location = useLocation();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
@ -528,6 +565,7 @@ export default function DocsPage() {
const [layout, setLayout] = useState<LayoutMode>(
() => (localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode) || 'split',
);
const [previewMode, setPreviewMode] = useState<PreviewMode>('desktop');
const [splitPercent, setSplitPercent] = useState<number>(
() => Number(localStorage.getItem(DIVIDER_STORAGE_KEY)) || 50,
);
@ -546,8 +584,12 @@ export default function DocsPage() {
const [contextPath, setContextPath] = useState<string>('');
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
const [photoPickerOpen, setPhotoPickerOpen] = useState(false);
const [photoInsertOpen, setPhotoInsertOpen] = useState(false);
const [pendingPhotoVariant, setPendingPhotoVariant] = useState<PhotoInsertResult | null>(null);
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
const [productInsertOpen, setProductInsertOpen] = useState(false);
const [adPickerOpen, setAdPickerOpen] = useState(false);
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -648,6 +690,30 @@ export default function DocsPage() {
}
}, [fileContentCache, messageApi]);
// Handle navigation state from command palette — auto-select a file
useEffect(() => {
const selectFile = (location.state as { selectFile?: string } | null)?.selectFile;
if (!selectFile || loading) return;
// Expand parent directories so the file is visible in the tree
const parts = selectFile.split('/');
if (parts.length > 1) {
const parentKeys: string[] = [];
for (let i = 1; i < parts.length; i++) {
parentKeys.push(parts.slice(0, i).join('/'));
}
setExpandedKeys(prev => {
const set = new Set(prev.map(String));
for (const k of parentKeys) set.add(k);
return Array.from(set);
});
}
loadFile(selectFile);
// Clear navigation state so it doesn't re-trigger on revisit
window.history.replaceState({}, '');
}, [location.state, loading, loadFile]);
// Save file
const saveFile = useCallback(async () => {
if (!selectedFile || !dirty) return;
@ -727,6 +793,11 @@ export default function DocsPage() {
return;
}
if (snippetId === 'photo-insert') {
setPhotoInsertOpen(true);
return;
}
// Donate button — opens variant picker modal
if (snippetId === 'donate-button') {
setDonateInsertOpen(true);
@ -739,6 +810,12 @@ export default function DocsPage() {
return;
}
// Ad — opens ad picker modal
if (snippetId === 'ad-insert') {
setAdPickerOpen(true);
return;
}
// Pricing table — static CTA (plans are dynamic, so link out)
if (snippetId === 'pricing-table') {
const appUrl = config
@ -794,6 +871,99 @@ export default function DocsPage() {
setVideoPickerOpen(false);
}, [config]);
const handlePhotoInsert = useCallback((result: PhotoInsertResult) => {
if (result.variant === 'single-photo' || result.variant === 'photo-card') {
// Photo variants: save result then open photo picker
setPendingPhotoVariant(result);
setPhotoPickerOpen(true);
return;
}
// Album variants: generate HTML directly
const album = result.album;
if (!album) return;
let html = '';
if (result.variant === 'album-grid') {
const cols = result.options.columns || 3;
const max = result.options.maxPhotos || 12;
const title = result.options.showTitle !== false ? 'true' : 'false';
html = `<div class="photo-album-block" data-album-id="${album.id}" data-columns="${cols}" data-max-photos="${max}" data-show-title="${title}">Loading album...</div>`;
} else if (result.variant === 'album-carousel') {
const max = result.options.maxPhotos || 20;
const title = result.options.showTitle !== false ? 'true' : 'false';
const auto = result.options.autoPlay ? 'true' : 'false';
html = `<div class="photo-album-carousel" data-album-id="${album.id}" data-max-photos="${max}" data-show-title="${title}" data-auto-play="${auto}">Loading carousel...</div>`;
}
const ed = monacoEditorRef.current;
if (ed && html) {
const sel = ed.getSelection();
if (sel) {
ed.executeEdits('photo-album-insert', [{ range: sel, text: '\n' + html + '\n' }]);
}
}
}, []);
const handlePhotoSelected = useCallback((photo: PickerPhoto) => {
const variant = pendingPhotoVariant?.variant || 'photo-card';
if (variant === 'single-photo') {
// Generate a photo-block div for hydration
const opts = pendingPhotoVariant?.options || {};
const sizeAttr = opts.size || 'large';
const alignAttr = opts.alignment || 'center';
const linkAttr = opts.linkToGallery !== false ? 'true' : 'false';
const html = `<div class="photo-block" data-photo-id="${photo.id}" data-size="${sizeAttr}" data-caption="" data-link-to-gallery="${linkAttr}" data-alignment="${alignAttr}">Loading...</div>`;
const ed = monacoEditorRef.current;
if (ed) {
const sel = ed.getSelection();
if (sel) {
ed.executeEdits('photo-block-insert', [{ range: sel, text: '\n' + html + '\n' }]);
}
}
} else {
// photo-card variant — use existing generatePhotoCardHtml
const adminUrl = config
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
: window.location.origin;
const placeholderThumb = 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320">' +
'<rect fill="#0d1b2a" width="480" height="320"/>' +
'<circle cx="240" cy="160" r="32" fill="rgba(46,125,50,0.6)"/>' +
'<rect x="224" y="144" width="32" height="32" rx="4" fill="none" stroke="#fff" stroke-width="2"/>' +
'<circle cx="234" cy="154" r="3" fill="#fff"/>' +
'<path d="M256 176l-10-10L224 176" fill="none" stroke="#fff" stroke-width="2"/>' +
'</svg>'
);
const html = generatePhotoCardHtml({
id: photo.id,
title: photo.title || photo.originalFilename || 'Untitled Photo',
description: photo.description || undefined,
showMetadata: true,
format: photo.format || undefined,
width: photo.width || undefined,
height: photo.height || undefined,
viewCount: photo.viewCount || 0,
thumbnailUrl: placeholderThumb,
}, { baseUrl: adminUrl });
const ed = monacoEditorRef.current;
if (ed) {
const sel = ed.getSelection();
if (sel) {
ed.executeEdits('photo-card-insert', [{ range: sel, text: '\n' + html + '\n' }]);
}
}
}
setPhotoPickerOpen(false);
setPendingPhotoVariant(null);
}, [config, pendingPhotoVariant]);
const handleDonateInsert = useCallback((result: DonateInsertResult) => {
const uid = Date.now().toString(36);
@ -870,6 +1040,24 @@ export default function DocsPage() {
}
}, []);
const handleAdInsert = useCallback((result: AdInsertResult) => {
let html = '';
if (result.type === 'specific') {
html = `<div class="ad-specific-block" data-ad-id="${result.adId}" style="max-width:400px; margin:16px auto;">\n Loading ad...\n</div>`;
} else {
html = `<div class="ad-slot-block" data-placement="docs" data-variant="${result.variant || 'standard'}" style="max-width:400px; margin:16px auto;">\n Loading ad...\n</div>`;
}
const ed = monacoEditorRef.current;
if (ed) {
const sel = ed.getSelection();
if (sel) {
ed.executeEdits('ad-block-insert', [{ range: sel, text: '\n' + html + '\n' }]);
}
}
setAdPickerOpen(false);
}, []);
const handleCtxMenuClick = useCallback((snippetId: string) => {
setCtxMenu(null);
handleToolbarSnippet(snippetId);
@ -1788,7 +1976,7 @@ export default function DocsPage() {
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
key: s.id,
label: s.label,
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <PictureOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'photo-insert' ? <PictureOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'ad-insert' ? <BuildOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <FileMarkdownOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
onClick: () => handleToolbarSnippet(s.id),
})) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
@ -1901,15 +2089,36 @@ export default function DocsPage() {
}}
>
{/* URL Preview Bar */}
<URLPreviewBar filePath={selectedFile} config={config} />
<URLPreviewBar
filePath={selectedFile}
config={config}
previewMode={previewMode}
onPreviewModeChange={setPreviewMode}
/>
{/* Preview iframe */}
<iframe
ref={previewIframeRef}
src="/mkdocs-proxy/"
style={{ width: '100%', flex: 1, border: 'none' }}
title="MkDocs Preview"
/>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', justifyContent: 'center', alignItems: 'stretch' }}>
<div
style={previewMode === 'mobile' ? {
width: 375,
margin: '12px auto',
borderRadius: 16,
boxShadow: `0 0 0 2px ${token.colorBorderSecondary}, 0 4px 24px rgba(0,0,0,0.15)`,
overflow: 'hidden',
height: 'calc(100% - 24px)',
} : {
width: '100%',
height: '100%',
}}
>
<iframe
ref={previewIframeRef}
src="/mkdocs-proxy/"
style={{ width: '100%', height: '100%', border: 'none' }}
title="MkDocs Preview"
/>
</div>
</div>
</div>
)}
</div>
@ -1945,6 +2154,21 @@ export default function DocsPage() {
title="Insert Video Card"
/>
{/* Photo Insert Variant Picker Modal */}
<PhotoInsertModal
open={photoInsertOpen}
onClose={() => setPhotoInsertOpen(false)}
onInsert={handlePhotoInsert}
/>
{/* Photo Picker Modal (opened by photo insert for single-photo / photo-card variants) */}
<PhotoPickerModal
open={photoPickerOpen}
onClose={() => { setPhotoPickerOpen(false); setPendingPhotoVariant(null); }}
onSelect={handlePhotoSelected}
title={pendingPhotoVariant?.variant === 'single-photo' ? 'Select Photo' : 'Select Photo for Card'}
/>
{/* Donate Block Picker Modal */}
<DonateInsertModal
open={donateInsertOpen}
@ -1959,6 +2183,13 @@ export default function DocsPage() {
onInsert={handleProductInsert}
/>
{/* Ad Picker Modal */}
<AdPickerModal
open={adPickerOpen}
onCancel={() => setAdPickerOpen(false)}
onInsert={handleAdInsert}
/>
{/* Custom right-click context menu with submenus */}
{ctxMenu && (
<div

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Card, Statistic, Row, Col, Button, Space, Tag, message, Popconfirm } from 'antd';
import {
PauseCircleOutlined,
@ -64,49 +64,51 @@ export default function EmailQueuePage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space>
{stats && (
<Tag color={stats.paused ? 'orange' : 'green'}>
{stats.paused ? 'PAUSED' : 'RUNNING'}
</Tag>
)}
<Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>
Refresh
</Button>
<Popconfirm
title={stats?.paused ? 'Resume email queue?' : 'Pause email queue?'}
onConfirm={handlePauseResume}
>
<Button
icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
loading={actionLoading}
>
{stats?.paused ? 'Resume' : 'Pause'}
</Button>
</Popconfirm>
<Popconfirm
title="Clean completed jobs?"
description="This permanently deletes job records."
onConfirm={handleClean}
>
<Button
icon={<DeleteOutlined />}
loading={actionLoading}
>
Clean Old Jobs
</Button>
</Popconfirm>
</Space>
), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);
useEffect(() => {
setPageHeader({ title: 'Email Queue', actions: headerActions });
setPageHeader({ title: 'Email Queue' });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
}, [setPageHeader]);
return (
<>
<Row style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<Space wrap>
{stats && (
<Tag color={stats.paused ? 'orange' : 'green'} style={{ marginInlineEnd: 0 }}>
{stats.paused ? 'PAUSED' : 'RUNNING'}
</Tag>
)}
<Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>
Refresh
</Button>
<Popconfirm
title={stats?.paused ? 'Resume email queue?' : 'Pause email queue?'}
onConfirm={handlePauseResume}
>
<Button
icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
loading={actionLoading}
>
{stats?.paused ? 'Resume' : 'Pause'}
</Button>
</Popconfirm>
<Popconfirm
title="Clean completed jobs?"
description="This permanently deletes job records."
onConfirm={handleClean}
>
<Button
icon={<DeleteOutlined />}
loading={actionLoading}
>
Clean Old Jobs
</Button>
</Popconfirm>
</Space>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={12} sm={6}>
<Card>

View File

@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import { useParams, Navigate } from 'react-router-dom';
import { Spin, Result } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { api } from '@/lib/api';
/**
* Jitsi TOKEN_AUTH_URL bridge page.
*
* When a guest clicks "I am the host" in Jitsi, Jitsi redirects here.
* This page checks auth, requests a moderator JWT from our API, and
* redirects back to Jitsi with the token attached.
*/
export default function JitsiAuthPage() {
const { room } = useParams<{ room: string }>();
const { isAuthenticated, isLoading } = useAuthStore();
const [error, setError] = useState<{ status: number; message: string } | null>(null);
useEffect(() => {
if (isLoading || !isAuthenticated || !room) return;
let cancelled = false;
async function fetchTokenAndRedirect() {
try {
const { data } = await api.post<{ token: string; room: string }>('/jitsi/token', { room });
if (cancelled) return;
// Derive meet URL: replace "app." prefix with "meet." on the current hostname
const hostname = window.location.hostname;
const meetHost = hostname.startsWith('app.')
? 'meet.' + hostname.slice(4)
: hostname;
const protocol = window.location.protocol;
window.location.replace(`${protocol}//${meetHost}/${room}?jwt=${data.token}`);
} catch (err: any) {
if (cancelled) return;
const status = err?.response?.status || 500;
const message = err?.response?.data?.error || 'Failed to generate meeting token';
setError({ status, message });
}
}
fetchTokenAndRedirect();
return () => { cancelled = true; };
}, [isAuthenticated, isLoading, room]);
// Still hydrating auth state — show spinner
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
// Not logged in — redirect to login with return path
if (!isAuthenticated) {
return <Navigate to={`/login?redirect=/jitsi-auth/${room}`} replace />;
}
// API error
if (error) {
const subtitle = error.status === 403
? 'Your account does not have permission to host meetings.'
: error.status === 400
? 'Video meetings are not enabled on this platform.'
: error.message;
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Result
status="error"
title="Unable to join as host"
subTitle={subtitle}
/>
</div>
);
}
// Default: fetching token — show spinner
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" tip="Authenticating..." />
</div>
);
}

View File

@ -0,0 +1,292 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Button, Space, Badge, Table, Modal, Form, Input, DatePicker,
App, Popconfirm, Typography, Tag, Tooltip, Grid, Result,
} from 'antd';
import {
ReloadOutlined, PlusOutlined, VideoCameraOutlined,
CopyOutlined, DeleteOutlined, LoginOutlined, LinkOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { Meeting, ServicesStatus, ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
const { Text, Paragraph } = Typography;
export default function JitsiMeetPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [loading, setLoading] = useState(true);
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [form] = Form.useForm();
const fetchStatus = useCallback(async () => {
try {
const [statusRes, configRes] = await Promise.all([
api.get<ServicesStatus>('/services/status'),
api.get<ServicesConfig>('/services/config'),
]);
setOnline(statusRes.data.jitsi.online);
setConfig(configRes.data);
} catch {
setOnline(false);
}
}, []);
const fetchMeetings = useCallback(async () => {
try {
const res = await api.get<{ meetings: Meeting[] }>('/jitsi/meetings');
setMeetings(res.data.meetings);
} catch {
message.error('Failed to load meetings');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
fetchStatus();
fetchMeetings();
}, [fetchStatus, fetchMeetings]);
const meetUrl = config
? buildServiceUrl(config.jitsiSubdomain, config.domain, config.jitsiPort)
: null;
const handleRefresh = useCallback(() => {
setLoading(true);
fetchStatus();
fetchMeetings();
}, [fetchStatus, fetchMeetings]);
const handleCreate = useCallback(async (values: { title: string; description?: string; schedule?: [dayjs.Dayjs, dayjs.Dayjs] }) => {
setCreating(true);
try {
const body: Record<string, unknown> = { title: values.title };
if (values.description) body.description = values.description;
if (values.schedule) {
body.startTime = values.schedule[0].toISOString();
body.endTime = values.schedule[1].toISOString();
}
await api.post('/jitsi/meetings', body);
message.success('Meeting created');
setCreateOpen(false);
form.resetFields();
fetchMeetings();
} catch {
message.error('Failed to create meeting');
} finally {
setCreating(false);
}
}, [form, message, fetchMeetings]);
const handleDelete = useCallback(async (id: string) => {
try {
await api.delete(`/jitsi/meetings/${id}`);
message.success('Meeting deleted');
fetchMeetings();
} catch {
message.error('Failed to delete meeting');
}
}, [message, fetchMeetings]);
const handleJoinAsModerator = useCallback(async (meeting: Meeting) => {
if (!meetUrl) return;
try {
const res = await api.post<{ token: string; jitsiRoom: string; domain: string }>(`/jitsi/meetings/${meeting.slug}/token`);
const url = `${meetUrl}/${res.data.jitsiRoom}?jwt=${res.data.token}`;
window.open(url, '_blank');
} catch {
message.error('Failed to generate meeting token');
}
}, [meetUrl, message]);
const copyGuestLink = useCallback((meeting: Meeting) => {
const origin = window.location.origin;
const link = `${origin}/meet/${meeting.slug}`;
navigator.clipboard.writeText(link);
message.success('Guest link copied');
}, [message]);
const copyModeratorLink = useCallback(async (meeting: Meeting) => {
if (!meetUrl) return;
try {
const res = await api.post<{ token: string; jitsiRoom: string }>(`/jitsi/meetings/${meeting.slug}/token`);
const url = `${meetUrl}/${res.data.jitsiRoom}?jwt=${res.data.token}`;
navigator.clipboard.writeText(url);
message.success('Moderator link copied (expires in 2h)');
} catch {
message.error('Failed to generate moderator link');
}
}, [meetUrl, message]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} size="small">
Refresh
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small">
New Meeting
</Button>
</Space>
), [online, handleRefresh]);
useEffect(() => {
setPageHeader({ title: 'Video Meet', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="Meeting management requires a desktop browser."
icon={<VideoCameraOutlined style={{ fontSize: 48 }} />}
/>
);
}
const columns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record: Meeting) => (
<Space direction="vertical" size={0}>
<Text strong>{title}</Text>
{record.description && (
<Text type="secondary" style={{ fontSize: 12 }}>{record.description}</Text>
)}
</Space>
),
},
{
title: 'Status',
dataIndex: 'isActive',
key: 'status',
width: 100,
render: (isActive: boolean) => (
<Tag color={isActive ? 'green' : 'default'}>{isActive ? 'Active' : 'Ended'}</Tag>
),
},
{
title: 'Schedule',
key: 'schedule',
width: 200,
render: (_: unknown, record: Meeting) => {
if (!record.startTime) return <Text type="secondary">Anytime</Text>;
return (
<Space direction="vertical" size={0}>
<Text style={{ fontSize: 12 }}>{dayjs(record.startTime).format('MMM D, YYYY h:mm A')}</Text>
{record.endTime && (
<Text type="secondary" style={{ fontSize: 12 }}>to {dayjs(record.endTime).format('h:mm A')}</Text>
)}
</Space>
);
},
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 140,
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
width: 280,
render: (_: unknown, record: Meeting) => (
<Space size="small" wrap>
<Tooltip title="Join as moderator">
<Button
size="small"
type="primary"
icon={<LoginOutlined />}
onClick={() => handleJoinAsModerator(record)}
disabled={!record.isActive}
>
Join
</Button>
</Tooltip>
<Tooltip title="Copy guest link">
<Button size="small" icon={<LinkOutlined />} onClick={() => copyGuestLink(record)}>
Guest Link
</Button>
</Tooltip>
<Tooltip title="Copy moderator link (JWT, expires in 2h)">
<Button size="small" icon={<CopyOutlined />} onClick={() => copyModeratorLink(record)}>
Mod Link
</Button>
</Tooltip>
<Popconfirm
title="Delete this meeting?"
onConfirm={() => handleDelete(record.id)}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Table
dataSource={meetings}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
locale={{ emptyText: 'No meetings yet. Create one to get started.' }}
/>
<Modal
title="Create Meeting"
open={createOpen}
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
onOk={() => form.submit()}
confirmLoading={creating}
okText="Create"
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item
label="Title"
name="title"
rules={[{ required: true, message: 'Enter a meeting title' }]}
>
<Input placeholder="e.g. Team Standup" maxLength={200} />
</Form.Item>
<Form.Item label="Description" name="description">
<Input.TextArea rows={3} placeholder="Optional description for guests" maxLength={2000} />
</Form.Item>
<Form.Item label="Schedule (optional)" name="schedule">
<DatePicker.RangePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Form>
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
A unique guest link will be generated. Share it with anyone they can join without an account.
Authenticated users join as moderators with full meeting controls.
</Paragraph>
</Modal>
</>
);
}

View File

@ -7,6 +7,7 @@ import {
Tag,
Space,
Modal,
Drawer,
Form,
Popconfirm,
message,
@ -31,7 +32,7 @@ import {
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import { useOutletContext, useLocation } from 'react-router-dom';
import { api } from '@/lib/api';
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import LandingPageEditor from '@/components/landing-pages/LandingPageEditor';
@ -48,6 +49,7 @@ const publishedOptions = [
export default function LandingPagesPage() {
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
const { setPageHeader } = useOutletContext<AppOutletContext>();
const location = useLocation();
const [pages, setPages] = useState<LandingPage[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
@ -59,8 +61,8 @@ export default function LandingPagesPage() {
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [publishedFilter, setPublishedFilter] = useState<'true' | 'false' | undefined>();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
const [editingPageId, setEditingPageId] = useState<string | null>(null);
const [qrPage, setQrPage] = useState<LandingPage | null>(null);
@ -78,6 +80,14 @@ export default function LandingPagesPage() {
return () => clearTimeout(searchTimerRef.current);
}, []);
// Handle navigation state from command palette — auto-open editor for a page
useEffect(() => {
const editPageId = (location.state as { editPageId?: string } | null)?.editPageId;
if (!editPageId) return;
setEditingPageId(editPageId);
window.history.replaceState({}, '');
}, [location.state]);
const fetchPages = useCallback(async (params?: LandingPagesListParams) => {
setLoading(true);
try {
@ -116,7 +126,7 @@ export default function LandingPagesPage() {
try {
const { data } = await api.post<LandingPage>('/pages', values);
message.success('Page created');
setCreateModalOpen(false);
setCreateDrawerOpen(false);
createForm.resetFields();
setEditingPageId(data.id);
} catch (err: unknown) {
@ -167,7 +177,7 @@ export default function LandingPagesPage() {
try {
await api.put(`/pages/${editingPage.id}`, values);
message.success('Page settings updated');
setSettingsModalOpen(false);
setSettingsDrawerOpen(false);
setEditingPage(null);
settingsForm.resetFields();
fetchPages();
@ -231,6 +241,7 @@ export default function LandingPagesPage() {
settingsForm.setFieldsValue({
title: page.title,
description: page.description,
listed: (page as any).listed ?? false,
mkdocsPath: page.mkdocsPath,
mkdocsExportMode: page.mkdocsExportMode,
mkdocsHideNav: page.mkdocsHideNav,
@ -240,7 +251,7 @@ export default function LandingPagesPage() {
seoDescription: page.seoDescription,
seoImage: page.seoImage,
});
setSettingsModalOpen(true);
setSettingsDrawerOpen(true);
};
const columns: ColumnsType<LandingPage> = [
@ -270,8 +281,11 @@ export default function LandingPagesPage() {
title: 'Status',
dataIndex: 'published',
key: 'published',
render: (published: boolean) => (
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
render: (published: boolean, record: LandingPage) => (
<Space size={4}>
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
{(record as any).listed && <Tag color="blue">Listed</Tag>}
</Space>
),
},
{
@ -399,95 +413,111 @@ export default function LandingPagesPage() {
);
}
const anyDrawerOpen = createDrawerOpen || settingsDrawerOpen;
const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 520 : settingsDrawerOpen ? 560 : 0);
return (
<>
<Row justify="end" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Space>
{isSuperAdmin && (
<div style={{ marginRight: anyDrawerOpen ? activeDrawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
<Row justify="end" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Space>
{isSuperAdmin && (
<Button
icon={<BuildOutlined />}
loading={building}
onClick={confirmAndBuild}
>
Build Site
</Button>
)}
<Button
icon={<BuildOutlined />}
loading={building}
onClick={confirmAndBuild}
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSyncOverrides}
>
Build Site
Sync Overrides
</Button>
)}
<Button
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSyncOverrides}
>
Sync Overrides
</Button>
<Button
icon={<SyncOutlined spin={validating} />}
loading={validating}
onClick={handleValidateExports}
title="Validate MkDocs export files and repair if missing"
>
Validate Exports
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
Create Page
</Button>
</Space>
</Col>
</Row>
<Button
icon={<SyncOutlined spin={validating} />}
loading={validating}
onClick={handleValidateExports}
title="Validate MkDocs export files and repair if missing"
>
Validate Exports
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateDrawerOpen(true)}
>
Create Page
</Button>
</Space>
</Col>
</Row>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search by title or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Status"
options={publishedOptions}
value={publishedFilter}
onChange={setPublishedFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search by title or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Status"
options={publishedOptions}
value={publishedFilter}
onChange={setPublishedFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Table<LandingPage>
columns={columns}
dataSource={pages}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} pages`,
}}
onChange={handleTableChange}
locale={{ emptyText: 'No landing pages yet. Create your first page to get started.' }}
/>
<Table<LandingPage>
columns={columns}
dataSource={pages}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} pages`,
}}
onChange={handleTableChange}
locale={{ emptyText: 'No landing pages yet. Create your first page to get started.' }}
/>
</div>
{/* Create Modal */}
<Modal
{/* Create Drawer */}
<Drawer
title="Create Landing Page"
open={createModalOpen}
open={createDrawerOpen}
destroyOnHidden
onCancel={() => {
setCreateModalOpen(false);
mask={false}
width={isMobile ? '100%' : 520}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setCreateDrawerOpen(false);
createForm.resetFields();
}}
onOk={() => createForm.submit()}
okText="Create & Edit"
extra={
<Space>
<Button onClick={() => { setCreateDrawerOpen(false); createForm.resetFields(); }}>
Cancel
</Button>
<Button type="primary" onClick={() => createForm.submit()}>
Create & Edit
</Button>
</Space>
}
>
<Form form={createForm} onFinish={handleCreate} layout="vertical" initialValues={{ editorMode: 'VISUAL' }}>
<Form.Item
@ -507,7 +537,7 @@ export default function LandingPagesPage() {
</Radio.Group>
</Form.Item>
</Form>
</Modal>
</Drawer>
{/* QR Code Modal */}
{qrPage && (
@ -519,19 +549,29 @@ export default function LandingPagesPage() {
/>
)}
{/* Settings Modal */}
<Modal
{/* Settings Drawer */}
<Drawer
title="Page Settings"
open={settingsModalOpen}
open={settingsDrawerOpen}
destroyOnHidden
width={isMobile ? '95vw' : 560}
onCancel={() => {
setSettingsModalOpen(false);
mask={false}
width={isMobile ? '100%' : 560}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setSettingsDrawerOpen(false);
setEditingPage(null);
settingsForm.resetFields();
}}
onOk={() => settingsForm.submit()}
okText="Save"
extra={
<Space>
<Button onClick={() => { setSettingsDrawerOpen(false); setEditingPage(null); settingsForm.resetFields(); }}>
Cancel
</Button>
<Button type="primary" onClick={() => settingsForm.submit()}>
Save
</Button>
</Space>
}
>
<Form form={settingsForm} onFinish={handleSettingsSave} layout="vertical">
<Form.Item
@ -554,6 +594,14 @@ export default function LandingPagesPage() {
<Input placeholder="https://..." />
</Form.Item>
<Form.Item
name="listed"
valuePropName="checked"
help="Show this page in the public /pages directory when published."
>
<Checkbox>List in Pages Index</Checkbox>
</Form.Item>
<Divider>MkDocs Integration</Divider>
<Form.Item
@ -599,7 +647,7 @@ export default function LandingPagesPage() {
}
</Form.Item>
</Form>
</Modal>
</Drawer>
</>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Table,
Button,
@ -112,14 +112,14 @@ export default function LocationsPage() {
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [confidenceFilter, setConfidenceFilter] = useState<'high' | 'medium' | 'low' | 'none' | undefined>();
// Modals/Drawers
const [createModalOpen, setCreateModalOpen] = useState(false);
// Drawers
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
const [locationHistory, setLocationHistory] = useState<LocationHistory[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyPagination, setHistoryPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [importModalOpen, setImportModalOpen] = useState(false);
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [geocodingMissing, setGeocodingMissing] = useState(false);
const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server' | 'area'>('standard');
@ -146,7 +146,7 @@ export default function LocationsPage() {
const narPollRef = useRef<ReturnType<typeof setInterval>>(undefined);
// Bulk Re-Geocoding state
const [bulkGeocodeModalOpen, setBulkGeocodeModalOpen] = useState(false);
const [bulkGeocodeDrawerOpen, setBulkGeocodeDrawerOpen] = useState(false);
const [bulkGeocoding, setBulkGeocoding] = useState(false);
const [bulkGeocodeStatus, setBulkGeocodeStatus] = useState<any>(null);
const bulkGeocodePollRef = useRef<ReturnType<typeof setInterval>>(undefined);
@ -376,7 +376,7 @@ export default function LocationsPage() {
);
await api.post('/map/locations', cleaned);
message.success('Location created');
setCreateModalOpen(false);
setCreateDrawerOpen(false);
createForm.resetFields();
fetchLocations({ page: 1 });
fetchStats();
@ -483,7 +483,7 @@ export default function LocationsPage() {
),
});
}
setImportModalOpen(false);
setImportDrawerOpen(false);
fetchLocations({ page: 1 });
fetchStats();
} catch (err: unknown) {
@ -674,7 +674,7 @@ export default function LocationsPage() {
longitude: Math.round(lng * 100000) / 100000,
});
}
setCreateModalOpen(true);
setCreateDrawerOpen(true);
};
const handleMoveLocation = async (id: string, lat: number, lng: number) => {
@ -980,53 +980,17 @@ export default function LocationsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space wrap>
<Button icon={<ScissorOutlined />} onClick={() => navigate('/app/map/cuts')}>
Cuts
</Button>
<Button icon={<EyeOutlined />} onClick={() => navigate('/map')}>
Public Map
</Button>
<Button icon={<SettingOutlined />} onClick={() => navigate('/app/map/settings')}>
Settings
</Button>
<Button icon={<DownloadOutlined />} onClick={handleExportCsv}>
Export CSV
</Button>
<Button icon={<UploadOutlined />} onClick={() => setImportModalOpen(true)}>
Import CSV
</Button>
<Button
icon={<AimOutlined />}
onClick={handleGeocodeMissing}
loading={geocodingMissing}
>
Geocode Missing
</Button>
<Button
icon={<TableOutlined />}
onClick={() => setBulkGeocodeModalOpen(true)}
>
Bulk Re-Geocode
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
Add Location
</Button>
</Space>
), [navigate, handleExportCsv, handleGeocodeMissing, geocodingMissing]);
useEffect(() => {
setPageHeader({ title: 'Map Locations', actions: headerActions });
setPageHeader({ title: 'Map Locations' });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
}, [setPageHeader]);
const anyDrawerOpen = createDrawerOpen || editDrawerOpen || importDrawerOpen || bulkGeocodeDrawerOpen;
const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 600 : editDrawerOpen ? 700 : importDrawerOpen ? 700 : bulkGeocodeDrawerOpen ? 600 : 0);
return (
<>
<div style={{ marginRight: anyDrawerOpen ? activeDrawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
{/* Stats cards */}
{stats && (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
@ -1144,6 +1108,22 @@ export default function LocationsPage() {
</Row>
)}
{/* Action bar */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Space wrap>
<Button icon={<ScissorOutlined />} onClick={() => navigate('/app/map/cuts')}>Cuts</Button>
<Button icon={<EyeOutlined />} onClick={() => navigate('/map')}>Public Map</Button>
<Button icon={<SettingOutlined />} onClick={() => navigate('/app/map/settings')}>Settings</Button>
</Space>
<Space wrap>
<Button icon={<DownloadOutlined />} onClick={handleExportCsv}>Export CSV</Button>
<Button icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>Import CSV</Button>
<Button icon={<AimOutlined />} onClick={handleGeocodeMissing} loading={geocodingMissing}>Geocode Missing</Button>
<Button icon={<TableOutlined />} onClick={() => setBulkGeocodeDrawerOpen(true)}>Bulk Re-Geocode</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateDrawerOpen(true)}>Add Location</Button>
</Space>
</Row>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
@ -1219,7 +1199,7 @@ export default function LocationsPage() {
? 'No locations match your filters.'
: <div style={{ padding: 16 }}>
<div style={{ marginBottom: 8, color: 'rgba(255,255,255,0.45)' }}>No locations yet.</div>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setImportModalOpen(true)}>Import CSV</Button>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>Import CSV</Button>
</div>
}}
/>
@ -1244,30 +1224,47 @@ export default function LocationsPage() {
},
]}
/>
</div>
{/* Create Modal */}
<Modal
{/* Create Drawer */}
<Drawer
title="Add Location"
open={createModalOpen}
open={createDrawerOpen}
destroyOnHidden
width={isMobile ? '95vw' : 600}
onCancel={() => {
setCreateModalOpen(false);
mask={false}
width={isMobile ? '100%' : 600}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setCreateDrawerOpen(false);
createForm.resetFields();
}}
onOk={() => createForm.submit()}
okText="Create"
extra={
<Space>
<Button onClick={() => {
setCreateDrawerOpen(false);
createForm.resetFields();
}}>
Cancel
</Button>
<Button type="primary" onClick={() => createForm.submit()}>
Create
</Button>
</Space>
}
>
<Form form={createForm} onFinish={handleCreate} layout="vertical">
{locationFormFields(createForm)}
</Form>
</Modal>
</Drawer>
{/* Edit Drawer */}
<Drawer
title="Edit Location"
open={editDrawerOpen}
mask={false}
destroyOnHidden
width={isMobile ? '100%' : 700}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setEditDrawerOpen(false);
setEditingLocation(null);
@ -1276,9 +1273,18 @@ export default function LocationsPage() {
editForm.resetFields();
}}
extra={
<Button type="primary" onClick={() => editForm.submit()}>
Save
</Button>
<Space>
<Button onClick={() => {
setEditDrawerOpen(false);
setEditingLocation(null);
editForm.resetFields();
}}>
Cancel
</Button>
<Button type="primary" onClick={() => editForm.submit()}>
Save
</Button>
</Space>
}
>
<Tabs
@ -1371,14 +1377,16 @@ export default function LocationsPage() {
/>
</Drawer>
{/* Import CSV / Bulk Import Modal */}
<Modal
{/* Import CSV / Bulk Import Drawer */}
<Drawer
title="Import Locations"
open={importModalOpen}
open={importDrawerOpen}
destroyOnHidden
width={importFormat === 'area' ? 700 : 620}
onCancel={() => {
setImportModalOpen(false);
mask={false}
width={isMobile ? '100%' : 700}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setImportDrawerOpen(false);
setBulkImportResult(null);
setNarImportResult(null);
setNarProgress(null);
@ -1395,7 +1403,14 @@ export default function LocationsPage() {
setNarFilterPostalPrefix('');
setNarFilterCutId(undefined);
}}
footer={null}
extra={
<Button onClick={() => {
setImportDrawerOpen(false);
setImportFormat('standard');
}}>
Close
</Button>
}
>
<Radio.Group
value={importFormat}
@ -1421,7 +1436,7 @@ export default function LocationsPage() {
<AreaImportWizard
cuts={cuts}
onComplete={() => {
setImportModalOpen(false);
setImportDrawerOpen(false);
setImportFormat('standard');
fetchLocations();
fetchStats();
@ -1840,21 +1855,34 @@ export default function LocationsPage() {
)}
</>
)}
</Modal>
</Drawer>
{/* Bulk Re-Geocode Modal */}
<Modal
{/* Bulk Re-Geocode Drawer */}
<Drawer
title="Bulk Re-Geocode Locations"
open={bulkGeocodeModalOpen}
onCancel={() => {
setBulkGeocodeModalOpen(false);
open={bulkGeocodeDrawerOpen}
destroyOnHidden
mask={false}
width={isMobile ? '100%' : 600}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
onClose={() => {
setBulkGeocodeDrawerOpen(false);
setBulkGeocoding(false);
setBulkGeocodeStatus(null);
stopBulkGeocodePolling();
bulkGeocodeForm.resetFields();
}}
footer={null}
width={isMobile ? '95vw' : 600}
extra={
<Button onClick={() => {
setBulkGeocodeDrawerOpen(false);
setBulkGeocoding(false);
setBulkGeocodeStatus(null);
stopBulkGeocodePolling();
bulkGeocodeForm.resetFields();
}}>
Close
</Button>
}
>
{!bulkGeocoding && !bulkGeocodeStatus ? (
<Form
@ -1980,7 +2008,7 @@ export default function LocationsPage() {
size="large"
style={{ marginTop: 16 }}
onClick={() => {
setBulkGeocodeModalOpen(false);
setBulkGeocodeDrawerOpen(false);
setBulkGeocoding(false);
setBulkGeocodeStatus(null);
stopBulkGeocodePolling();
@ -1991,7 +2019,7 @@ export default function LocationsPage() {
</Button>
</>
) : null}
</Modal>
</Drawer>
</>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Form,
Input,
@ -12,7 +12,6 @@ import {
Divider,
message,
Spin,
Space,
AutoComplete,
Switch,
} from 'antd';
@ -42,6 +41,12 @@ export default function MapSettingsPage() {
const { data } = await api.get<MapSettings>('/map/settings');
form.setFieldsValue({
publicMapEnabled: data.publicMapEnabled ?? true,
publicShowLocations: data.publicShowLocations ?? true,
publicShowSupportLevels: data.publicShowSupportLevels ?? true,
publicShowCuts: data.publicShowCuts ?? true,
publicShowEvents: data.publicShowEvents ?? true,
publicShowAddresses: data.publicShowAddresses ?? true,
publicShowSignInfo: data.publicShowSignInfo ?? true,
latitude: data.latitude ? parseFloat(data.latitude) : 45.4215,
longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,
zoom: data.zoom ?? 12,
@ -131,29 +136,10 @@ export default function MapSettingsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space>
<Button
icon={<PrinterOutlined />}
onClick={() => window.print()}
>
Print Walk Sheet
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={() => form.submit()}
>
Save Settings
</Button>
</Space>
), [saving, form]);
useEffect(() => {
setPageHeader({ title: 'Map Settings', actions: headerActions });
setPageHeader({ title: 'Map Settings' });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
}, [setPageHeader]);
if (loading) {
return (
@ -196,6 +182,25 @@ export default function MapSettingsPage() {
{/* Left column: Settings form */}
<Col xs={24} lg={10}>
<Form form={form} onFinish={handleSave} layout="vertical">
<div style={{ display: 'flex', gap: 12, marginBottom: 24 }}>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
htmlType="submit"
size="large"
>
Save Settings
</Button>
<Button
icon={<PrinterOutlined />}
onClick={() => window.print()}
size="large"
>
Print Walk Sheet
</Button>
</div>
<Card title="Public Map Visibility" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
@ -211,6 +216,31 @@ export default function MapSettingsPage() {
</div>
</Card>
<Card title="Public Map Layer Visibility" style={{ marginBottom: 24 }}>
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
Control which data layers are visible on the public map. Support levels and sign info are stripped server-side for security.
</Text>
{[
{ name: 'publicShowLocations', label: 'Show Location Markers', desc: 'Display location dots on the public map' },
{ name: 'publicShowSupportLevels', label: 'Show Support Levels', desc: 'Color markers by support level (stripped from API when off)' },
{ name: 'publicShowCuts', label: 'Show Cut Boundaries', desc: 'Display cut polygon overlays on the map' },
{ name: 'publicShowEvents', label: 'Show Events', desc: 'Display Gancio event markers on the map' },
{ name: 'publicShowAddresses', label: 'Show Addresses in Popups', desc: 'Display address text when clicking markers' },
{ name: 'publicShowSignInfo', label: 'Show Sign Info', desc: 'Display sign/sign-size data in popups (stripped from API when off)' },
].map(({ name, label, desc }) => (
<div key={name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div>
<Text strong>{label}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{desc}</Text>
</div>
<Form.Item name={name} valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</div>
))}
</Card>
<Card title="Map Center & Zoom" style={{ marginBottom: 24 }}>
<div style={{ marginBottom: 16 }}>
<AutoComplete
@ -279,6 +309,7 @@ export default function MapSettingsPage() {
</Row>
))}
</Card>
</Form>
</Col>

View File

@ -4,7 +4,6 @@ import {
Tabs,
Card,
Collapse,
ColorPicker,
Dropdown,
Form,
Input,
@ -23,7 +22,6 @@ import {
Result,
Grid,
theme,
Popconfirm,
} from 'antd';
import type { TreeDataNode } from 'antd';
import type { TreeProps } from 'antd';
@ -41,9 +39,6 @@ import {
LoadingOutlined,
WarningOutlined,
HolderOutlined,
UndoOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { parseDocument, Document } from 'yaml';
@ -51,7 +46,7 @@ import type { ScalarTag } from 'yaml';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import type { AppOutletContext } from '@/components/AppLayout';
import type { MkDocsConfigResponse, MkDocsBuildResult, FileNode, DocsStatus, DocsConfig, Campaign, HeaderConfig, HeaderNavItem } from '@/types/api';
import type { MkDocsConfigResponse, MkDocsBuildResult, FileNode, DocsStatus, DocsConfig, Campaign } from '@/types/api';
const { Text } = Typography;
@ -292,16 +287,6 @@ export default function MkDocsSettingsPage() {
const [editorDirty, setEditorDirty] = useState(false);
const [editorSaving, setEditorSaving] = useState(false);
// Header tab state
const [headerConfig, setHeaderConfig] = useState<HeaderConfig | null>(null);
const [headerDirty, setHeaderDirty] = useState(false);
const [headerSaving, setHeaderSaving] = useState(false);
const [headerLoading, setHeaderLoading] = useState(false);
const [customLinkModalOpen, setCustomLinkModalOpen] = useState(false);
const [customLinkLabel, setCustomLinkLabel] = useState('');
const [customLinkPath, setCustomLinkPath] = useState('');
const [customLinkIcon, setCustomLinkIcon] = useState('');
const [customLinkNewTab, setCustomLinkNewTab] = useState(false);
// Build tab state
const [docsStatus, setDocsStatus] = useState<DocsStatus | null>(null);
@ -316,11 +301,10 @@ export default function MkDocsSettingsPage() {
setLoading(true);
setError(false);
try {
const [configRes, filesRes, campaignsRes, headerRes] = await Promise.all([
const [configRes, filesRes, campaignsRes] = await Promise.all([
api.get<MkDocsConfigResponse>('/docs/mkdocs-config'),
api.get<FileNode[]>('/docs/files'),
api.get<Campaign[]>('/campaigns/public').catch(() => ({ data: [] as Campaign[] })),
api.get<HeaderConfig>('/docs/header-config').catch(() => null),
]);
const content = configRes.data.content;
setRawYaml(content);
@ -328,9 +312,6 @@ export default function MkDocsSettingsPage() {
setEditorYaml(content);
setFileTree(filesRes.data);
setCampaigns(campaignsRes.data);
if (headerRes?.data) {
setHeaderConfig(headerRes.data);
}
// Parse for settings tab
syncSettingsFromYaml(content);
@ -399,17 +380,6 @@ export default function MkDocsSettingsPage() {
return () => setPageHeader(null);
}, [setPageHeader]);
// Load Material Icons for header preview
useEffect(() => {
const id = 'material-icons-css';
if (!document.getElementById(id)) {
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
document.head.appendChild(link);
}
}, []);
// --- Settings Tab: Save ---
@ -533,114 +503,6 @@ export default function MkDocsSettingsPage() {
return () => window.removeEventListener('keydown', handler);
}, [editorDirty, saveEditor]);
// --- Header Tab ---
const updateHeaderConfig = useCallback((updater: (prev: HeaderConfig) => HeaderConfig) => {
setHeaderConfig((prev) => {
if (!prev) return prev;
return updater(prev);
});
setHeaderDirty(true);
}, []);
const toggleHeaderItem = useCallback((itemId: string, enabled: boolean) => {
updateHeaderConfig((prev) => ({
...prev,
items: prev.items.map((item) =>
item.id === itemId ? { ...item, enabled } : item
),
}));
}, [updateHeaderConfig]);
const updateHeaderItemField = useCallback((itemId: string, field: keyof HeaderNavItem, value: unknown) => {
updateHeaderConfig((prev) => ({
...prev,
items: prev.items.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}));
}, [updateHeaderConfig]);
const moveHeaderItem = useCallback((itemId: string, direction: 'up' | 'down') => {
updateHeaderConfig((prev) => {
const items = [...prev.items];
const idx = items.findIndex((i) => i.id === itemId);
if (idx < 0) return prev;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= items.length) return prev;
// Swap order values
const tempOrder = items[idx]!.order;
items[idx] = { ...items[idx]!, order: items[swapIdx]!.order };
items[swapIdx] = { ...items[swapIdx]!, order: tempOrder };
// Sort by order
items.sort((a, b) => a.order - b.order);
return { ...prev, items };
});
}, [updateHeaderConfig]);
const deleteHeaderItem = useCallback((itemId: string) => {
updateHeaderConfig((prev) => ({
...prev,
items: prev.items.filter((item) => item.id !== itemId),
}));
}, [updateHeaderConfig]);
const addCustomLink = useCallback(() => {
if (!customLinkLabel.trim() || !customLinkPath.trim()) return;
updateHeaderConfig((prev) => {
const maxOrder = prev.items.reduce((max, item) => Math.max(max, item.order), -1);
return {
...prev,
items: [
...prev.items,
{
id: `custom-${Date.now()}`,
label: customLinkLabel.trim(),
path: customLinkPath.trim(),
icon: customLinkIcon.trim() || undefined,
enabled: true,
order: maxOrder + 1,
type: 'custom' as const,
openInNewTab: customLinkNewTab,
},
],
};
});
setCustomLinkLabel('');
setCustomLinkPath('');
setCustomLinkIcon('');
setCustomLinkNewTab(false);
setCustomLinkModalOpen(false);
}, [customLinkLabel, customLinkPath, customLinkIcon, customLinkNewTab, updateHeaderConfig]);
const saveHeaderConfig = useCallback(async () => {
if (!isSuperAdmin || !headerConfig) return;
setHeaderSaving(true);
try {
await api.put('/docs/header-config', headerConfig);
setHeaderDirty(false);
messageApi.success('Header config saved & template generated');
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Failed to save header config';
messageApi.error(msg);
} finally {
setHeaderSaving(false);
}
}, [isSuperAdmin, headerConfig, messageApi]);
const resetHeaderConfig = useCallback(async () => {
setHeaderLoading(true);
try {
const res = await api.get<HeaderConfig>('/docs/header-config');
setHeaderConfig(res.data);
setHeaderDirty(false);
} catch {
messageApi.error('Failed to reload header config');
} finally {
setHeaderLoading(false);
}
}, [messageApi]);
// --- Build Tab ---
const checkStatus = useCallback(async () => {
@ -1397,316 +1259,22 @@ export default function MkDocsSettingsPage() {
},
{
key: 'header',
label: (
<span>
Header
{headerDirty && <Badge status="warning" style={{ marginLeft: 8 }} />}
</span>
),
children: headerConfig ? (
<div style={{ maxWidth: 900 }}>
{/* Master Toggle */}
<Card size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text strong style={{ fontSize: 15 }}>Enable Header Navigation Bar</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Adds a navigation bar above the docs header linking to your app features
</Text>
</div>
<Switch
checked={headerConfig.enabled}
onChange={(checked) => updateHeaderConfig((prev) => ({ ...prev, enabled: checked }))}
disabled={!isSuperAdmin}
/>
</div>
</Card>
{headerConfig.enabled && (
<>
{/* Style */}
<Card title="Style" size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 16 }}>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Background</Text>
<ColorPicker
value={headerConfig.style.backgroundColor}
onChange={(_, hex) => updateHeaderConfig((prev) => ({
...prev,
style: { ...prev.style, backgroundColor: hex },
}))}
showText
disabled={!isSuperAdmin}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Text</Text>
<ColorPicker
value={headerConfig.style.textColor}
onChange={(_, hex) => updateHeaderConfig((prev) => ({
...prev,
style: { ...prev.style, textColor: hex },
}))}
showText
disabled={!isSuperAdmin}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Hover</Text>
<Input
value={headerConfig.style.hoverColor}
onChange={(e) => updateHeaderConfig((prev) => ({
...prev,
style: { ...prev.style, hoverColor: e.target.value },
}))}
size="small"
disabled={!isSuperAdmin}
style={{ width: 180 }}
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>Height</Text>
<Input
value={headerConfig.style.height}
onChange={(e) => updateHeaderConfig((prev) => ({
...prev,
style: { ...prev.style, height: e.target.value },
}))}
size="small"
disabled={!isSuperAdmin}
placeholder="40px"
style={{ width: 80 }}
/>
</div>
</div>
</Card>
{/* Navigation Items */}
<Card
title="Navigation Items"
size="small"
style={{ marginBottom: 16 }}
extra={
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setCustomLinkModalOpen(true)}
disabled={!isSuperAdmin}
>
Add Custom Link
</Button>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[...headerConfig.items].sort((a, b) => a.order - b.order).map((item, idx) => (
<div
key={item.id}
style={{
display: 'grid',
gridTemplateColumns: '40px 1fr 1.5fr 120px 80px',
gap: 8,
alignItems: 'center',
padding: '8px 12px',
background: item.enabled ? token.colorBgContainer : token.colorBgLayout,
borderRadius: 6,
border: `1px solid ${token.colorBorderSecondary}`,
opacity: item.enabled ? 1 : 0.6,
}}
>
<Switch
size="small"
checked={item.enabled}
onChange={(checked) => toggleHeaderItem(item.id, checked)}
disabled={!isSuperAdmin}
/>
<Input
size="small"
value={item.label}
onChange={(e) => updateHeaderItemField(item.id, 'label', e.target.value)}
disabled={!isSuperAdmin}
prefix={item.icon ? <span className="material-icons" style={{ fontSize: 16, color: token.colorTextSecondary }}>{item.icon}</span> : undefined}
/>
<Input
size="small"
value={item.path}
onChange={(e) => updateHeaderItemField(item.id, 'path', e.target.value)}
disabled={!isSuperAdmin}
placeholder="/path or https://..."
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
<Space size={2}>
<Tooltip title="Move up">
<Button
type="text"
size="small"
icon={<ArrowUpOutlined />}
onClick={() => moveHeaderItem(item.id, 'up')}
disabled={!isSuperAdmin || idx === 0}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
<Tooltip title="Move down">
<Button
type="text"
size="small"
icon={<ArrowDownOutlined />}
onClick={() => moveHeaderItem(item.id, 'down')}
disabled={!isSuperAdmin || idx === headerConfig.items.length - 1}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
{item.type === 'custom' && (
<Tooltip title="Delete">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => deleteHeaderItem(item.id)}
disabled={!isSuperAdmin}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
)}
</Space>
<div style={{ textAlign: 'right' }}>
<Tag color={item.type === 'builtin' ? 'blue' : 'purple'} style={{ margin: 0, fontSize: 11 }}>
{item.type}
</Tag>
</div>
</div>
))}
</div>
</Card>
{/* Preview */}
<Card title="Preview" size="small" style={{ marginBottom: 16 }}>
<div
style={{
background: headerConfig.style.backgroundColor,
minHeight: headerConfig.style.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 6,
padding: '6px 24px',
gap: 6,
overflow: 'hidden',
}}
>
{headerConfig.items
.filter((i) => i.enabled)
.sort((a, b) => a.order - b.order)
.map((item) => (
<span
key={item.id}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 16px',
color: headerConfig.style.textColor,
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.02em',
borderRadius: 6,
background: 'rgba(255, 255, 255, 0.12)',
cursor: 'default',
whiteSpace: 'nowrap',
}}
>
{item.icon && (
<span className="material-icons" style={{ fontSize: 16, color: headerConfig.style.textColor, opacity: 0.9 }}>
{item.icon}
</span>
)}
{item.label}
</span>
))}
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 8 }}>
On mobile (&lt;768px), labels are hidden only icons are shown. Dismiss (X) button is always hidden.</Text>
</Card>
</>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '16px 0' }}>
<Popconfirm
title="Reset to defaults?"
description="This will discard unsaved changes and reload the config from disk."
onConfirm={resetHeaderConfig}
okText="Reset"
cancelText="Cancel"
>
label: 'Header',
children: (
<div style={{ maxWidth: 600, padding: '24px 0' }}>
<Result
status="info"
title="Navigation Moved"
subTitle="The app-wide navigation bar is now configured in Settings > Navigation. The MkDocs header is automatically generated from the centralized navigation configuration."
extra={
<Button
icon={<UndoOutlined />}
disabled={!isSuperAdmin}
loading={headerLoading}
type="primary"
onClick={() => window.location.href = '/app/settings?activeTab=navigation'}
>
Reset
Go to Settings &gt; Navigation
</Button>
</Popconfirm>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={saveHeaderConfig}
loading={headerSaving}
disabled={!headerDirty || !isSuperAdmin}
>
Save & Generate
</Button>
</div>
{/* Custom Link Modal */}
<Modal
title="Add Custom Link"
open={customLinkModalOpen}
onOk={addCustomLink}
onCancel={() => setCustomLinkModalOpen(false)}
okText="Add"
okButtonProps={{ disabled: !customLinkLabel.trim() || !customLinkPath.trim() }}
destroyOnHidden
>
<Form layout="vertical" size="small">
<Form.Item label="Label" required>
<Input
value={customLinkLabel}
onChange={(e) => setCustomLinkLabel(e.target.value)}
placeholder="e.g. Blog"
autoFocus
/>
</Form.Item>
<Form.Item label="Path or URL" required>
<Input
value={customLinkPath}
onChange={(e) => setCustomLinkPath(e.target.value)}
placeholder="e.g. /blog or https://example.com"
/>
</Form.Item>
<Form.Item label="Icon (Material Icons name)">
<Input
value={customLinkIcon}
onChange={(e) => setCustomLinkIcon(e.target.value)}
placeholder="e.g. article, open_in_new"
/>
</Form.Item>
<Form.Item>
<Switch
checked={customLinkNewTab}
onChange={setCustomLinkNewTab}
size="small"
/>
<Text style={{ marginLeft: 8, fontSize: 13 }}>Open in new tab</Text>
</Form.Item>
</Form>
</Modal>
</div>
) : (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>Loading header config...</Text>
}
/>
</div>
),
},

View File

@ -0,0 +1,319 @@
import { useEffect, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Input,
Switch,
Button,
Alert,
Space,
Tag,
Modal,
Tooltip,
message,
Spin,
Form,
} from 'antd';
import {
SaveOutlined,
HomeOutlined,
EnvironmentOutlined,
CalendarOutlined,
ScheduleOutlined,
PlayCircleOutlined,
HeartOutlined,
DollarOutlined,
ShoppingOutlined,
LinkOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
DeleteOutlined,
PlusOutlined,
GlobalOutlined,
BookOutlined,
SendOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import type { AppOutletContext } from '@/components/AppLayout';
import type { NavItem } from '@/types/api';
const NAV_ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
SendOutlined: <SendOutlined />,
EnvironmentOutlined: <EnvironmentOutlined />,
CalendarOutlined: <CalendarOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
HeartOutlined: <HeartOutlined />,
DollarOutlined: <DollarOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
LinkOutlined: <LinkOutlined />,
GlobalOutlined: <GlobalOutlined />,
BookOutlined: <BookOutlined />,
};
const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true },
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Events', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents', external: true },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true },
{ id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true },
];
export default function NavigationSettingsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();
const [navItems, setNavItems] = useState<NavItem[]>(DEFAULT_NAV_ITEMS);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [customLinkModalOpen, setCustomLinkModalOpen] = useState(false);
const [customLinkLabel, setCustomLinkLabel] = useState('');
const [customLinkPath, setCustomLinkPath] = useState('');
useEffect(() => {
setPageHeader({ title: 'Navigation' });
return () => setPageHeader(null);
}, [setPageHeader]);
useEffect(() => {
fetchAdminSettings();
}, [fetchAdminSettings]);
useEffect(() => {
if (settings?.navConfig?.items) {
// Merge missing builtin defaults and sync icons so code-level changes propagate
const stored = settings.navConfig.items;
const defaultMap = new Map(DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d]));
const synced = stored.map((item: NavItem) => {
const def = defaultMap.get(item.id);
return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item;
});
const ids = new Set(synced.map((i: NavItem) => i.id));
const missing = DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id));
setNavItems(missing.length > 0 ? [...synced, ...missing] : synced);
}
}, [settings]);
const handleSave = async () => {
setSaving(true);
try {
await updateSettings({ navConfig: { items: navItems } });
setDirty(false);
message.success('Navigation saved');
} catch {
message.error('Failed to save navigation');
} finally {
setSaving(false);
}
};
const toggleNavItem = (itemId: string, enabled: boolean) => {
setNavItems(prev => prev.map(item => item.id === itemId ? { ...item, enabled } : item));
setDirty(true);
};
const moveNavItem = (itemId: string, direction: 'up' | 'down') => {
setNavItems(prev => {
const items = [...prev].sort((a, b) => a.order - b.order);
const idx = items.findIndex(i => i.id === itemId);
if (idx < 0) return prev;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= items.length) return prev;
const tempOrder = items[idx]!.order;
items[idx] = { ...items[idx]!, order: items[swapIdx]!.order };
items[swapIdx] = { ...items[swapIdx]!, order: tempOrder };
items.sort((a, b) => a.order - b.order);
return items;
});
setDirty(true);
};
const updateNavItemField = (itemId: string, field: 'label' | 'path', value: string) => {
setNavItems(prev => prev.map(item => item.id === itemId ? { ...item, [field]: value } : item));
setDirty(true);
};
const deleteNavItem = (itemId: string) => {
setNavItems(prev => prev.filter(item => item.id !== itemId));
setDirty(true);
};
const addCustomNavLink = () => {
if (!customLinkLabel.trim() || !customLinkPath.trim()) return;
const maxOrder = navItems.reduce((max, item) => Math.max(max, item.order), -1);
setNavItems(prev => [...prev, {
id: `custom-${Date.now()}`,
label: customLinkLabel.trim(),
path: customLinkPath.trim(),
icon: 'LinkOutlined',
enabled: true,
order: maxOrder + 1,
type: 'custom',
external: customLinkPath.trim().startsWith('http'),
}]);
setCustomLinkLabel('');
setCustomLinkPath('');
setCustomLinkModalOpen(false);
setDirty(true);
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ maxWidth: 700 }}>
<Alert
type="info"
message="Configure the navigation bar shown on all public pages, the admin header, Gancio events page, and MkDocs site."
showIcon
style={{ marginBottom: 24 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{[...navItems].sort((a, b) => a.order - b.order).map((item, idx) => {
const sorted = [...navItems].sort((a, b) => a.order - b.order);
return (
<div
key={item.id}
style={{
display: 'grid',
gridTemplateColumns: '40px 32px 1fr 1.5fr auto 90px',
gap: 8,
alignItems: 'center',
padding: '8px 12px',
background: item.enabled ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.01)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
opacity: item.enabled ? 1 : 0.5,
}}
>
<Switch
size="small"
checked={item.enabled}
onChange={(checked) => toggleNavItem(item.id, checked)}
/>
<span style={{ fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{NAV_ICON_MAP[item.icon] || <LinkOutlined />}
</span>
<Input
size="small"
value={item.label}
onChange={(e) => updateNavItemField(item.id, 'label', e.target.value)}
/>
<Tooltip title={item.path.startsWith('$') ? 'Auto-resolved based on environment' : undefined}>
<Input
size="small"
value={item.path}
onChange={(e) => updateNavItemField(item.id, 'path', e.target.value)}
disabled={item.path.startsWith('$')}
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Tooltip>
<Space size={2}>
<Tooltip title="Move up">
<Button
type="text"
size="small"
icon={<ArrowUpOutlined />}
onClick={() => moveNavItem(item.id, 'up')}
disabled={idx === 0}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
<Tooltip title="Move down">
<Button
type="text"
size="small"
icon={<ArrowDownOutlined />}
onClick={() => moveNavItem(item.id, 'down')}
disabled={idx === sorted.length - 1}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
{item.type === 'custom' && (
<Tooltip title="Delete">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => deleteNavItem(item.id)}
style={{ width: 24, height: 24 }}
/>
</Tooltip>
)}
</Space>
<div style={{ textAlign: 'right' }}>
{item.featureFlag ? (
<Tooltip title={`Controlled by ${item.featureFlag}`}>
<Tag color="cyan" style={{ margin: 0, fontSize: 10 }}>
{item.featureFlag.replace('enable', '')}
</Tag>
</Tooltip>
) : (
<Tag color={item.type === 'builtin' ? 'blue' : 'purple'} style={{ margin: 0, fontSize: 10 }}>
{item.type}
</Tag>
)}
</div>
</div>
);
})}
</div>
<Space>
<Button
icon={<PlusOutlined />}
onClick={() => setCustomLinkModalOpen(true)}
>
Add Custom Link
</Button>
</Space>
<div style={{ marginTop: 24 }}>
<Button type="primary" icon={<SaveOutlined />} size="large" onClick={handleSave} loading={saving} disabled={!dirty}>
Save Navigation
</Button>
</div>
<Modal
title="Add Custom Link"
open={customLinkModalOpen}
onOk={addCustomNavLink}
onCancel={() => setCustomLinkModalOpen(false)}
okText="Add"
okButtonProps={{ disabled: !customLinkLabel.trim() || !customLinkPath.trim() }}
destroyOnHidden
>
<Form layout="vertical" size="small">
<Form.Item label="Label" required>
<Input
value={customLinkLabel}
onChange={(e) => setCustomLinkLabel(e.target.value)}
placeholder="e.g. Blog"
autoFocus
/>
</Form.Item>
<Form.Item label="Path or URL" required>
<Input
value={customLinkPath}
onChange={(e) => setCustomLinkPath(e.target.value)}
placeholder="e.g. /blog or https://example.com"
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}

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