Okay Wish I could say I know exactly. Will do better next time promise lol
This commit is contained in:
parent
2fa50b001c
commit
9e51aac570
48
.env.example
48
.env.example
@ -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
14
.mcp.json
Normal 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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
CLAUDE.md
17
CLAUDE.md
@ -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 18–24 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
156
SOCIAL_SYSTEM_PLAN.md
Normal 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
|
||||
@ -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
196
admin/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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={
|
||||
|
||||
89
admin/src/components/AdBanner.tsx
Normal file
89
admin/src/components/AdBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;">📢</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;">🔀</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>`;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)}
|
||||
|
||||
511
admin/src/components/PublicNavBar.tsx
Normal file
511
admin/src/components/PublicNavBar.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
208
admin/src/components/PublicSearchModal.tsx
Normal file
208
admin/src/components/PublicSearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
236
admin/src/components/calendar/EventSubmissionForm.tsx
Normal file
236
admin/src/components/calendar/EventSubmissionForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
431
admin/src/components/calendar/UnifiedCalendar.tsx
Normal file
431
admin/src/components/calendar/UnifiedCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
admin/src/components/chat/ChatPanel.tsx
Normal file
210
admin/src/components/chat/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
admin/src/components/chat/ChatPanelTray.tsx
Normal file
115
admin/src/components/chat/ChatPanelTray.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
170
admin/src/components/chat/ChatWidgetFAB.tsx
Normal file
170
admin/src/components/chat/ChatWidgetFAB.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
admin/src/components/chat/RocketChatWidget.tsx
Normal file
26
admin/src/components/chat/RocketChatWidget.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
510
admin/src/components/command-palette/CommandPalette.tsx
Normal file
510
admin/src/components/command-palette/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
773
admin/src/components/command-palette/registry.ts
Normal file
773
admin/src/components/command-palette/registry.ts
Normal 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 },
|
||||
},
|
||||
];
|
||||
33
admin/src/components/command-palette/types.ts
Normal file
33
admin/src/components/command-palette/types.ts
Normal 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;
|
||||
}
|
||||
81
admin/src/components/command-palette/useCommandIndex.ts
Normal file
81
admin/src/components/command-palette/useCommandIndex.ts
Normal 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 };
|
||||
}
|
||||
216
admin/src/components/command-palette/useEntitySearch.ts
Normal file
216
admin/src/components/command-palette/useEntitySearch.ts
Normal 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 };
|
||||
}
|
||||
@ -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 }} /> },
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
95
admin/src/components/dashboard/GiteaActivityCard.tsx
Normal file
95
admin/src/components/dashboard/GiteaActivityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
96
admin/src/components/dashboard/RocketChatStatsCard.tsx
Normal file
96
admin/src/components/dashboard/RocketChatStatsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
admin/src/components/dashboard/UpcomingMeetingsCard.tsx
Normal file
149
admin/src/components/dashboard/UpcomingMeetingsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
95
admin/src/components/dashboard/VaultwardenAdoptionCard.tsx
Normal file
95
admin/src/components/dashboard/VaultwardenAdoptionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
admin/src/components/map/EventLayerToggle.tsx
Normal file
57
admin/src/components/map/EventLayerToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
admin/src/components/map/EventMarkers.tsx
Normal file
116
admin/src/components/map/EventMarkers.tsx
Normal 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);
|
||||
">📅</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 →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
136
admin/src/components/media/AdPickerModal.tsx
Normal file
136
admin/src/components/media/AdPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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={{
|
||||
|
||||
449
admin/src/components/media/PhotoInsertModal.tsx
Normal file
449
admin/src/components/media/PhotoInsertModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
399
admin/src/components/media/PhotoPickerModal.tsx
Normal file
399
admin/src/components/media/PhotoPickerModal.tsx
Normal 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;
|
||||
176
admin/src/components/people/ConnectionForm.tsx
Normal file
176
admin/src/components/people/ConnectionForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
admin/src/components/people/ConnectionGraph.tsx
Normal file
354
admin/src/components/people/ConnectionGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
admin/src/components/people/ContactAddressPanel.tsx
Normal file
243
admin/src/components/people/ContactAddressPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
admin/src/components/people/ContactEmailPanel.tsx
Normal file
248
admin/src/components/people/ContactEmailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
admin/src/components/people/ContactPhonePanel.tsx
Normal file
248
admin/src/components/people/ContactPhonePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
admin/src/components/people/CreateUserFromContactModal.tsx
Normal file
139
admin/src/components/people/CreateUserFromContactModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
admin/src/components/people/DuplicateDetectionDrawer.tsx
Normal file
218
admin/src/components/people/DuplicateDetectionDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
admin/src/components/people/EngagementSummaryPanel.tsx
Normal file
189
admin/src/components/people/EngagementSummaryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
admin/src/components/people/HouseholdPanel.tsx
Normal file
163
admin/src/components/people/HouseholdPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
admin/src/components/people/MergeContactModal.tsx
Normal file
306
admin/src/components/people/MergeContactModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
admin/src/components/people/PersonActivityTimeline.tsx
Normal file
182
admin/src/components/people/PersonActivityTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
admin/src/components/people/PersonCard.tsx
Normal file
152
admin/src/components/people/PersonCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
903
admin/src/components/people/PersonDetailDrawer.tsx
Normal file
903
admin/src/components/people/PersonDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
504
admin/src/components/people/ProfileLinkActions.tsx
Normal file
504
admin/src/components/people/ProfileLinkActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
admin/src/components/people/ProfilePreviewModal.tsx
Normal file
183
admin/src/components/people/ProfilePreviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
443
admin/src/components/people/TagManagementModal.tsx
Normal file
443
admin/src/components/people/TagManagementModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
admin/src/components/people/UserAccountStatusPanel.tsx
Normal file
82
admin/src/components/people/UserAccountStatusPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
admin/src/components/people/VideoCallModal.tsx
Normal file
134
admin/src/components/people/VideoCallModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
admin/src/components/public/ActivityFeed.tsx
Normal file
111
admin/src/components/public/ActivityFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
admin/src/components/public/HomepageSection.tsx
Normal file
31
admin/src/components/public/HomepageSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
admin/src/components/public/NewsletterSignup.tsx
Normal file
68
admin/src/components/public/NewsletterSignup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
admin/src/components/public/RelatedContent.tsx
Normal file
115
admin/src/components/public/RelatedContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
admin/src/components/public/ShiftSignupModal.tsx
Normal file
215
admin/src/components/public/ShiftSignupModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
admin/src/components/social/FeedCard.tsx
Normal file
98
admin/src/components/social/FeedCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
admin/src/components/social/FriendButton.tsx
Normal file
142
admin/src/components/social/FriendButton.tsx
Normal 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;
|
||||
}
|
||||
117
admin/src/components/social/FriendSuggestions.tsx
Normal file
117
admin/src/components/social/FriendSuggestions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
admin/src/components/social/FriendsAttendingBadge.tsx
Normal file
62
admin/src/components/social/FriendsAttendingBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
admin/src/components/social/FriendsCampaignBadge.tsx
Normal file
62
admin/src/components/social/FriendsCampaignBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
admin/src/components/social/FriendsOnMap.tsx
Normal file
121
admin/src/components/social/FriendsOnMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
admin/src/components/social/GroupCard.tsx
Normal file
55
admin/src/components/social/GroupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
admin/src/components/social/MessageButton.tsx
Normal file
55
admin/src/components/social/MessageButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
admin/src/components/social/NotificationBell.tsx
Normal file
138
admin/src/components/social/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
admin/src/components/social/OnlineIndicator.tsx
Normal file
29
admin/src/components/social/OnlineIndicator.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
admin/src/components/social/PokeButton.tsx
Normal file
73
admin/src/components/social/PokeButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
admin/src/components/social/RecommendVideoModal.tsx
Normal file
169
admin/src/components/social/RecommendVideoModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
admin/src/components/social/SocialNetworkGraph.tsx
Normal file
283
admin/src/components/social/SocialNetworkGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
admin/src/components/social/SocialUserNode.tsx
Normal file
86
admin/src/components/social/SocialUserNode.tsx
Normal 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);
|
||||
64
admin/src/components/social/UserAvatar.tsx
Normal file
64
admin/src/components/social/UserAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
admin/src/hooks/useDocumentTitle.ts
Normal file
14
admin/src/hooks/useDocumentTitle.ts
Normal 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]);
|
||||
}
|
||||
27
admin/src/hooks/usePageAds.ts
Normal file
27
admin/src/hooks/usePageAds.ts
Normal 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;
|
||||
}
|
||||
21
admin/src/hooks/usePostalCode.ts
Normal file
21
admin/src/hooks/usePostalCode.ts
Normal 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
126
admin/src/hooks/useSSE.ts
Normal 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]);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) ---
|
||||
|
||||
@ -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}>
|
||||
|
||||
441
admin/src/pages/DocsCommentsPage.tsx
Normal file
441
admin/src/pages/DocsCommentsPage.tsx
Normal 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 > Settings > 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 & 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 />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
90
admin/src/pages/JitsiAuthPage.tsx
Normal file
90
admin/src/pages/JitsiAuthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
292
admin/src/pages/JitsiMeetPage.tsx
Normal file
292
admin/src/pages/JitsiMeetPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 (<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 > 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>
|
||||
),
|
||||
},
|
||||
|
||||
319
admin/src/pages/NavigationSettingsPage.tsx
Normal file
319
admin/src/pages/NavigationSettingsPage.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user