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_ROOT_URL=https://git.cmlite.org
|
||||||
GITEA_DOMAIN=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 ---
|
||||||
N8N_URL=http://n8n-changemaker:5678
|
N8N_URL=http://n8n-changemaker:5678
|
||||||
N8N_PORT=5678
|
N8N_PORT=5678
|
||||||
@ -289,6 +303,40 @@ GANCIO_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
|||||||
# Enable automatic shift → Gancio event sync
|
# Enable automatic shift → Gancio event sync
|
||||||
GANCIO_SYNC_ENABLED=false
|
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) ---
|
# --- Monitoring (only used with --profile monitoring) ---
|
||||||
PROMETHEUS_PORT=9090
|
PROMETHEUS_PORT=9090
|
||||||
GRAFANA_PORT=3005
|
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
|
- ✅ Observability Dashboard
|
||||||
- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026)
|
- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026)
|
||||||
- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, 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
|
- 🚧 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
|
- **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
|
- **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
|
### 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)
|
- Fastify media API on port 4100, separate from Express on 4000 (same DB, different ORM)
|
||||||
- Volunteer page naming: `VolunteerShiftsPage.tsx` (not "MyAssignmentsPage")
|
- Volunteer page naming: `VolunteerShiftsPage.tsx` (not "MyAssignmentsPage")
|
||||||
- Tracking module: `api/src/modules/map/tracking/` (volunteer + admin routes)
|
- 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
|
### Database
|
||||||
- `api/prisma/schema.prisma` — Main schema (30+ Prisma models)
|
- `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/drizzle.config.ts` — Drizzle config for media tables
|
||||||
- `api/prisma/seed.ts` — Database seeding
|
- `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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<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>
|
</head>
|
||||||
<body style="margin:0;background:#1a1025">
|
<body style="margin:0;background:#1a1025">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
196
admin/package-lock.json
generated
196
admin/package-lock.json
generated
@ -10,10 +10,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.6.0",
|
"@ant-design/icons": "^5.6.0",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"antd": "^5.23.0",
|
"antd": "^5.23.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"grapesjs": "^0.22.14",
|
"grapesjs": "^0.22.14",
|
||||||
@ -424,6 +428,19 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emotion/hash": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
"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": {
|
"node_modules/@types/d3-ease": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
"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": {
|
"node_modules/@types/d3-interpolate": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
@ -1525,6 +1555,11 @@
|
|||||||
"@types/d3-time": "*"
|
"@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": {
|
"node_modules/@types/d3-shape": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
"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": {
|
"node_modules/@types/dompurify": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
"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"
|
"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": {
|
"node_modules/antd": {
|
||||||
"version": "5.29.3",
|
"version": "5.29.3",
|
||||||
"resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
|
"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": {
|
"node_modules/classnames": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
@ -1906,6 +2020,26 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-ease": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
@ -1914,6 +2048,19 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-format": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
@ -1941,6 +2088,14 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-scale": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
@ -1956,6 +2111,14 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-shape": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
@ -1997,6 +2160,39 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
|||||||
@ -11,10 +11,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.6.0",
|
"@ant-design/icons": "^5.6.0",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"antd": "^5.23.0",
|
"antd": "^5.23.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"grapesjs": "^0.22.14",
|
"grapesjs": "^0.22.14",
|
||||||
|
|||||||
@ -38,23 +38,32 @@ import ExcalidrawPage from '@/pages/ExcalidrawPage';
|
|||||||
import VaultwardenPage from '@/pages/VaultwardenPage';
|
import VaultwardenPage from '@/pages/VaultwardenPage';
|
||||||
import RocketChatPage from '@/pages/RocketChatPage';
|
import RocketChatPage from '@/pages/RocketChatPage';
|
||||||
import GancioPage from '@/pages/GancioPage';
|
import GancioPage from '@/pages/GancioPage';
|
||||||
|
import JitsiMeetPage from '@/pages/JitsiMeetPage';
|
||||||
import SettingsPage from '@/pages/SettingsPage';
|
import SettingsPage from '@/pages/SettingsPage';
|
||||||
|
import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
|
||||||
import PangolinPage from '@/pages/PangolinPage';
|
import PangolinPage from '@/pages/PangolinPage';
|
||||||
import ObservabilityPage from '@/pages/ObservabilityPage';
|
import ObservabilityPage from '@/pages/ObservabilityPage';
|
||||||
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
|
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
|
||||||
|
import DocsCommentsPage from '@/pages/DocsCommentsPage';
|
||||||
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
||||||
import SubscribersPage from '@/pages/payments/SubscribersPage';
|
import SubscribersPage from '@/pages/payments/SubscribersPage';
|
||||||
import PaymentProductsPage from '@/pages/payments/ProductsPage';
|
import PaymentProductsPage from '@/pages/payments/ProductsPage';
|
||||||
import PaymentDonationsPage from '@/pages/payments/DonationsPage';
|
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 PaymentSettingsPage from '@/pages/payments/PaymentSettingsPage';
|
||||||
import LibraryPage from '@/pages/media/LibraryPage';
|
import LibraryPage from '@/pages/media/LibraryPage';
|
||||||
import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage';
|
import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage';
|
||||||
import MediaJobsPage from '@/pages/media/MediaJobsPage';
|
import MediaJobsPage from '@/pages/media/MediaJobsPage';
|
||||||
import CommentModerationPage from '@/pages/media/CommentModerationPage';
|
import CommentModerationPage from '@/pages/media/CommentModerationPage';
|
||||||
import GalleryAdsPage from '@/pages/media/GalleryAdsPage';
|
import GalleryAdsPage from '@/pages/media/GalleryAdsPage';
|
||||||
|
import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage';
|
||||||
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
|
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
|
||||||
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage';
|
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage';
|
||||||
import PublicLandingPage from '@/pages/public/LandingPage';
|
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 CampaignsListPage from '@/pages/public/CampaignsListPage';
|
||||||
import CampaignPage from '@/pages/public/CampaignPage';
|
import CampaignPage from '@/pages/public/CampaignPage';
|
||||||
import CreateCampaignPage from '@/pages/public/CreateCampaignPage';
|
import CreateCampaignPage from '@/pages/public/CreateCampaignPage';
|
||||||
@ -73,21 +82,44 @@ import MySettingsPage from '@/pages/public/MySettingsPage';
|
|||||||
import VolunteerChatPage from '@/pages/volunteer/VolunteerChatPage';
|
import VolunteerChatPage from '@/pages/volunteer/VolunteerChatPage';
|
||||||
import PricingPage from '@/pages/public/PricingPage';
|
import PricingPage from '@/pages/public/PricingPage';
|
||||||
import ShopPage from '@/pages/public/ShopPage';
|
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 DonatePage from '@/pages/public/DonatePage';
|
||||||
|
import DonationPagesListPage from '@/pages/public/DonationPagesListPage';
|
||||||
import PaymentSuccessPage from '@/pages/public/PaymentSuccessPage';
|
import PaymentSuccessPage from '@/pages/public/PaymentSuccessPage';
|
||||||
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
|
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
|
||||||
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
|
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
|
||||||
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
|
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
|
||||||
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
|
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
|
||||||
|
import 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 { ADMIN_ROLES } from '@/types/api';
|
||||||
import { isAdmin } from '@/utils/roles';
|
import { isAdmin } from '@/utils/roles';
|
||||||
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
||||||
import VerifyEmailPage from '@/pages/VerifyEmailPage';
|
import VerifyEmailPage from '@/pages/VerifyEmailPage';
|
||||||
import ResetPasswordPage from '@/pages/ResetPasswordPage';
|
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() {
|
function RoleAwareRedirect() {
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
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 />;
|
if (user && isAdmin(user)) return <Navigate to="/app" replace />;
|
||||||
return <Navigate to="/volunteer" replace />;
|
return <Navigate to="/volunteer" replace />;
|
||||||
}
|
}
|
||||||
@ -151,7 +183,13 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<AntApp>
|
<AntApp>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<CommandPalette />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public homepage */}
|
||||||
|
<Route path="/home" element={<PublicLayout />}>
|
||||||
|
<Route index element={<HomePage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Public pages (no auth, dark blue theme) — feature-gated */}
|
{/* Public pages (no auth, dark blue theme) — feature-gated */}
|
||||||
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<CampaignsListPage />} />
|
<Route index element={<CampaignsListPage />} />
|
||||||
@ -182,22 +220,41 @@ export default function App() {
|
|||||||
<Route index element={<PublicShiftsPage />} />
|
<Route index element={<PublicShiftsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/map" element={<FeatureGate feature="enableMap"><MapPage /></FeatureGate>} />
|
<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>} />
|
<Route path="/p/:slug" element={<FeatureGate feature="enableLandingPages"><PublicLandingPage /></FeatureGate>} />
|
||||||
|
|
||||||
{/* Public Payment pages (PublicLayout, dark blue theme) — feature-gated */}
|
{/* Public Payment pages (PublicLayout, dark blue theme) — feature-gated */}
|
||||||
<Route path="/pricing" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
|
<Route path="/pricing" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<PricingPage />} />
|
<Route index element={<PricingPage />} />
|
||||||
|
<Route path=":slug" element={<PlanDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/shop" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
|
<Route path="/shop" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<ShopPage />} />
|
<Route index element={<ShopPage />} />
|
||||||
|
<Route path=":slug" element={<ProductDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/donate" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
|
<Route path="/donate" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<DonatePage />} />
|
<Route index element={<DonationPagesListPage />} />
|
||||||
|
<Route path=":slug" element={<DonatePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/payments/success" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
|
<Route path="/payments/success" element={<FeatureGate feature="enablePayments"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<PaymentSuccessPage />} />
|
<Route index element={<PaymentSuccessPage />} />
|
||||||
</Route>
|
</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 */}
|
{/* Public Media Gallery (purple theme) — feature-gated */}
|
||||||
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<MediaGalleryPage />} />
|
<Route index element={<MediaGalleryPage />} />
|
||||||
@ -240,6 +297,14 @@ export default function App() {
|
|||||||
<Route path="/volunteer/activity" element={<MyActivityPage />} />
|
<Route path="/volunteer/activity" element={<MyActivityPage />} />
|
||||||
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
|
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
|
||||||
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
|
<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 path="/volunteer/chat" element={<VolunteerChatPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
@ -251,6 +316,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="/join" element={<QuickJoinPage />} />
|
<Route path="/join" element={<QuickJoinPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/jitsi-auth/:room" element={<JitsiAuthPage />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route
|
<Route
|
||||||
@ -262,6 +328,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route
|
||||||
|
path="people"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<PeoplePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="users"
|
path="users"
|
||||||
element={
|
element={
|
||||||
@ -270,6 +344,36 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</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
|
<Route
|
||||||
path="campaigns"
|
path="campaigns"
|
||||||
element={
|
element={
|
||||||
@ -366,6 +470,22 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="docs/comments"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<DocsCommentsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="navigation"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<NavigationSettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="code"
|
path="code"
|
||||||
element={
|
element={
|
||||||
@ -446,6 +566,55 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</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
|
<Route
|
||||||
path="settings"
|
path="settings"
|
||||||
element={
|
element={
|
||||||
@ -567,7 +736,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="media/gallery-ads"
|
path="payments/ads/analytics"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||||
|
<AdAnalyticsDashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="payments/ads"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||||
<GalleryAdsPage />
|
<GalleryAdsPage />
|
||||||
@ -582,6 +759,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="payments/plans"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||||
|
<PlansPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="payments/subscribers"
|
path="payments/subscribers"
|
||||||
element={
|
element={
|
||||||
@ -606,6 +791,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="payments/donation-pages"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||||
|
<DonationPagesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="payments/settings"
|
path="payments/settings"
|
||||||
element={
|
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,
|
HomeOutlined,
|
||||||
ScissorOutlined,
|
ScissorOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
|
ScheduleOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
NotificationOutlined,
|
NotificationOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
@ -42,6 +43,15 @@ import {
|
|||||||
CrownOutlined,
|
CrownOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
TagOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ContactsOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
ApartmentOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
StarFilled,
|
||||||
|
StarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -49,15 +59,97 @@ import { useAuthStore } from '@/stores/auth.store';
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { hasAnyRole } from '@/utils/roles';
|
import { hasAnyRole } from '@/utils/roles';
|
||||||
import type { PageHeaderConfig, AppOutletContext } from '@/types/api';
|
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
|
// Re-export for backward compatibility
|
||||||
export type { PageHeaderConfig, AppOutletContext };
|
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 { Header, Sider, Content } = Layout;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
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'] = [
|
const items: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
key: '/app',
|
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) {
|
if (settings?.enableInfluence !== false) {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'influence-submenu',
|
key: 'influence-submenu',
|
||||||
@ -73,9 +187,9 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
label: 'Advocacy',
|
label: 'Advocacy',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
|
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
|
||||||
{ key: '/app/campaign-moderation', icon: <FileTextOutlined />, label: 'Campaign Review' },
|
{ key: '/app/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/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/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' },
|
{ 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) {
|
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({
|
items.push({
|
||||||
key: 'broadcast-submenu',
|
key: 'broadcast-submenu',
|
||||||
icon: <NotificationOutlined />,
|
icon: <NotificationOutlined />,
|
||||||
label: 'Broadcast',
|
label: 'Broadcast',
|
||||||
children: [
|
children: broadcastChildren,
|
||||||
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
|
|
||||||
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,8 +227,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
if (settings?.enableLandingPages !== false) {
|
if (settings?.enableLandingPages !== false) {
|
||||||
webChildren.push({ key: '/app/pages', icon: <FileTextOutlined />, label: 'Landing Pages' });
|
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', icon: <BookOutlined />, label: 'Documentation' });
|
||||||
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
|
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/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
|
||||||
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
|
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
|
||||||
items.push({
|
items.push({
|
||||||
@ -118,7 +248,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
children: [
|
children: [
|
||||||
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
|
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
|
||||||
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
|
{ 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/cuts', icon: <ScissorOutlined />, label: 'Areas' },
|
||||||
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
||||||
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
|
{ 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/analytics', icon: <BarChartOutlined />, label: 'Analytics' },
|
||||||
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
|
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
|
||||||
{ key: '/app/media/moderation', icon: <MessageOutlined />, label: 'Moderation' },
|
{ 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' },
|
{ key: '/app/media/jobs', icon: <HistoryOutlined />, label: 'Processing Jobs' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -149,9 +278,12 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
label: 'Payments',
|
label: 'Payments',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/app/payments', icon: <DashboardOutlined />, label: 'Dashboard' },
|
{ 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/subscribers', icon: <CrownOutlined />, label: 'Subscribers' },
|
||||||
{ key: '/app/payments/products', icon: <ShoppingOutlined />, label: 'Products' },
|
{ 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' },
|
{ 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/gitea', icon: <BranchesOutlined />, label: 'Git' },
|
||||||
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
|
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
|
||||||
...(settings?.enableChat ? [{ key: '/app/services/rocketchat', icon: <MessageOutlined />, label: 'Team Chat' }] : []),
|
...(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/gancio', icon: <CalendarOutlined />, label: 'Events' },
|
||||||
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
|
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
|
||||||
]},
|
]},
|
||||||
@ -183,11 +316,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
}
|
}
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
{
|
|
||||||
key: '/app/users',
|
|
||||||
icon: <TeamOutlined />,
|
|
||||||
label: 'Users',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: '/app/settings',
|
key: '/app/settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
@ -210,11 +338,16 @@ export default function AppLayout() {
|
|||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
|
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(() => {
|
const fetchBadges = useCallback(() => {
|
||||||
api.get('/dashboard/summary').then(({ data }) => {
|
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(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -224,10 +357,37 @@ export default function AppLayout() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchBadges]);
|
}, [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 }) => {
|
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);
|
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 selectedKey = (() => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
// Exact match first
|
// Exact match first
|
||||||
if (path === '/app') return '/app';
|
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 = '';
|
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 || []) {
|
for (const item of menuItems || []) {
|
||||||
if (!item || !('key' in item)) continue;
|
if (!item || !('key' in item)) continue;
|
||||||
if ('children' in item && item.children) {
|
if ('children' in item && item.children) {
|
||||||
for (const child of item.children) {
|
for (const child of item.children) {
|
||||||
if (!child || !('key' in child)) continue;
|
if (!child) continue;
|
||||||
const k = child.key as string;
|
// Handle item groups (type: 'group') — check their nested children
|
||||||
if (path === k || path.startsWith(k + '/')) {
|
if ('type' in child && child.type === 'group' && 'children' in child && child.children) {
|
||||||
if (k.length > best.length) best = k;
|
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() || '';
|
const k = item.key?.toString() || '';
|
||||||
if (k.startsWith('/') && k !== '/app' && (path === k || path.startsWith(k + '/'))) {
|
if (k !== '/app') checkKey(k);
|
||||||
if (k.length > best.length) best = k;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return best || '/app';
|
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
|
// Derive which submenus should be open based on active route
|
||||||
const defaultOpenKeys = (() => {
|
const defaultOpenKeys = (() => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (const item of menuItems || []) {
|
for (const item of menuItems || []) {
|
||||||
if (!item || !('children' in item) || !item.children) continue;
|
if (!item || !('children' in item) || !item.children) continue;
|
||||||
|
let found = false;
|
||||||
for (const child of item.children) {
|
for (const child of item.children) {
|
||||||
if (!child || !('key' in child)) continue;
|
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;
|
const k = child.key as string;
|
||||||
if (path === k || path.startsWith(k + '/')) {
|
if (path === k || path.startsWith(k + '/')) {
|
||||||
keys.push(item.key as string);
|
keys.push(item.key as string);
|
||||||
break;
|
found = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -333,7 +521,7 @@ export default function AppLayout() {
|
|||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={selectedKeys}
|
||||||
defaultOpenKeys={defaultOpenKeys}
|
defaultOpenKeys={defaultOpenKeys}
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
@ -342,6 +530,12 @@ export default function AppLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<Drawer
|
<Drawer
|
||||||
@ -366,11 +560,11 @@ export default function AppLayout() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: '0 24px',
|
padding: '0 16px',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 2,
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -391,103 +585,70 @@ export default function AppLayout() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
<Tooltip title={navigator.platform?.toLowerCase().includes('mac') ? 'Search (⌘K)' : 'Search (Ctrl+K)'}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={() => useCommandPaletteStore.getState().open()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
{pageHeader?.actions}
|
{pageHeader?.actions}
|
||||||
<Tooltip title="Home (Static Site)">
|
{(() => {
|
||||||
|
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
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<HomeOutlined />}
|
size="small"
|
||||||
onClick={() => window.open(`//${window.location.hostname}:4004`, '_blank')}
|
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 && 'Home'}
|
{!isMobile && !collapsed && item.label}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{settings?.enableInfluence !== false && (
|
));
|
||||||
<Tooltip title="View Public Campaigns">
|
})()}
|
||||||
<Button
|
{/* Canvass button — always tied to enableMap, not in navConfig */}
|
||||||
type="text"
|
|
||||||
icon={<SoundOutlined />}
|
|
||||||
onClick={() => navigate('/campaigns')}
|
|
||||||
>
|
|
||||||
{!isMobile && 'Campaigns'}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{settings?.enableMap !== false && (
|
{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">
|
<Tooltip title="Switch to Volunteer Portal">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
size="small"
|
||||||
icon={<TeamOutlined />}
|
icon={<TeamOutlined />}
|
||||||
onClick={() => navigate('/volunteer')}
|
onClick={() => navigate('/volunteer')}
|
||||||
>
|
>
|
||||||
{!isMobile && 'Canvass'}
|
{!isMobile && !collapsed && 'Canvass'}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{settings?.enableMediaFeatures !== false && (
|
|
||||||
<Tooltip title="Open Gallery">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<PlaySquareOutlined />}
|
|
||||||
onClick={() => navigate('/gallery')}
|
|
||||||
>
|
|
||||||
{!isMobile && 'Gallery'}
|
|
||||||
</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">
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
<Button type="text" icon={<UserOutlined />}>
|
<Button type="text" icon={<UserOutlined />}>
|
||||||
{!isMobile && (
|
{!isMobile && !collapsed && (
|
||||||
<Text style={{ marginLeft: 8 }}>
|
<Text style={{ marginLeft: 8 }}>
|
||||||
{user?.name || user?.email || 'User'}
|
{user?.name || user?.email || 'User'}
|
||||||
</Text>
|
</Text>
|
||||||
@ -509,5 +670,7 @@ export default function AppLayout() {
|
|||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
<RocketChatWidget />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,10 +15,14 @@ const FEATURE_LABELS: Record<string, string> = {
|
|||||||
enableMediaFeatures: 'Media Library',
|
enableMediaFeatures: 'Media Library',
|
||||||
enablePayments: 'Payments',
|
enablePayments: 'Payments',
|
||||||
enableGalleryAds: 'Gallery Ads',
|
enableGalleryAds: 'Gallery Ads',
|
||||||
|
enablePeople: 'People CRM',
|
||||||
|
enableEvents: 'Events',
|
||||||
|
enableSocial: 'Social Connections',
|
||||||
|
enableMeet: 'Video Meetings',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FeatureGateProps {
|
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;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import styleGradientPlugin from 'grapesjs-style-gradient';
|
|||||||
import touchPlugin from 'grapesjs-touch';
|
import touchPlugin from 'grapesjs-touch';
|
||||||
import type { PageBlock } from '@/types/api';
|
import type { PageBlock } from '@/types/api';
|
||||||
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
|
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
|
||||||
|
import { generatePhotoCardHtml } from '@/utils/photoCardHtml';
|
||||||
|
|
||||||
interface GrapesJSEditorProps {
|
interface GrapesJSEditorProps {
|
||||||
initialData?: Record<string, unknown>;
|
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
|
// Register save command
|
||||||
editor.Commands.add('save-page', {
|
editor.Commands.add('save-page', {
|
||||||
run(ed: Editor) {
|
run(ed: Editor) {
|
||||||
@ -395,6 +411,144 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
|||||||
</div>
|
</div>
|
||||||
</section>`;
|
</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:
|
default:
|
||||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import ChatBar from '@/components/media/chatbar/ChatBar';
|
|||||||
import { useChatNotifications } from '@/hooks/useChatNotifications';
|
import { useChatNotifications } from '@/hooks/useChatNotifications';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { hexToRgba } from '@/utils/color';
|
import { hexToRgba } from '@/utils/color';
|
||||||
|
import PublicNavBar from '@/components/PublicNavBar';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@ -79,6 +80,8 @@ export default function MediaPublicLayout() {
|
|||||||
>
|
>
|
||||||
<ChatBarProvider>
|
<ChatBarProvider>
|
||||||
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
|
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
|
||||||
|
<PublicNavBar activePath="/gallery" />
|
||||||
|
|
||||||
{/* Desktop: Show sidebar, Mobile: Hide */}
|
{/* Desktop: Show sidebar, Mobile: Hide */}
|
||||||
{!isMobile && <MediaSidebar />}
|
{!isMobile && <MediaSidebar />}
|
||||||
|
|
||||||
@ -86,7 +89,7 @@ export default function MediaPublicLayout() {
|
|||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
marginLeft: mainContentMarginLeft,
|
marginLeft: mainContentMarginLeft,
|
||||||
minHeight: '100vh',
|
minHeight: 'calc(100vh - 56px)',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
paddingBottom: 'calc(48px + env(safe-area-inset-bottom, 0px))', // Space for bottom search bar + iOS safe area
|
paddingBottom: 'calc(48px + env(safe-area-inset-bottom, 0px))', // Space for bottom search bar + iOS safe area
|
||||||
transition: 'margin-left 0.3s ease',
|
transition: 'margin-left 0.3s ease',
|
||||||
|
|||||||
@ -1,126 +1,58 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { ConfigProvider, Layout, Typography, theme, Space, Grid, Drawer, Button } from 'antd';
|
import { ConfigProvider, Layout, theme } from 'antd';
|
||||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
import { PlayCircleOutlined, LoginOutlined, LogoutOutlined, HeartOutlined, EnvironmentOutlined, CalendarOutlined, MenuOutlined, CloseOutlined, SendOutlined, HomeOutlined, TeamOutlined, AppstoreOutlined } from '@ant-design/icons';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
|
||||||
import AuthModal from '@/components/AuthModal';
|
import AuthModal from '@/components/AuthModal';
|
||||||
|
import PublicNavBar from '@/components/PublicNavBar';
|
||||||
|
import NewsletterSignup from '@/components/public/NewsletterSignup';
|
||||||
|
|
||||||
const { Header, Content, Footer } = Layout;
|
const { 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicLayout() {
|
export default function PublicLayout() {
|
||||||
const { settings } = useSettingsStore();
|
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 navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
const [authModalContext, setAuthModalContext] = useState<'generic' | 'campaign'>('generic');
|
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 colorPrimary = settings?.publicColorPrimary ?? '#3498db';
|
||||||
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
||||||
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
|
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 footerText = settings?.footerText ?? 'Powered by Changemaker Lite';
|
||||||
const logoUrl = settings?.organizationLogoUrl;
|
|
||||||
|
|
||||||
// Resolve Gancio URL — subdomain in production, direct port in dev
|
// Build footer links from navConfig (or defaults)
|
||||||
const gancioUrl = (() => {
|
const footerLinks = useMemo(() => {
|
||||||
const host = window.location.hostname;
|
const items = settings?.navConfig?.items;
|
||||||
if (host !== 'localhost' && host.includes('.')) {
|
if (!items) {
|
||||||
const protocol = window.location.protocol;
|
// Legacy fallback: hardcoded links
|
||||||
const baseDomain = host.split('.').slice(-2).join('.');
|
const links: { label: string; path: string; external?: boolean }[] = [];
|
||||||
return `${protocol}//events.${baseDomain}`;
|
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' });
|
||||||
}
|
}
|
||||||
return `http://localhost:8092`;
|
if (settings?.enableMediaFeatures !== false) links.push({ label: 'Gallery', path: '/gallery' });
|
||||||
})();
|
if (settings?.enablePayments) links.push({ label: 'Donate', path: '/donate' });
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Dynamic document title + favicon for public pages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -154,82 +86,10 @@ export default function PublicLayout() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
|
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
|
||||||
<Header
|
<PublicNavBar
|
||||||
style={{
|
showAuth
|
||||||
background: headerGradient,
|
onSignInClick={() => { setAuthModalContext('generic'); setAuthModalOpen(true); }}
|
||||||
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>
|
|
||||||
<Content
|
<Content
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 960,
|
maxWidth: 960,
|
||||||
@ -249,155 +109,26 @@ export default function PublicLayout() {
|
|||||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<NewsletterSignup />
|
||||||
<div>{footerText}</div>
|
<div>{footerText}</div>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
{settings?.enableInfluence !== false && (
|
{footerLinks.map((link, idx) => (
|
||||||
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Campaigns</Link>
|
<span key={link.path}>
|
||||||
)}
|
{idx > 0 && ' \u2022 '}
|
||||||
{settings?.enableMap !== false && (
|
{link.external ? (
|
||||||
<>
|
<a href={link.path} target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
||||||
{' • '}
|
{link.label}
|
||||||
<Link to="/map" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Map</Link>
|
</a>
|
||||||
{' • '}
|
) : (
|
||||||
<Link to="/shifts" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Shifts</Link>
|
<Link to={link.path} style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
||||||
</>
|
{link.label}
|
||||||
)}
|
</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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</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
|
<AuthModal
|
||||||
open={authModalOpen}
|
open={authModalOpen}
|
||||||
onCancel={() => setAuthModalOpen(false)}
|
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 { theme } from 'antd';
|
||||||
import {
|
import {
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
CalendarOutlined,
|
ScheduleOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
NodeIndexOutlined,
|
NodeIndexOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
|
TeamOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
const BASE_NAV_ITEMS = [
|
const BASE_NAV_ITEMS = [
|
||||||
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' },
|
{ 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/activity', icon: HistoryOutlined, label: 'Activity' },
|
||||||
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },
|
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },
|
||||||
];
|
];
|
||||||
@ -32,11 +33,14 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
|
|||||||
|
|
||||||
const NAV_ITEMS = useMemo(() => {
|
const NAV_ITEMS = useMemo(() => {
|
||||||
const items = [...BASE_NAV_ITEMS];
|
const items = [...BASE_NAV_ITEMS];
|
||||||
|
if (settings?.enableSocial) {
|
||||||
|
items.push({ key: '/volunteer/feed', icon: TeamOutlined, label: 'Social' });
|
||||||
|
}
|
||||||
if (settings?.enableChat) {
|
if (settings?.enableChat) {
|
||||||
items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' });
|
items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' });
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [settings?.enableChat]);
|
}, [settings?.enableChat, settings?.enableSocial]);
|
||||||
|
|
||||||
const activeKey = (() => {
|
const activeKey = (() => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import type { MenuProps } from 'antd';
|
|||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
|
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;
|
const { Header, Content, Footer } = Layout;
|
||||||
|
|
||||||
@ -13,6 +16,9 @@ export default function VolunteerLayout() {
|
|||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const { settings } = useSettingsStore();
|
const { settings } = useSettingsStore();
|
||||||
|
|
||||||
|
// Initialize SSE connection for real-time notifications + online presence
|
||||||
|
useSSE();
|
||||||
|
|
||||||
const orgName = settings?.organizationName ?? 'Changemaker Lite';
|
const orgName = settings?.organizationName ?? 'Changemaker Lite';
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@ -21,7 +27,7 @@ export default function VolunteerLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userMenuItems: MenuProps['items'] = [
|
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') },
|
{ key: 'browse', icon: <GlobalOutlined />, label: 'Browse Site', onClick: () => navigate('/campaigns') },
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{ key: 'logout', icon: <LogoutOutlined />, label: 'Logout', onClick: handleLogout },
|
{ 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 }}>
|
<Typography.Text strong style={{ fontSize: 16, color: '#fff', flex: 1 }}>
|
||||||
{orgName}
|
{orgName}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
{settings?.enableSocial && <NotificationBell />}
|
||||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
<Button type="text" size="small" icon={<UserOutlined style={{ color: '#fff' }} />}>
|
<Button type="text" size="small" icon={<UserOutlined style={{ color: '#fff' }} />}>
|
||||||
<Typography.Text style={{ marginLeft: 4, color: '#fff', fontSize: 13 }}>
|
<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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { Card, Typography, Segmented, Button, Spin, Flex } from 'antd';
|
import { Card, Typography, Segmented, Button, Spin, Flex } from 'antd';
|
||||||
import {
|
import {
|
||||||
CalendarOutlined,
|
ScheduleOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
CompassOutlined,
|
CompassOutlined,
|
||||||
UserAddOutlined,
|
UserAddOutlined,
|
||||||
@ -18,7 +18,7 @@ dayjs.extend(relativeTime);
|
|||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
const TYPE_CONFIG: Record<ActivityItem['type'], { color: string; icon: React.ReactNode }> = {
|
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 }} /> },
|
response_submitted: { color: '#faad14', icon: <MessageOutlined style={{ fontSize: 13 }} /> },
|
||||||
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 13 }} /> },
|
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 13 }} /> },
|
||||||
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 13 }} /> },
|
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 13 }} /> },
|
||||||
|
|||||||
@ -5,11 +5,12 @@ import {
|
|||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
TeamOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { ChatSummaryResult, ChatMessage } from '@/types/api';
|
import type { ChatSummaryResult, ChatMessage, RocketChatStatsData } from '@/types/api';
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@ -72,13 +73,18 @@ function ChatRow({ message }: { message: ChatMessage }) {
|
|||||||
|
|
||||||
export default function ChatNotifierCard() {
|
export default function ChatNotifierCard() {
|
||||||
const [result, setResult] = useState<ChatSummaryResult | null>(null);
|
const [result, setResult] = useState<ChatSummaryResult | null>(null);
|
||||||
|
const [stats, setStats] = useState<RocketChatStatsData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const fetchChat = useCallback(async () => {
|
const fetchChat = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<ChatSummaryResult>('/dashboard/chat-summary');
|
const [chatRes, statsRes] = await Promise.allSettled([
|
||||||
setResult(res.data);
|
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 {
|
} catch {
|
||||||
// non-critical widget
|
// non-critical widget
|
||||||
} finally {
|
} finally {
|
||||||
@ -99,13 +105,40 @@ export default function ChatNotifierCard() {
|
|||||||
title={<span style={{ fontSize: 14 }}><MessageOutlined style={{ marginRight: 6, fontSize: 15 }} />Team Chat</span>}
|
title={<span style={{ fontSize: 14 }}><MessageOutlined style={{ marginRight: 6, fontSize: 15 }} />Team Chat</span>}
|
||||||
size="small"
|
size="small"
|
||||||
extra={
|
extra={
|
||||||
|
<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} />
|
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchChat} />
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
styles={{ body: { padding: '6px 14px 8px' } }}
|
styles={{ body: { padding: '6px 14px 8px' } }}
|
||||||
>
|
>
|
||||||
{loading && !result ? (
|
{loading && !result ? (
|
||||||
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
|
||||||
) : result && result.messages.length > 0 ? (
|
) : (
|
||||||
|
<>
|
||||||
|
{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>
|
<div>
|
||||||
{result.messages.map(msg => (
|
{result.messages.map(msg => (
|
||||||
<ChatRow key={msg.id} message={msg} />
|
<ChatRow key={msg.id} message={msg} />
|
||||||
@ -114,6 +147,8 @@ export default function ChatNotifierCard() {
|
|||||||
) : (
|
) : (
|
||||||
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent messages</Text>
|
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent messages</Text>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Card>
|
</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 { 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 {
|
import {
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { api } from '@/lib/api';
|
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 { 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() {
|
export default function NewsletterStatsCard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [data, setData] = useState<ListmonkStats | null>(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<ListmonkStats>('/listmonk/stats');
|
const [statsRes, statusRes, campaignsRes] = await Promise.all([
|
||||||
setData(res.data);
|
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);
|
setHasError(false);
|
||||||
} catch {
|
} catch {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
@ -40,6 +61,7 @@ export default function NewsletterStatsCard() {
|
|||||||
|
|
||||||
const lists = data?.lists || [];
|
const lists = data?.lists || [];
|
||||||
const totalSubscribers = lists.reduce((sum, l) => sum + l.subscriberCount, 0);
|
const totalSubscribers = lists.reduce((sum, l) => sum + l.subscriberCount, 0);
|
||||||
|
const campaignList = campaigns?.campaigns || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -76,6 +98,35 @@ export default function NewsletterStatsCard() {
|
|||||||
</Flex>
|
</Flex>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip, Progress } from 'antd';
|
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip, Progress } from 'antd';
|
||||||
import {
|
import {
|
||||||
CalendarOutlined,
|
ScheduleOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@ -87,7 +87,7 @@ export default function UpcomingShiftsCard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<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"
|
size="small"
|
||||||
extra={
|
extra={
|
||||||
<Flex align="center" gap={6}>
|
<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 { useAuthStore } from '@/stores/auth.store';
|
||||||
import { hexToRgba } from '@/utils/color';
|
import { hexToRgba } from '@/utils/color';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
import { buildHomeUrl } from '@/lib/service-url';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -166,7 +167,7 @@ export default function MediaSidebar() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: sidebarWidth,
|
width: sidebarWidth,
|
||||||
height: '100vh',
|
height: 'calc(100vh - 56px)',
|
||||||
background: token.colorBgContainer,
|
background: token.colorBgContainer,
|
||||||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -175,7 +176,7 @@ export default function MediaSidebar() {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 56,
|
||||||
zIndex: 100,
|
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)' }}>
|
<div style={{ padding: collapsed ? '8px 0' : '8px 12px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
<Tooltip title={collapsed ? 'Home' : ''} placement="right">
|
<Tooltip title={collapsed ? 'Home' : ''} placement="right">
|
||||||
<a
|
<a
|
||||||
href={`//${window.location.hostname}:4004`}
|
href={buildHomeUrl()}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
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:
|
* All URLs route through nginx which strips X-Frame-Options headers:
|
||||||
* - Real domain (app.cmlite.org) → //db.cmlite.org (subdomain via nginx port 80)
|
* - 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(
|
export function buildServiceUrl(
|
||||||
subdomain: string,
|
subdomain: string,
|
||||||
@ -11,10 +11,45 @@ export function buildServiceUrl(
|
|||||||
embedPort: number,
|
embedPort: number,
|
||||||
): string {
|
): string {
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
// Real domain (contains a dot) — use subdomain via nginx
|
// Use subdomain routing only when accessing via the configured domain
|
||||||
if (hostname.includes('.')) {
|
// (avoids IP addresses like 0.0.0.0/127.0.0.1 triggering subdomain logic)
|
||||||
|
if (hostname.endsWith(domain)) {
|
||||||
return `//${subdomain}.${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}`;
|
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,
|
Select,
|
||||||
Tag,
|
Tag,
|
||||||
Space,
|
Space,
|
||||||
Modal,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Switch,
|
Switch,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@ -492,21 +492,12 @@ export default function CampaignsPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const headerActions = useMemo(() => (
|
const headerActions = useMemo(() => (
|
||||||
<Space>
|
|
||||||
<Button
|
<Button
|
||||||
icon={<MailOutlined />}
|
icon={<MailOutlined />}
|
||||||
onClick={() => navigate('/app/email-queue')}
|
onClick={() => navigate('/app/email-queue')}
|
||||||
>
|
>
|
||||||
Email Queue
|
Email Queue
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setCreateModalOpen(true)}
|
|
||||||
>
|
|
||||||
Create Campaign
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
), [navigate]);
|
), [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -514,8 +505,12 @@ export default function CampaignsPage() {
|
|||||||
return () => setPageHeader(null);
|
return () => setPageHeader(null);
|
||||||
}, [setPageHeader, headerActions]);
|
}, [setPageHeader, headerActions]);
|
||||||
|
|
||||||
|
const drawerOpen = createModalOpen || editModalOpen;
|
||||||
|
const drawerWidth = isMobile ? 0 : 640;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<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 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} sm={12} md={8}>
|
<Col xs={24} sm={12} md={8}>
|
||||||
<Input
|
<Input
|
||||||
@ -536,6 +531,11 @@ export default function CampaignsPage() {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col flex="auto" style={{ textAlign: 'right' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
|
||||||
|
Create Campaign
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Table<Campaign>
|
<Table<Campaign>
|
||||||
@ -554,43 +554,66 @@ export default function CampaignsPage() {
|
|||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
|
locale={{ emptyText: 'No campaigns yet. Create your first campaign to get started.' }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Create Campaign"
|
title="Create Campaign"
|
||||||
open={createModalOpen}
|
open={createModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={isMobile ? '95vw' : 640}
|
mask={false}
|
||||||
onCancel={() => {
|
width={isMobile ? '100%' : 640}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
|
setCreateSelectedVideo(null);
|
||||||
}}
|
}}
|
||||||
onOk={() => createForm.submit()}
|
extra={
|
||||||
okText="Create"
|
<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">
|
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
||||||
{campaignFormFields}
|
{campaignFormFields}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Edit Campaign"
|
title="Edit Campaign"
|
||||||
open={editModalOpen}
|
open={editModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={isMobile ? '95vw' : 640}
|
mask={false}
|
||||||
onCancel={() => {
|
width={isMobile ? '100%' : 640}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setEditingCampaign(null);
|
setEditingCampaign(null);
|
||||||
editForm.resetFields();
|
editForm.resetFields();
|
||||||
|
setEditSelectedVideo(null);
|
||||||
}}
|
}}
|
||||||
onOk={() => editForm.submit()}
|
extra={
|
||||||
okText="Save"
|
<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">
|
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
||||||
{campaignFormFields}
|
{campaignFormFields}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* Emails Drawer */}
|
{/* Emails Drawer */}
|
||||||
<CampaignEmailsDrawer
|
<CampaignEmailsDrawer
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Button,
|
Button,
|
||||||
@ -6,7 +6,6 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Tag,
|
Tag,
|
||||||
Space,
|
Space,
|
||||||
Modal,
|
|
||||||
Form,
|
Form,
|
||||||
Switch,
|
Switch,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@ -67,13 +66,13 @@ export default function CutsPage() {
|
|||||||
const [activeTab, setActiveTab] = useState('table');
|
const [activeTab, setActiveTab] = useState('table');
|
||||||
|
|
||||||
// Create modal
|
// Create modal
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
|
||||||
const [pendingGeojson, setPendingGeojson] = useState<string | null>(null);
|
const [pendingGeojson, setPendingGeojson] = useState<string | null>(null);
|
||||||
const [pendingBounds, setPendingBounds] = useState<string | null>(null);
|
const [pendingBounds, setPendingBounds] = useState<string | null>(null);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
|
|
||||||
// Import modal
|
// Import modal
|
||||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
// Edit drawer
|
// Edit drawer
|
||||||
@ -127,7 +126,7 @@ export default function CutsPage() {
|
|||||||
bounds: pendingBounds,
|
bounds: pendingBounds,
|
||||||
});
|
});
|
||||||
message.success('Cut created');
|
message.success('Cut created');
|
||||||
setCreateModalOpen(false);
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
setPendingGeojson(null);
|
setPendingGeojson(null);
|
||||||
setPendingBounds(null);
|
setPendingBounds(null);
|
||||||
@ -198,7 +197,7 @@ export default function CutsPage() {
|
|||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
message.warning(`${data.failed} features failed to import`);
|
message.warning(`${data.failed} features failed to import`);
|
||||||
}
|
}
|
||||||
setImportModalOpen(false);
|
setImportDrawerOpen(false);
|
||||||
fetchCuts({ page: 1 });
|
fetchCuts({ page: 1 });
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Import failed');
|
message.error('Import failed');
|
||||||
@ -239,7 +238,7 @@ export default function CutsPage() {
|
|||||||
setPendingBounds(bounds);
|
setPendingBounds(bounds);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
createForm.setFieldsValue({ color: '#3388ff', opacity: 0.3 });
|
createForm.setFieldsValue({ color: '#3388ff', opacity: 0.3 });
|
||||||
setCreateModalOpen(true);
|
setCreateDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<Cut> = [
|
const columns: ColumnsType<Cut> = [
|
||||||
@ -402,8 +401,22 @@ export default function CutsPage() {
|
|||||||
|
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
|
||||||
const headerActions = useMemo(() => (
|
useEffect(() => {
|
||||||
<Space>
|
setPageHeader({
|
||||||
|
title: 'Cuts',
|
||||||
|
fullBleed: activeTab === 'map'
|
||||||
|
});
|
||||||
|
return () => setPageHeader(null);
|
||||||
|
}, [setPageHeader, activeTab]);
|
||||||
|
|
||||||
|
const anyDrawerOpen = createDrawerOpen || importDrawerOpen || editDrawerOpen;
|
||||||
|
const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 500 : importDrawerOpen ? 500 : editDrawerOpen ? 480 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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
|
<Segmented
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={(val) => setActiveTab(val as string)}
|
onChange={(val) => setActiveTab(val as string)}
|
||||||
@ -413,33 +426,13 @@ export default function CutsPage() {
|
|||||||
]}
|
]}
|
||||||
size="middle"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
<Button icon={<ExportOutlined />} onClick={handleExportAll}>
|
<Space>
|
||||||
Export GeoJSON
|
<Button icon={<ExportOutlined />} onClick={handleExportAll}>Export GeoJSON</Button>
|
||||||
</Button>
|
<Button icon={<ImportOutlined />} onClick={() => setImportDrawerOpen(true)}>Import GeoJSON</Button>
|
||||||
<Button icon={<ImportOutlined />} onClick={() => setImportModalOpen(true)}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setActiveTab('map')}>Draw New Cut</Button>
|
||||||
Import GeoJSON
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setActiveTab('map')}
|
|
||||||
>
|
|
||||||
Draw New Cut
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
), [activeTab]);
|
</Row>
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPageHeader({
|
|
||||||
title: 'Cuts',
|
|
||||||
actions: headerActions,
|
|
||||||
fullBleed: activeTab === 'map'
|
|
||||||
});
|
|
||||||
return () => setPageHeader(null);
|
|
||||||
}, [setPageHeader, headerActions, activeTab]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{activeTab === 'table' ? (
|
{activeTab === 'table' ? (
|
||||||
<>
|
<>
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
@ -501,21 +494,32 @@ export default function CutsPage() {
|
|||||||
onFinishDraw={handleFinishDraw}
|
onFinishDraw={handleFinishDraw}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Create Cut"
|
title="Create Cut"
|
||||||
open={createModalOpen}
|
open={createDrawerOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={isMobile ? '95vw' : 500}
|
mask={false}
|
||||||
onCancel={() => {
|
width={isMobile ? '100%' : 500}
|
||||||
setCreateModalOpen(false);
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
setPendingGeojson(null);
|
setPendingGeojson(null);
|
||||||
setPendingBounds(null);
|
setPendingBounds(null);
|
||||||
}}
|
}}
|
||||||
onOk={() => createForm.submit()}
|
extra={
|
||||||
okText="Create"
|
<Space>
|
||||||
|
<Button onClick={() => { setCreateDrawerOpen(false); createForm.resetFields(); setPendingGeojson(null); setPendingBounds(null); }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => createForm.submit()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={createForm}
|
form={createForm}
|
||||||
@ -525,16 +529,22 @@ export default function CutsPage() {
|
|||||||
>
|
>
|
||||||
{cutFormFields}
|
{cutFormFields}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* Import GeoJSON Modal */}
|
{/* Import GeoJSON Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Import GeoJSON"
|
title="Import GeoJSON"
|
||||||
open={importModalOpen}
|
open={importDrawerOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={isMobile ? '95vw' : 500}
|
mask={false}
|
||||||
onCancel={() => setImportModalOpen(false)}
|
width={isMobile ? '100%' : 500}
|
||||||
footer={null}
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => setImportDrawerOpen(false)}
|
||||||
|
extra={
|
||||||
|
<Button onClick={() => setImportDrawerOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
Upload a GeoJSON file containing Polygon or MultiPolygon features.
|
Upload a GeoJSON file containing Polygon or MultiPolygon features.
|
||||||
@ -553,22 +563,30 @@ export default function CutsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p>{importing ? 'Importing...' : 'Click or drag a .geojson file here'}</p>
|
<p>{importing ? 'Importing...' : 'Click or drag a .geojson file here'}</p>
|
||||||
</Upload.Dragger>
|
</Upload.Dragger>
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* Edit Drawer */}
|
{/* Edit Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title="Edit Cut"
|
title="Edit Cut"
|
||||||
open={editDrawerOpen}
|
open={editDrawerOpen}
|
||||||
|
destroyOnHidden
|
||||||
|
mask={false}
|
||||||
width={isMobile ? '100%' : 480}
|
width={isMobile ? '100%' : 480}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditDrawerOpen(false);
|
setEditDrawerOpen(false);
|
||||||
setEditingCut(null);
|
setEditingCut(null);
|
||||||
editForm.resetFields();
|
editForm.resetFields();
|
||||||
}}
|
}}
|
||||||
extra={
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => { setEditDrawerOpen(false); setEditingCut(null); editForm.resetFields(); }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button type="primary" onClick={() => editForm.submit()}>
|
<Button type="primary" onClick={() => editForm.submit()}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
CalendarOutlined,
|
ScheduleOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
@ -35,6 +35,9 @@ import {
|
|||||||
CloseCircleFilled,
|
CloseCircleFilled,
|
||||||
MinusCircleFilled,
|
MinusCircleFilled,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
|
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
|
||||||
@ -62,6 +65,9 @@ import RecentSignupsCard from '@/components/dashboard/RecentSignupsCard';
|
|||||||
import NewsletterStatsCard from '@/components/dashboard/NewsletterStatsCard';
|
import NewsletterStatsCard from '@/components/dashboard/NewsletterStatsCard';
|
||||||
import DonationSummaryCard from '@/components/dashboard/DonationSummaryCard';
|
import DonationSummaryCard from '@/components/dashboard/DonationSummaryCard';
|
||||||
import SystemAlertsCard from '@/components/dashboard/SystemAlertsCard';
|
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 CutCampaignAnalyticsCard from '@/components/canvass/CutCampaignAnalyticsCard';
|
||||||
import { buildServiceUrl } from '@/lib/service-url';
|
import { buildServiceUrl } from '@/lib/service-url';
|
||||||
import type {
|
import type {
|
||||||
@ -88,8 +94,7 @@ if (typeof document !== 'undefined' && !document.getElementById(PULSE_STYLE_ID))
|
|||||||
.svc-badge-online .ant-badge-status-dot {
|
.svc-badge-online .ant-badge-status-dot {
|
||||||
animation: dashboard-pulse 2s infinite;
|
animation: dashboard-pulse 2s infinite;
|
||||||
}
|
}
|
||||||
.db-mi { min-width: 0; display: flex; flex-direction: column; }
|
.db-mi { break-inside: avoid; margin-bottom: 16px; }
|
||||||
.db-mi > * { flex: 1; }
|
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
@ -250,13 +255,7 @@ export default function DashboardPage() {
|
|||||||
const showChat = settings?.enableChat !== false;
|
const showChat = settings?.enableChat !== false;
|
||||||
const showNewsletter = settings?.enableNewsletter !== false;
|
const showNewsletter = settings?.enableNewsletter !== false;
|
||||||
const showPayments = settings?.enablePayments !== false;
|
const showPayments = settings?.enablePayments !== false;
|
||||||
|
const showMeet = settings?.enableMeet !== 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 geocodePct = summary && summary.locations.total > 0
|
const geocodePct = summary && summary.locations.total > 0
|
||||||
? Math.round((summary.locations.geocoded / summary.locations.total) * 100) : 0;
|
? Math.round((summary.locations.geocoded / summary.locations.total) * 100) : 0;
|
||||||
@ -308,7 +307,7 @@ export default function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
{/* === Welcome Banner === */}
|
{/* === Welcome Banner === */}
|
||||||
<Card
|
<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' } }}
|
styles={{ body: { padding: '10px 16px' } }}
|
||||||
>
|
>
|
||||||
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
|
<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) === */}
|
{/* === Status Bar (weather + stats + pending actions + connectivity) === */}
|
||||||
{summary && (
|
{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 justify="space-between" align="center" wrap="wrap" gap={8}>
|
||||||
<Flex gap={0} wrap="wrap" align="center">
|
<Flex gap={0} wrap="wrap" align="center">
|
||||||
{weather && (
|
{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')} />}
|
{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')} />}
|
{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')} />}
|
{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 */}
|
{/* Pending action tags */}
|
||||||
{summary.responses.pending > 0 && (
|
{summary.responses.pending > 0 && (
|
||||||
<Tag color="orange" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/responses')}>
|
<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) && (
|
{queue && (queue.waiting > 0 || queue.active > 0 || queue.failed > 0) && (
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
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' } }}
|
styles={{ body: { padding: '6px 16px' } }}
|
||||||
>
|
>
|
||||||
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
|
<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) === */}
|
{/* === 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 && (
|
{showInfluence && (
|
||||||
<div className="db-mi" style={gs(4)}>
|
<div className="db-mi">
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Flex align="center" gap={6}>
|
<Flex align="center" gap={6}>
|
||||||
@ -553,7 +552,7 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showMap && (
|
{showMap && (
|
||||||
<div className="db-mi" style={gs(4)}>
|
<div className="db-mi">
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Flex align="center" gap={6}>
|
<Flex align="center" gap={6}>
|
||||||
@ -590,7 +589,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="db-mi" style={gs(4)}>
|
<div className="db-mi">
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Flex align="center" gap={6}>
|
<Flex align="center" gap={6}>
|
||||||
@ -633,67 +632,82 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="db-mi" style={gs(5, 2)}>
|
<div className="db-mi">
|
||||||
<ActivityFeedCard />
|
<ActivityFeedCard />
|
||||||
</div>
|
</div>
|
||||||
{showEvents && (
|
{showEvents && (
|
||||||
<div className="db-mi" style={gs(3)}>
|
<div className="db-mi">
|
||||||
<TodayEventsCard />
|
<TodayEventsCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="db-mi" style={gs(4)}>
|
<div className="db-mi">
|
||||||
<DocsAnalyticsCard />
|
<DocsAnalyticsCard />
|
||||||
</div>
|
</div>
|
||||||
{showChat && (
|
{showChat && (
|
||||||
<div className="db-mi" style={gs(5, 2)}>
|
<div className="db-mi">
|
||||||
<ChatNotifierCard />
|
<ChatNotifierCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showMap && (
|
{showMap && (
|
||||||
<div className="db-mi" style={gs(4)}>
|
<div className="db-mi">
|
||||||
<UpcomingShiftsCard />
|
<UpcomingShiftsCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showMeet && (
|
||||||
|
<div className="db-mi">
|
||||||
|
<UpcomingMeetingsCard />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showInfluence && (
|
{showInfluence && (
|
||||||
<div className="db-mi" style={gs(3)}>
|
<div className="db-mi">
|
||||||
<CampaignEffectivenessCard />
|
<CampaignEffectivenessCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showMap && (
|
{showMap && (
|
||||||
<div className="db-mi" style={gs(3)}>
|
<div className="db-mi">
|
||||||
<RecentSignupsCard />
|
<RecentSignupsCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showMedia && (
|
{showMedia && (
|
||||||
<div className="db-mi" style={gs(5)}>
|
<div className="db-mi">
|
||||||
<TopVideosCard />
|
<TopVideosCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showMedia && (
|
{showMedia && (
|
||||||
<div className="db-mi" style={gs(4)}>
|
<div className="db-mi">
|
||||||
<RecentCommentsCard />
|
<RecentCommentsCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showNewsletter && isSuperAdmin && (
|
{showNewsletter && isSuperAdmin && (
|
||||||
<div className="db-mi" style={gs(4)}>
|
<div className="db-mi">
|
||||||
<NewsletterStatsCard />
|
<NewsletterStatsCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showPayments && isSuperAdmin && (
|
{showPayments && isSuperAdmin && (
|
||||||
<div className="db-mi" style={gs(3)}>
|
<div className="db-mi">
|
||||||
<DonationSummaryCard />
|
<DonationSummaryCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<div className="db-mi" style={gs(5)}>
|
<div className="db-mi">
|
||||||
<SystemAlertsCard />
|
<SystemAlertsCard />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="db-mi">
|
||||||
|
<GiteaActivityCard />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="db-mi">
|
||||||
|
<VaultwardenAdoptionCard />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* === Canvass Progress (full-width table) === */}
|
{/* === Canvass Progress (full-width table) === */}
|
||||||
{showMap && (
|
{showMap && (
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
<Row gutter={[16, 16]} style={{ marginTop: 16, marginBottom: 16 }}>
|
||||||
<Col xs={24}>
|
<Col xs={24}>
|
||||||
<CutCampaignAnalyticsCard />
|
<CutCampaignAnalyticsCard />
|
||||||
</Col>
|
</Col>
|
||||||
@ -705,7 +719,7 @@ export default function DashboardPage() {
|
|||||||
<>
|
<>
|
||||||
{/* === Time-Series Charts (Traffic + Latency) === */}
|
{/* === Time-Series Charts (Traffic + Latency) === */}
|
||||||
{timeSeries && screens.md && (
|
{timeSeries && screens.md && (
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
<Card
|
||||||
title={<><BarChartOutlined style={{ marginRight: 6 }} />Request Traffic (1h)</>}
|
title={<><BarChartOutlined style={{ marginRight: 6 }} />Request Traffic (1h)</>}
|
||||||
@ -728,7 +742,7 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System Info + Service Health */}
|
{/* 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 */}
|
{/* Hardware card with circular gauges + mini system chart */}
|
||||||
<Col xs={24} lg={8}>
|
<Col xs={24} lg={8}>
|
||||||
<Card title={<><DesktopOutlined style={{ marginRight: 6 }} />System</>} size="small" style={{ height: '100%' }}>
|
<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 */}
|
{/* API Performance with status code donut + route bar chart */}
|
||||||
{apiMetrics && (
|
{apiMetrics && (
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
<Card
|
||||||
title={<><ApiOutlined style={{ marginRight: 6 }} />API Performance</>}
|
title={<><ApiOutlined style={{ marginRight: 6 }} />API Performance</>}
|
||||||
@ -989,6 +1003,10 @@ const SERVICE_LABELS: Record<string, string> = {
|
|||||||
miniqr: 'Mini QR',
|
miniqr: 'Mini QR',
|
||||||
excalidraw: 'Excalidraw',
|
excalidraw: 'Excalidraw',
|
||||||
homepage: 'Homepage',
|
homepage: 'Homepage',
|
||||||
|
vaultwarden: 'Vaultwarden',
|
||||||
|
rocketchat: 'Rocket.Chat',
|
||||||
|
gancio: 'Gancio',
|
||||||
|
jitsi: 'Jitsi Meet',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVICE_ICONS: Record<string, React.ReactNode> = {
|
const SERVICE_ICONS: Record<string, React.ReactNode> = {
|
||||||
@ -999,6 +1017,10 @@ const SERVICE_ICONS: Record<string, React.ReactNode> = {
|
|||||||
miniqr: <QrcodeOutlined />,
|
miniqr: <QrcodeOutlined />,
|
||||||
excalidraw: <FileTextOutlined />,
|
excalidraw: <FileTextOutlined />,
|
||||||
homepage: <HomeOutlined />,
|
homepage: <HomeOutlined />,
|
||||||
|
vaultwarden: <LockOutlined />,
|
||||||
|
rocketchat: <MessageOutlined />,
|
||||||
|
gancio: <CalendarOutlined />,
|
||||||
|
jitsi: <VideoCameraOutlined />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Quick Stat chip (for status bar) ---
|
// --- Quick Stat chip (for status bar) ---
|
||||||
|
|||||||
@ -41,24 +41,10 @@ export default function DataQualityDashboardPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadStats]);
|
}, [loadStats]);
|
||||||
|
|
||||||
// Page header with refresh button
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader({
|
setPageHeader({ title: 'Data Quality Dashboard' });
|
||||||
title: 'Data Quality Dashboard',
|
|
||||||
actions: (
|
|
||||||
<Button
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setLoading(true);
|
|
||||||
loadStats();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
return () => setPageHeader(null);
|
return () => setPageHeader(null);
|
||||||
}, [setPageHeader, loadStats]);
|
}, [setPageHeader]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||||
@ -68,6 +54,18 @@ export default function DataQualityDashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: screens.md ? 24 : 16 }}>
|
<div style={{ padding: screens.md ? 24 : 16 }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
loadStats();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Overview Cards */}
|
{/* Overview Cards */}
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={24} sm={12} md={6}>
|
<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 { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Space,
|
Space,
|
||||||
@ -15,6 +15,7 @@ import {
|
|||||||
Dropdown,
|
Dropdown,
|
||||||
Menu,
|
Menu,
|
||||||
Typography,
|
Typography,
|
||||||
|
Segmented,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { TreeDataNode } from 'antd';
|
import type { TreeDataNode } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
@ -55,6 +56,8 @@ import {
|
|||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
CrownOutlined,
|
CrownOutlined,
|
||||||
ShoppingCartOutlined,
|
ShoppingCartOutlined,
|
||||||
|
MobileOutlined,
|
||||||
|
DesktopOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import type { OnMount } from '@monaco-editor/react';
|
import type { OnMount } from '@monaco-editor/react';
|
||||||
@ -66,13 +69,21 @@ import type { FileNode, ServicesConfig } from '@/types/api';
|
|||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
|
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
|
||||||
import type { Video as PickerVideo } 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 { generateVideoCardHtml } from '@/utils/videoCardHtml';
|
||||||
|
import { generatePhotoCardHtml } from '@/utils/photoCardHtml';
|
||||||
import { DonateInsertModal } from '@/components/payments/DonateInsertModal';
|
import { DonateInsertModal } from '@/components/payments/DonateInsertModal';
|
||||||
import type { DonateInsertResult } from '@/components/payments/DonateInsertModal';
|
import type { DonateInsertResult } from '@/components/payments/DonateInsertModal';
|
||||||
import { ProductInsertModal } from '@/components/payments/ProductInsertModal';
|
import { ProductInsertModal } from '@/components/payments/ProductInsertModal';
|
||||||
import type { ProductInsertResult } 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 LayoutMode = 'split' | 'editor' | 'preview';
|
||||||
|
type PreviewMode = 'desktop' | 'mobile';
|
||||||
|
|
||||||
const LAYOUT_STORAGE_KEY = 'docs-editor-layout';
|
const LAYOUT_STORAGE_KEY = 'docs-editor-layout';
|
||||||
const DIVIDER_STORAGE_KEY = 'docs-editor-split';
|
const DIVIDER_STORAGE_KEY = 'docs-editor-split';
|
||||||
@ -140,7 +151,17 @@ function invalidateTreeCache(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URL Preview Bar Component (shows full clickable URLs above iframe)
|
// 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();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
// Only show for markdown files
|
// Only show for markdown files
|
||||||
@ -188,14 +209,15 @@ const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config:
|
|||||||
height: 32,
|
height: 32,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
background: token.colorBgElevated,
|
background: token.colorBgElevated,
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
gap: 12,
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, overflow: 'hidden', minWidth: 0 }}>
|
||||||
{productionUrl && (
|
{productionUrl && (
|
||||||
<Tooltip title={productionUrl} mouseEnterDelay={0.3}>
|
<Tooltip title={productionUrl} mouseEnterDelay={0.3}>
|
||||||
<span style={urlLinkStyle} onClick={() => openUrl(productionUrl)}>
|
<span style={urlLinkStyle} onClick={() => openUrl(productionUrl)}>
|
||||||
@ -215,6 +237,18 @@ const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config:
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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: '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: '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: '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: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' },
|
||||||
{ id: 'pricing-table', label: 'Pricing Table', 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: '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: '---' },
|
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -509,6 +545,7 @@ function applySnippet(
|
|||||||
|
|
||||||
export default function DocsPage() {
|
export default function DocsPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const location = useLocation();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
@ -528,6 +565,7 @@ export default function DocsPage() {
|
|||||||
const [layout, setLayout] = useState<LayoutMode>(
|
const [layout, setLayout] = useState<LayoutMode>(
|
||||||
() => (localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode) || 'split',
|
() => (localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode) || 'split',
|
||||||
);
|
);
|
||||||
|
const [previewMode, setPreviewMode] = useState<PreviewMode>('desktop');
|
||||||
const [splitPercent, setSplitPercent] = useState<number>(
|
const [splitPercent, setSplitPercent] = useState<number>(
|
||||||
() => Number(localStorage.getItem(DIVIDER_STORAGE_KEY)) || 50,
|
() => Number(localStorage.getItem(DIVIDER_STORAGE_KEY)) || 50,
|
||||||
);
|
);
|
||||||
@ -546,8 +584,12 @@ export default function DocsPage() {
|
|||||||
const [contextPath, setContextPath] = useState<string>('');
|
const [contextPath, setContextPath] = useState<string>('');
|
||||||
|
|
||||||
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
|
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 [donateInsertOpen, setDonateInsertOpen] = useState(false);
|
||||||
const [productInsertOpen, setProductInsertOpen] = useState(false);
|
const [productInsertOpen, setProductInsertOpen] = useState(false);
|
||||||
|
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -648,6 +690,30 @@ export default function DocsPage() {
|
|||||||
}
|
}
|
||||||
}, [fileContentCache, messageApi]);
|
}, [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
|
// Save file
|
||||||
const saveFile = useCallback(async () => {
|
const saveFile = useCallback(async () => {
|
||||||
if (!selectedFile || !dirty) return;
|
if (!selectedFile || !dirty) return;
|
||||||
@ -727,6 +793,11 @@ export default function DocsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (snippetId === 'photo-insert') {
|
||||||
|
setPhotoInsertOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Donate button — opens variant picker modal
|
// Donate button — opens variant picker modal
|
||||||
if (snippetId === 'donate-button') {
|
if (snippetId === 'donate-button') {
|
||||||
setDonateInsertOpen(true);
|
setDonateInsertOpen(true);
|
||||||
@ -739,6 +810,12 @@ export default function DocsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ad — opens ad picker modal
|
||||||
|
if (snippetId === 'ad-insert') {
|
||||||
|
setAdPickerOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Pricing table — static CTA (plans are dynamic, so link out)
|
// Pricing table — static CTA (plans are dynamic, so link out)
|
||||||
if (snippetId === 'pricing-table') {
|
if (snippetId === 'pricing-table') {
|
||||||
const appUrl = config
|
const appUrl = config
|
||||||
@ -794,6 +871,99 @@ export default function DocsPage() {
|
|||||||
setVideoPickerOpen(false);
|
setVideoPickerOpen(false);
|
||||||
}, [config]);
|
}, [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 handleDonateInsert = useCallback((result: DonateInsertResult) => {
|
||||||
const uid = Date.now().toString(36);
|
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) => {
|
const handleCtxMenuClick = useCallback((snippetId: string) => {
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
handleToolbarSnippet(snippetId);
|
handleToolbarSnippet(snippetId);
|
||||||
@ -1788,7 +1976,7 @@ export default function DocsPage() {
|
|||||||
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
|
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
|
||||||
key: s.id,
|
key: s.id,
|
||||||
label: s.label,
|
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),
|
onClick: () => handleToolbarSnippet(s.id),
|
||||||
})) }} trigger={['click']}>
|
})) }} trigger={['click']}>
|
||||||
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
|
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
|
||||||
@ -1901,16 +2089,37 @@ export default function DocsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* URL Preview Bar */}
|
{/* URL Preview Bar */}
|
||||||
<URLPreviewBar filePath={selectedFile} config={config} />
|
<URLPreviewBar
|
||||||
|
filePath={selectedFile}
|
||||||
|
config={config}
|
||||||
|
previewMode={previewMode}
|
||||||
|
onPreviewModeChange={setPreviewMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Preview iframe */}
|
{/* Preview iframe */}
|
||||||
|
<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
|
<iframe
|
||||||
ref={previewIframeRef}
|
ref={previewIframeRef}
|
||||||
src="/mkdocs-proxy/"
|
src="/mkdocs-proxy/"
|
||||||
style={{ width: '100%', flex: 1, border: 'none' }}
|
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||||
title="MkDocs Preview"
|
title="MkDocs Preview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1945,6 +2154,21 @@ export default function DocsPage() {
|
|||||||
title="Insert Video Card"
|
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 */}
|
{/* Donate Block Picker Modal */}
|
||||||
<DonateInsertModal
|
<DonateInsertModal
|
||||||
open={donateInsertOpen}
|
open={donateInsertOpen}
|
||||||
@ -1959,6 +2183,13 @@ export default function DocsPage() {
|
|||||||
onInsert={handleProductInsert}
|
onInsert={handleProductInsert}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Ad Picker Modal */}
|
||||||
|
<AdPickerModal
|
||||||
|
open={adPickerOpen}
|
||||||
|
onCancel={() => setAdPickerOpen(false)}
|
||||||
|
onInsert={handleAdInsert}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Custom right-click context menu with submenus */}
|
{/* Custom right-click context menu with submenus */}
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<div
|
<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 { Card, Statistic, Row, Col, Button, Space, Tag, message, Popconfirm } from 'antd';
|
||||||
import {
|
import {
|
||||||
PauseCircleOutlined,
|
PauseCircleOutlined,
|
||||||
@ -64,10 +64,18 @@ export default function EmailQueuePage() {
|
|||||||
|
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
|
||||||
const headerActions = useMemo(() => (
|
useEffect(() => {
|
||||||
<Space>
|
setPageHeader({ title: 'Email Queue' });
|
||||||
|
return () => setPageHeader(null);
|
||||||
|
}, [setPageHeader]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row style={{ marginBottom: 16 }} align="middle">
|
||||||
|
<Col flex="auto">
|
||||||
|
<Space wrap>
|
||||||
{stats && (
|
{stats && (
|
||||||
<Tag color={stats.paused ? 'orange' : 'green'}>
|
<Tag color={stats.paused ? 'orange' : 'green'} style={{ marginInlineEnd: 0 }}>
|
||||||
{stats.paused ? 'PAUSED' : 'RUNNING'}
|
{stats.paused ? 'PAUSED' : 'RUNNING'}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
@ -98,15 +106,9 @@ export default function EmailQueuePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPageHeader({ title: 'Email Queue', actions: headerActions });
|
|
||||||
return () => setPageHeader(null);
|
|
||||||
}, [setPageHeader, headerActions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card>
|
<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,
|
Tag,
|
||||||
Space,
|
Space,
|
||||||
Modal,
|
Modal,
|
||||||
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
message,
|
message,
|
||||||
@ -31,7 +32,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext, useLocation } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
||||||
import LandingPageEditor from '@/components/landing-pages/LandingPageEditor';
|
import LandingPageEditor from '@/components/landing-pages/LandingPageEditor';
|
||||||
@ -48,6 +49,7 @@ const publishedOptions = [
|
|||||||
export default function LandingPagesPage() {
|
export default function LandingPagesPage() {
|
||||||
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const location = useLocation();
|
||||||
const [pages, setPages] = useState<LandingPage[]>([]);
|
const [pages, setPages] = useState<LandingPage[]>([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -59,8 +61,8 @@ export default function LandingPagesPage() {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const [publishedFilter, setPublishedFilter] = useState<'true' | 'false' | undefined>();
|
const [publishedFilter, setPublishedFilter] = useState<'true' | 'false' | undefined>();
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
|
||||||
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
|
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
|
||||||
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
||||||
const [qrPage, setQrPage] = useState<LandingPage | null>(null);
|
const [qrPage, setQrPage] = useState<LandingPage | null>(null);
|
||||||
@ -78,6 +80,14 @@ export default function LandingPagesPage() {
|
|||||||
return () => clearTimeout(searchTimerRef.current);
|
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) => {
|
const fetchPages = useCallback(async (params?: LandingPagesListParams) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -116,7 +126,7 @@ export default function LandingPagesPage() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post<LandingPage>('/pages', values);
|
const { data } = await api.post<LandingPage>('/pages', values);
|
||||||
message.success('Page created');
|
message.success('Page created');
|
||||||
setCreateModalOpen(false);
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
setEditingPageId(data.id);
|
setEditingPageId(data.id);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -167,7 +177,7 @@ export default function LandingPagesPage() {
|
|||||||
try {
|
try {
|
||||||
await api.put(`/pages/${editingPage.id}`, values);
|
await api.put(`/pages/${editingPage.id}`, values);
|
||||||
message.success('Page settings updated');
|
message.success('Page settings updated');
|
||||||
setSettingsModalOpen(false);
|
setSettingsDrawerOpen(false);
|
||||||
setEditingPage(null);
|
setEditingPage(null);
|
||||||
settingsForm.resetFields();
|
settingsForm.resetFields();
|
||||||
fetchPages();
|
fetchPages();
|
||||||
@ -231,6 +241,7 @@ export default function LandingPagesPage() {
|
|||||||
settingsForm.setFieldsValue({
|
settingsForm.setFieldsValue({
|
||||||
title: page.title,
|
title: page.title,
|
||||||
description: page.description,
|
description: page.description,
|
||||||
|
listed: (page as any).listed ?? false,
|
||||||
mkdocsPath: page.mkdocsPath,
|
mkdocsPath: page.mkdocsPath,
|
||||||
mkdocsExportMode: page.mkdocsExportMode,
|
mkdocsExportMode: page.mkdocsExportMode,
|
||||||
mkdocsHideNav: page.mkdocsHideNav,
|
mkdocsHideNav: page.mkdocsHideNav,
|
||||||
@ -240,7 +251,7 @@ export default function LandingPagesPage() {
|
|||||||
seoDescription: page.seoDescription,
|
seoDescription: page.seoDescription,
|
||||||
seoImage: page.seoImage,
|
seoImage: page.seoImage,
|
||||||
});
|
});
|
||||||
setSettingsModalOpen(true);
|
setSettingsDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<LandingPage> = [
|
const columns: ColumnsType<LandingPage> = [
|
||||||
@ -270,8 +281,11 @@ export default function LandingPagesPage() {
|
|||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'published',
|
dataIndex: 'published',
|
||||||
key: 'published',
|
key: 'published',
|
||||||
render: (published: boolean) => (
|
render: (published: boolean, record: LandingPage) => (
|
||||||
|
<Space size={4}>
|
||||||
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
|
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
|
||||||
|
{(record as any).listed && <Tag color="blue">Listed</Tag>}
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -399,8 +413,12 @@ export default function LandingPagesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const anyDrawerOpen = createDrawerOpen || settingsDrawerOpen;
|
||||||
|
const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 520 : settingsDrawerOpen ? 560 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<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 }}>
|
<Row justify="end" align="middle" style={{ marginBottom: 16 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space>
|
||||||
@ -431,7 +449,7 @@ export default function LandingPagesPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => setCreateModalOpen(true)}
|
onClick={() => setCreateDrawerOpen(true)}
|
||||||
>
|
>
|
||||||
Create Page
|
Create Page
|
||||||
</Button>
|
</Button>
|
||||||
@ -476,18 +494,30 @@ export default function LandingPagesPage() {
|
|||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
locale={{ emptyText: 'No landing pages yet. Create your first page to get started.' }}
|
locale={{ emptyText: 'No landing pages yet. Create your first page to get started.' }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Create Landing Page"
|
title="Create Landing Page"
|
||||||
open={createModalOpen}
|
open={createDrawerOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
onCancel={() => {
|
mask={false}
|
||||||
setCreateModalOpen(false);
|
width={isMobile ? '100%' : 520}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => createForm.submit()}
|
extra={
|
||||||
okText="Create & Edit"
|
<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 form={createForm} onFinish={handleCreate} layout="vertical" initialValues={{ editorMode: 'VISUAL' }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@ -507,7 +537,7 @@ export default function LandingPagesPage() {
|
|||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* QR Code Modal */}
|
{/* QR Code Modal */}
|
||||||
{qrPage && (
|
{qrPage && (
|
||||||
@ -519,19 +549,29 @@ export default function LandingPagesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings Modal */}
|
{/* Settings Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Page Settings"
|
title="Page Settings"
|
||||||
open={settingsModalOpen}
|
open={settingsDrawerOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={isMobile ? '95vw' : 560}
|
mask={false}
|
||||||
onCancel={() => {
|
width={isMobile ? '100%' : 560}
|
||||||
setSettingsModalOpen(false);
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setSettingsDrawerOpen(false);
|
||||||
setEditingPage(null);
|
setEditingPage(null);
|
||||||
settingsForm.resetFields();
|
settingsForm.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => settingsForm.submit()}
|
extra={
|
||||||
okText="Save"
|
<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 form={settingsForm} onFinish={handleSettingsSave} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@ -554,6 +594,14 @@ export default function LandingPagesPage() {
|
|||||||
<Input placeholder="https://..." />
|
<Input placeholder="https://..." />
|
||||||
</Form.Item>
|
</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>
|
<Divider>MkDocs Integration</Divider>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@ -599,7 +647,7 @@ export default function LandingPagesPage() {
|
|||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Button,
|
Button,
|
||||||
@ -112,14 +112,14 @@ export default function LocationsPage() {
|
|||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const [confidenceFilter, setConfidenceFilter] = useState<'high' | 'medium' | 'low' | 'none' | undefined>();
|
const [confidenceFilter, setConfidenceFilter] = useState<'high' | 'medium' | 'low' | 'none' | undefined>();
|
||||||
|
|
||||||
// Modals/Drawers
|
// Drawers
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createDrawerOpen, setCreateDrawerOpen] = useState(false);
|
||||||
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
|
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
|
||||||
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
|
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
|
||||||
const [locationHistory, setLocationHistory] = useState<LocationHistory[]>([]);
|
const [locationHistory, setLocationHistory] = useState<LocationHistory[]>([]);
|
||||||
const [historyLoading, setHistoryLoading] = useState(false);
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
const [historyPagination, setHistoryPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
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 [importing, setImporting] = useState(false);
|
||||||
const [geocodingMissing, setGeocodingMissing] = useState(false);
|
const [geocodingMissing, setGeocodingMissing] = useState(false);
|
||||||
const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server' | 'area'>('standard');
|
const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server' | 'area'>('standard');
|
||||||
@ -146,7 +146,7 @@ export default function LocationsPage() {
|
|||||||
const narPollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
const narPollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||||
|
|
||||||
// Bulk Re-Geocoding state
|
// Bulk Re-Geocoding state
|
||||||
const [bulkGeocodeModalOpen, setBulkGeocodeModalOpen] = useState(false);
|
const [bulkGeocodeDrawerOpen, setBulkGeocodeDrawerOpen] = useState(false);
|
||||||
const [bulkGeocoding, setBulkGeocoding] = useState(false);
|
const [bulkGeocoding, setBulkGeocoding] = useState(false);
|
||||||
const [bulkGeocodeStatus, setBulkGeocodeStatus] = useState<any>(null);
|
const [bulkGeocodeStatus, setBulkGeocodeStatus] = useState<any>(null);
|
||||||
const bulkGeocodePollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
const bulkGeocodePollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||||
@ -376,7 +376,7 @@ export default function LocationsPage() {
|
|||||||
);
|
);
|
||||||
await api.post('/map/locations', cleaned);
|
await api.post('/map/locations', cleaned);
|
||||||
message.success('Location created');
|
message.success('Location created');
|
||||||
setCreateModalOpen(false);
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchLocations({ page: 1 });
|
fetchLocations({ page: 1 });
|
||||||
fetchStats();
|
fetchStats();
|
||||||
@ -483,7 +483,7 @@ export default function LocationsPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setImportModalOpen(false);
|
setImportDrawerOpen(false);
|
||||||
fetchLocations({ page: 1 });
|
fetchLocations({ page: 1 });
|
||||||
fetchStats();
|
fetchStats();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -674,7 +674,7 @@ export default function LocationsPage() {
|
|||||||
longitude: Math.round(lng * 100000) / 100000,
|
longitude: Math.round(lng * 100000) / 100000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setCreateModalOpen(true);
|
setCreateDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveLocation = async (id: string, lat: number, lng: number) => {
|
const handleMoveLocation = async (id: string, lat: number, lng: number) => {
|
||||||
@ -980,53 +980,17 @@ export default function LocationsPage() {
|
|||||||
|
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
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(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'Map Locations', actions: headerActions });
|
setPageHeader({ title: 'Map Locations' });
|
||||||
return () => setPageHeader(null);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div style={{ marginRight: anyDrawerOpen ? activeDrawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||||
{/* Stats cards */}
|
{/* Stats cards */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
@ -1144,6 +1108,22 @@ export default function LocationsPage() {
|
|||||||
</Row>
|
</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
|
<Tabs
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
@ -1219,7 +1199,7 @@ export default function LocationsPage() {
|
|||||||
? 'No locations match your filters.'
|
? 'No locations match your filters.'
|
||||||
: <div style={{ padding: 16 }}>
|
: <div style={{ padding: 16 }}>
|
||||||
<div style={{ marginBottom: 8, color: 'rgba(255,255,255,0.45)' }}>No locations yet.</div>
|
<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>
|
</div>
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -1244,30 +1224,47 @@ export default function LocationsPage() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Add Location"
|
title="Add Location"
|
||||||
open={createModalOpen}
|
open={createDrawerOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={isMobile ? '95vw' : 600}
|
mask={false}
|
||||||
onCancel={() => {
|
width={isMobile ? '100%' : 600}
|
||||||
setCreateModalOpen(false);
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateDrawerOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => createForm.submit()}
|
extra={
|
||||||
okText="Create"
|
<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">
|
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
||||||
{locationFormFields(createForm)}
|
{locationFormFields(createForm)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* Edit Drawer */}
|
{/* Edit Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title="Edit Location"
|
title="Edit Location"
|
||||||
open={editDrawerOpen}
|
open={editDrawerOpen}
|
||||||
|
mask={false}
|
||||||
|
destroyOnHidden
|
||||||
width={isMobile ? '100%' : 700}
|
width={isMobile ? '100%' : 700}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditDrawerOpen(false);
|
setEditDrawerOpen(false);
|
||||||
setEditingLocation(null);
|
setEditingLocation(null);
|
||||||
@ -1276,9 +1273,18 @@ export default function LocationsPage() {
|
|||||||
editForm.resetFields();
|
editForm.resetFields();
|
||||||
}}
|
}}
|
||||||
extra={
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setEditDrawerOpen(false);
|
||||||
|
setEditingLocation(null);
|
||||||
|
editForm.resetFields();
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button type="primary" onClick={() => editForm.submit()}>
|
<Button type="primary" onClick={() => editForm.submit()}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
@ -1371,14 +1377,16 @@ export default function LocationsPage() {
|
|||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* Import CSV / Bulk Import Modal */}
|
{/* Import CSV / Bulk Import Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Import Locations"
|
title="Import Locations"
|
||||||
open={importModalOpen}
|
open={importDrawerOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={importFormat === 'area' ? 700 : 620}
|
mask={false}
|
||||||
onCancel={() => {
|
width={isMobile ? '100%' : 700}
|
||||||
setImportModalOpen(false);
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setImportDrawerOpen(false);
|
||||||
setBulkImportResult(null);
|
setBulkImportResult(null);
|
||||||
setNarImportResult(null);
|
setNarImportResult(null);
|
||||||
setNarProgress(null);
|
setNarProgress(null);
|
||||||
@ -1395,7 +1403,14 @@ export default function LocationsPage() {
|
|||||||
setNarFilterPostalPrefix('');
|
setNarFilterPostalPrefix('');
|
||||||
setNarFilterCutId(undefined);
|
setNarFilterCutId(undefined);
|
||||||
}}
|
}}
|
||||||
footer={null}
|
extra={
|
||||||
|
<Button onClick={() => {
|
||||||
|
setImportDrawerOpen(false);
|
||||||
|
setImportFormat('standard');
|
||||||
|
}}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={importFormat}
|
value={importFormat}
|
||||||
@ -1421,7 +1436,7 @@ export default function LocationsPage() {
|
|||||||
<AreaImportWizard
|
<AreaImportWizard
|
||||||
cuts={cuts}
|
cuts={cuts}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
setImportModalOpen(false);
|
setImportDrawerOpen(false);
|
||||||
setImportFormat('standard');
|
setImportFormat('standard');
|
||||||
fetchLocations();
|
fetchLocations();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
@ -1840,21 +1855,34 @@ export default function LocationsPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Drawer>
|
||||||
|
|
||||||
{/* Bulk Re-Geocode Modal */}
|
{/* Bulk Re-Geocode Drawer */}
|
||||||
<Modal
|
<Drawer
|
||||||
title="Bulk Re-Geocode Locations"
|
title="Bulk Re-Geocode Locations"
|
||||||
open={bulkGeocodeModalOpen}
|
open={bulkGeocodeDrawerOpen}
|
||||||
onCancel={() => {
|
destroyOnHidden
|
||||||
setBulkGeocodeModalOpen(false);
|
mask={false}
|
||||||
|
width={isMobile ? '100%' : 600}
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
|
onClose={() => {
|
||||||
|
setBulkGeocodeDrawerOpen(false);
|
||||||
setBulkGeocoding(false);
|
setBulkGeocoding(false);
|
||||||
setBulkGeocodeStatus(null);
|
setBulkGeocodeStatus(null);
|
||||||
stopBulkGeocodePolling();
|
stopBulkGeocodePolling();
|
||||||
bulkGeocodeForm.resetFields();
|
bulkGeocodeForm.resetFields();
|
||||||
}}
|
}}
|
||||||
footer={null}
|
extra={
|
||||||
width={isMobile ? '95vw' : 600}
|
<Button onClick={() => {
|
||||||
|
setBulkGeocodeDrawerOpen(false);
|
||||||
|
setBulkGeocoding(false);
|
||||||
|
setBulkGeocodeStatus(null);
|
||||||
|
stopBulkGeocodePolling();
|
||||||
|
bulkGeocodeForm.resetFields();
|
||||||
|
}}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{!bulkGeocoding && !bulkGeocodeStatus ? (
|
{!bulkGeocoding && !bulkGeocodeStatus ? (
|
||||||
<Form
|
<Form
|
||||||
@ -1980,7 +2008,7 @@ export default function LocationsPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBulkGeocodeModalOpen(false);
|
setBulkGeocodeDrawerOpen(false);
|
||||||
setBulkGeocoding(false);
|
setBulkGeocoding(false);
|
||||||
setBulkGeocodeStatus(null);
|
setBulkGeocodeStatus(null);
|
||||||
stopBulkGeocodePolling();
|
stopBulkGeocodePolling();
|
||||||
@ -1991,7 +2019,7 @@ export default function LocationsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
@ -12,7 +12,6 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
message,
|
message,
|
||||||
Spin,
|
Spin,
|
||||||
Space,
|
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
Switch,
|
Switch,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@ -42,6 +41,12 @@ export default function MapSettingsPage() {
|
|||||||
const { data } = await api.get<MapSettings>('/map/settings');
|
const { data } = await api.get<MapSettings>('/map/settings');
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
publicMapEnabled: data.publicMapEnabled ?? true,
|
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,
|
latitude: data.latitude ? parseFloat(data.latitude) : 45.4215,
|
||||||
longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,
|
longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,
|
||||||
zoom: data.zoom ?? 12,
|
zoom: data.zoom ?? 12,
|
||||||
@ -131,29 +136,10 @@ export default function MapSettingsPage() {
|
|||||||
|
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
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(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'Map Settings', actions: headerActions });
|
setPageHeader({ title: 'Map Settings' });
|
||||||
return () => setPageHeader(null);
|
return () => setPageHeader(null);
|
||||||
}, [setPageHeader, headerActions]);
|
}, [setPageHeader]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -196,6 +182,25 @@ export default function MapSettingsPage() {
|
|||||||
{/* Left column: Settings form */}
|
{/* Left column: Settings form */}
|
||||||
<Col xs={24} lg={10}>
|
<Col xs={24} lg={10}>
|
||||||
<Form form={form} onFinish={handleSave} layout="vertical">
|
<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 }}>
|
<Card title="Public Map Visibility" style={{ marginBottom: 24 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<div>
|
||||||
@ -211,6 +216,31 @@ export default function MapSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 }}>
|
<Card title="Map Center & Zoom" style={{ marginBottom: 24 }}>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
@ -279,6 +309,7 @@ export default function MapSettingsPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Card,
|
Card,
|
||||||
Collapse,
|
Collapse,
|
||||||
ColorPicker,
|
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
@ -23,7 +22,6 @@ import {
|
|||||||
Result,
|
Result,
|
||||||
Grid,
|
Grid,
|
||||||
theme,
|
theme,
|
||||||
Popconfirm,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { TreeDataNode } from 'antd';
|
import type { TreeDataNode } from 'antd';
|
||||||
import type { TreeProps } from 'antd';
|
import type { TreeProps } from 'antd';
|
||||||
@ -41,9 +39,6 @@ import {
|
|||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
HolderOutlined,
|
HolderOutlined,
|
||||||
UndoOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
ArrowDownOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { parseDocument, Document } from 'yaml';
|
import { parseDocument, Document } from 'yaml';
|
||||||
@ -51,7 +46,7 @@ import type { ScalarTag } from 'yaml';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
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;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -292,16 +287,6 @@ export default function MkDocsSettingsPage() {
|
|||||||
const [editorDirty, setEditorDirty] = useState(false);
|
const [editorDirty, setEditorDirty] = useState(false);
|
||||||
const [editorSaving, setEditorSaving] = 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
|
// Build tab state
|
||||||
const [docsStatus, setDocsStatus] = useState<DocsStatus | null>(null);
|
const [docsStatus, setDocsStatus] = useState<DocsStatus | null>(null);
|
||||||
@ -316,11 +301,10 @@ export default function MkDocsSettingsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
try {
|
try {
|
||||||
const [configRes, filesRes, campaignsRes, headerRes] = await Promise.all([
|
const [configRes, filesRes, campaignsRes] = await Promise.all([
|
||||||
api.get<MkDocsConfigResponse>('/docs/mkdocs-config'),
|
api.get<MkDocsConfigResponse>('/docs/mkdocs-config'),
|
||||||
api.get<FileNode[]>('/docs/files'),
|
api.get<FileNode[]>('/docs/files'),
|
||||||
api.get<Campaign[]>('/campaigns/public').catch(() => ({ data: [] as Campaign[] })),
|
api.get<Campaign[]>('/campaigns/public').catch(() => ({ data: [] as Campaign[] })),
|
||||||
api.get<HeaderConfig>('/docs/header-config').catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
const content = configRes.data.content;
|
const content = configRes.data.content;
|
||||||
setRawYaml(content);
|
setRawYaml(content);
|
||||||
@ -328,9 +312,6 @@ export default function MkDocsSettingsPage() {
|
|||||||
setEditorYaml(content);
|
setEditorYaml(content);
|
||||||
setFileTree(filesRes.data);
|
setFileTree(filesRes.data);
|
||||||
setCampaigns(campaignsRes.data);
|
setCampaigns(campaignsRes.data);
|
||||||
if (headerRes?.data) {
|
|
||||||
setHeaderConfig(headerRes.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse for settings tab
|
// Parse for settings tab
|
||||||
syncSettingsFromYaml(content);
|
syncSettingsFromYaml(content);
|
||||||
@ -399,17 +380,6 @@ export default function MkDocsSettingsPage() {
|
|||||||
return () => setPageHeader(null);
|
return () => setPageHeader(null);
|
||||||
}, [setPageHeader]);
|
}, [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 ---
|
// --- Settings Tab: Save ---
|
||||||
|
|
||||||
@ -533,114 +503,6 @@ export default function MkDocsSettingsPage() {
|
|||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [editorDirty, saveEditor]);
|
}, [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 ---
|
// --- Build Tab ---
|
||||||
|
|
||||||
const checkStatus = useCallback(async () => {
|
const checkStatus = useCallback(async () => {
|
||||||
@ -1397,316 +1259,22 @@ export default function MkDocsSettingsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'header',
|
key: 'header',
|
||||||
label: (
|
label: 'Header',
|
||||||
<span>
|
children: (
|
||||||
Header
|
<div style={{ maxWidth: 600, padding: '24px 0' }}>
|
||||||
{headerDirty && <Badge status="warning" style={{ marginLeft: 8 }} />}
|
<Result
|
||||||
</span>
|
status="info"
|
||||||
),
|
title="Navigation Moved"
|
||||||
children: headerConfig ? (
|
subTitle="The app-wide navigation bar is now configured in Settings > Navigation. The MkDocs header is automatically generated from the centralized navigation configuration."
|
||||||
<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={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
onClick={() => window.location.href = '/app/settings?activeTab=navigation'}
|
||||||
onClick={() => setCustomLinkModalOpen(true)}
|
|
||||||
disabled={!isSuperAdmin}
|
|
||||||
>
|
>
|
||||||
Add Custom Link
|
Go to Settings > Navigation
|
||||||
</Button>
|
</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"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<UndoOutlined />}
|
|
||||||
disabled={!isSuperAdmin}
|
|
||||||
loading={headerLoading}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</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>
|
</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