From a7978de5a020a52d7f6b372f1ca734e14ad577ad Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Mon, 16 Feb 2026 18:48:54 -0700 Subject: [PATCH] Bunch of stuff again --- admin/src/App.tsx | 71 +- admin/src/components/AppLayout.tsx | 4 + admin/src/components/AuthModal.tsx | 242 ++++ admin/src/components/MediaPublicLayout.tsx | 90 +- admin/src/components/ProtectedRoute.tsx | 3 +- admin/src/components/PublicLayout.tsx | 109 +- .../components/canvass/CanvassMarkerGroup.tsx | 255 ++-- .../dashboard/ContainerMemoryChart.tsx | 49 + .../components/dashboard/ContainerPopover.tsx | 58 + .../dashboard/LatencyBandsChart.tsx | 50 + .../components/dashboard/MiniDonutChart.tsx | 49 + .../dashboard/RequestTrafficChart.tsx | 50 + .../src/components/dashboard/SystemGauges.tsx | 58 + admin/src/components/map/AreaImportWizard.tsx | 586 ++++++++ .../components/media/AddToPlaylistModal.tsx | 242 ++++ .../media/BulkAddToPlaylistModal.tsx | 188 +++ .../media/ChatNotificationToast.tsx | 79 + admin/src/components/media/CommentSection.tsx | 4 +- .../components/media/CreatePlaylistModal.tsx | 99 ++ .../components/media/EditPlaylistModal.tsx | 260 ++++ admin/src/components/media/EditVideoModal.tsx | 9 +- .../components/media/ExpandedVideoCard.tsx | 62 +- .../media/FeaturedPlaylistCarousel.tsx | 152 ++ .../components/media/FetchVideosDrawer.tsx | 540 +++++++ admin/src/components/media/LiveChat.tsx | 16 +- admin/src/components/media/MediaBottomNav.tsx | 159 +-- admin/src/components/media/MediaSidebar.tsx | 273 ++-- admin/src/components/media/PlaylistCard.tsx | 178 +++ .../components/media/PlaylistSidebarPanel.tsx | 228 +++ .../src/components/media/PublicVideoCard.tsx | 7 +- .../src/components/media/ReactionButtons.tsx | 12 +- admin/src/components/media/VideoActions.tsx | 9 + admin/src/components/media/VideoCard.tsx | 40 +- admin/src/components/media/VideoPlayer.tsx | 32 +- .../src/components/media/VideoViewerModal.tsx | 12 +- .../src/components/media/chatbar/ChatBar.tsx | 49 + .../media/chatbar/ChatBarContext.tsx | 127 ++ .../media/chatbar/MiniChatWindow.tsx | 93 ++ .../components/media/chatbar/MiniLiveChat.tsx | 229 +++ .../media/chatbar/MinimizedChat.tsx | 55 + admin/src/contexts/ExpandedVideoContext.tsx | 15 +- admin/src/contexts/MediaAuthContext.tsx | 103 +- admin/src/hooks/useChatNotifications.ts | 79 + admin/src/pages/DashboardPage.tsx | 871 +++++++++++- admin/src/pages/EmailTemplatesPage.tsx | 70 +- admin/src/pages/LocationsPage.tsx | 18 +- admin/src/pages/LoginPage.tsx | 318 ++++- admin/src/pages/ResetPasswordPage.tsx | 133 ++ admin/src/pages/SettingsPage.tsx | 38 + admin/src/pages/UsersPage.tsx | 135 +- admin/src/pages/VerifyEmailPage.tsx | 137 ++ .../influence/CampaignModerationPage.tsx | 419 ++++++ .../src/pages/media/CommentModerationPage.tsx | 582 ++++++++ admin/src/pages/media/LibraryPage.tsx | 90 +- .../pages/media/PlaylistManagementPage.tsx | 629 ++++++++ admin/src/pages/public/CampaignPage.tsx | 92 +- admin/src/pages/public/CampaignsListPage.tsx | 508 +++++-- admin/src/pages/public/CreateCampaignPage.tsx | 266 ++++ admin/src/pages/public/MediaGalleryPage.tsx | 90 +- admin/src/pages/public/MediaViewerPage.tsx | 25 +- admin/src/pages/public/MyCampaignsPage.tsx | 152 ++ admin/src/pages/public/MySettingsPage.tsx | 394 +++++ admin/src/pages/public/MyStatsPage.tsx | 471 ++++++ admin/src/pages/public/PlaylistBrowsePage.tsx | 355 +++++ admin/src/pages/public/PlaylistViewerPage.tsx | 381 +++++ admin/src/pages/public/ShortsPage.tsx | 1265 +++++++++++++++++ admin/src/stores/auth.store.ts | 65 +- admin/src/types/api.ts | 261 +++- admin/src/types/media.ts | 73 + admin/src/utils/color.ts | 6 + admin/src/utils/roles.ts | 23 + admin/tsconfig.tsbuildinfo | 2 +- api/Dockerfile.media | 8 +- api/prisma/schema.prisma | 36 +- api/prisma/seed.ts | 1 + api/src/config/env.ts | 5 + api/src/media-server.ts | 32 +- api/src/middleware/auth.middleware.ts | 15 +- api/src/middleware/rbac.middleware.ts | 10 +- api/src/modules/auth/auth.rate-limits.ts | 43 + api/src/modules/auth/auth.routes.ts | 179 ++- api/src/modules/auth/auth.service.ts | 91 +- api/src/modules/dashboard/dashboard.routes.ts | 124 ++ .../modules/dashboard/dashboard.service.ts | 621 ++++++++ .../campaigns/campaigns-moderation.routes.ts | 58 + .../campaigns/campaigns-user.routes.ts | 77 + .../influence/campaigns/campaigns.schemas.ts | 34 +- .../influence/campaigns/campaigns.service.ts | 199 ++- api/src/modules/map/canvass/canvass.routes.ts | 6 +- .../map/locations/area-import.routes.ts | 89 ++ .../map/locations/area-import.schemas.ts | 57 + .../map/locations/area-import.service.ts | 671 +++++++++ .../modules/map/locations/overpass.service.ts | 211 +++ api/src/modules/map/shifts/shifts.service.ts | 111 +- api/src/modules/media/middleware/auth.ts | 14 +- .../media/routes/chat-notifications.routes.ts | 117 ++ .../media/routes/chat-stream.routes.ts | 2 +- .../media/routes/chat-threads.routes.ts | 161 +++ .../media/routes/comment-admin.routes.ts | 505 +++++++ .../modules/media/routes/comments.routes.ts | 124 +- api/src/modules/media/routes/fetch.routes.ts | 198 +++ .../media/routes/playlists-admin.routes.ts | 386 +++++ .../media/routes/playlists-public.routes.ts | 376 +++++ .../media/routes/playlists-user.routes.ts | 450 ++++++ api/src/modules/media/routes/public.routes.ts | 21 +- api/src/modules/media/routes/shorts.routes.ts | 172 +++ api/src/modules/media/routes/upvote.routes.ts | 112 ++ .../media/routes/user-profile.routes.ts | 405 ++++++ .../media/routes/video-actions.routes.ts | 2 + .../media/routes/video-streaming.routes.ts | 78 +- api/src/modules/media/routes/videos.routes.ts | 7 + .../media/services/word-filter.service.ts | 95 ++ api/src/modules/settings/settings.schemas.ts | 5 + api/src/modules/users/users.routes.ts | 100 +- api/src/modules/users/users.schemas.ts | 2 + api/src/modules/users/users.service.ts | 23 + api/src/scripts/seed-email-templates.ts | 56 + api/src/server.ts | 18 + api/src/services/email.service.ts | 255 ++++ api/src/services/listmonk-proxy.service.ts | 4 +- .../services/password-reset-token.service.ts | 58 + .../services/verification-token.service.ts | 50 + api/src/services/video-fetch-queue.service.ts | 482 +++++++ api/src/templates/email/account-approved.html | 84 ++ api/src/templates/email/account-approved.txt | 9 + .../email/account-pending-approval.html | 109 ++ .../email/account-pending-approval.txt | 11 + .../templates/email/email-verification.html | 102 ++ .../templates/email/email-verification.txt | 13 + api/src/templates/email/password-reset.html | 102 ++ api/src/templates/email/password-reset.txt | 11 + api/src/types/express.d.ts | 1 + api/src/utils/promql-validator.ts | 2 +- api/src/utils/roles.ts | 39 + api/src/utils/spatial.ts | 35 + 135 files changed, 19366 insertions(+), 1002 deletions(-) create mode 100644 admin/src/components/AuthModal.tsx create mode 100644 admin/src/components/dashboard/ContainerMemoryChart.tsx create mode 100644 admin/src/components/dashboard/ContainerPopover.tsx create mode 100644 admin/src/components/dashboard/LatencyBandsChart.tsx create mode 100644 admin/src/components/dashboard/MiniDonutChart.tsx create mode 100644 admin/src/components/dashboard/RequestTrafficChart.tsx create mode 100644 admin/src/components/dashboard/SystemGauges.tsx create mode 100644 admin/src/components/map/AreaImportWizard.tsx create mode 100644 admin/src/components/media/AddToPlaylistModal.tsx create mode 100644 admin/src/components/media/BulkAddToPlaylistModal.tsx create mode 100644 admin/src/components/media/ChatNotificationToast.tsx create mode 100644 admin/src/components/media/CreatePlaylistModal.tsx create mode 100644 admin/src/components/media/EditPlaylistModal.tsx create mode 100644 admin/src/components/media/FeaturedPlaylistCarousel.tsx create mode 100644 admin/src/components/media/FetchVideosDrawer.tsx create mode 100644 admin/src/components/media/PlaylistCard.tsx create mode 100644 admin/src/components/media/PlaylistSidebarPanel.tsx create mode 100644 admin/src/components/media/chatbar/ChatBar.tsx create mode 100644 admin/src/components/media/chatbar/ChatBarContext.tsx create mode 100644 admin/src/components/media/chatbar/MiniChatWindow.tsx create mode 100644 admin/src/components/media/chatbar/MiniLiveChat.tsx create mode 100644 admin/src/components/media/chatbar/MinimizedChat.tsx create mode 100644 admin/src/hooks/useChatNotifications.ts create mode 100644 admin/src/pages/ResetPasswordPage.tsx create mode 100644 admin/src/pages/VerifyEmailPage.tsx create mode 100644 admin/src/pages/influence/CampaignModerationPage.tsx create mode 100644 admin/src/pages/media/CommentModerationPage.tsx create mode 100644 admin/src/pages/media/PlaylistManagementPage.tsx create mode 100644 admin/src/pages/public/CreateCampaignPage.tsx create mode 100644 admin/src/pages/public/MyCampaignsPage.tsx create mode 100644 admin/src/pages/public/MySettingsPage.tsx create mode 100644 admin/src/pages/public/MyStatsPage.tsx create mode 100644 admin/src/pages/public/PlaylistBrowsePage.tsx create mode 100644 admin/src/pages/public/PlaylistViewerPage.tsx create mode 100644 admin/src/pages/public/ShortsPage.tsx create mode 100644 admin/src/utils/color.ts create mode 100644 admin/src/utils/roles.ts create mode 100644 api/src/modules/auth/auth.rate-limits.ts create mode 100644 api/src/modules/dashboard/dashboard.routes.ts create mode 100644 api/src/modules/dashboard/dashboard.service.ts create mode 100644 api/src/modules/influence/campaigns/campaigns-moderation.routes.ts create mode 100644 api/src/modules/influence/campaigns/campaigns-user.routes.ts create mode 100644 api/src/modules/map/locations/area-import.routes.ts create mode 100644 api/src/modules/map/locations/area-import.schemas.ts create mode 100644 api/src/modules/map/locations/area-import.service.ts create mode 100644 api/src/modules/map/locations/overpass.service.ts create mode 100644 api/src/modules/media/routes/chat-notifications.routes.ts create mode 100644 api/src/modules/media/routes/chat-threads.routes.ts create mode 100644 api/src/modules/media/routes/comment-admin.routes.ts create mode 100644 api/src/modules/media/routes/fetch.routes.ts create mode 100644 api/src/modules/media/routes/playlists-admin.routes.ts create mode 100644 api/src/modules/media/routes/playlists-public.routes.ts create mode 100644 api/src/modules/media/routes/playlists-user.routes.ts create mode 100644 api/src/modules/media/routes/shorts.routes.ts create mode 100644 api/src/modules/media/routes/upvote.routes.ts create mode 100644 api/src/modules/media/routes/user-profile.routes.ts create mode 100644 api/src/modules/media/services/word-filter.service.ts create mode 100644 api/src/services/password-reset-token.service.ts create mode 100644 api/src/services/verification-token.service.ts create mode 100644 api/src/services/video-fetch-queue.service.ts create mode 100644 api/src/templates/email/account-approved.html create mode 100644 api/src/templates/email/account-approved.txt create mode 100644 api/src/templates/email/account-pending-approval.html create mode 100644 api/src/templates/email/account-pending-approval.txt create mode 100644 api/src/templates/email/email-verification.html create mode 100644 api/src/templates/email/email-verification.txt create mode 100644 api/src/templates/email/password-reset.html create mode 100644 api/src/templates/email/password-reset.txt create mode 100644 api/src/utils/roles.ts diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 6267838f..84fc6d49 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -41,24 +41,37 @@ import ObservabilityPage from '@/pages/ObservabilityPage'; import LibraryPage from '@/pages/media/LibraryPage'; import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage'; import MediaJobsPage from '@/pages/media/MediaJobsPage'; +import CommentModerationPage from '@/pages/media/CommentModerationPage'; +import CampaignModerationPage from '@/pages/influence/CampaignModerationPage'; import PublicLandingPage from '@/pages/public/LandingPage'; import CampaignsListPage from '@/pages/public/CampaignsListPage'; import CampaignPage from '@/pages/public/CampaignPage'; +import CreateCampaignPage from '@/pages/public/CreateCampaignPage'; +import MyCampaignsPage from '@/pages/public/MyCampaignsPage'; import ResponseWallPage from '@/pages/public/ResponseWallPage'; import MapPage from '@/pages/public/MapPage'; import PublicShiftsPage from '@/pages/public/ShiftsPage'; import MediaGalleryPage from '@/pages/public/MediaGalleryPage'; +import ShortsPage from '@/pages/public/ShortsPage'; import MediaViewerPage from '@/pages/public/MediaViewerPage'; +import PlaylistBrowsePage from '@/pages/public/PlaylistBrowsePage'; +import PlaylistViewerPage from '@/pages/public/PlaylistViewerPage'; +import PlaylistManagementPage from '@/pages/media/PlaylistManagementPage'; +import MyStatsPage from '@/pages/public/MyStatsPage'; +import MySettingsPage from '@/pages/public/MySettingsPage'; import MyActivityPage from '@/pages/volunteer/MyActivityPage'; import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage'; import MyRoutesPage from '@/pages/volunteer/MyRoutesPage'; import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage'; import { ADMIN_ROLES } from '@/types/api'; +import { isAdmin } from '@/utils/roles'; +import VerifyEmailPage from '@/pages/VerifyEmailPage'; +import ResetPasswordPage from '@/pages/ResetPasswordPage'; function RoleAwareRedirect() { const { user, isAuthenticated } = useAuthStore(); if (!isAuthenticated) return ; - if (user && ADMIN_ROLES.includes(user.role)) return ; + if (user && isAdmin(user)) return ; return ; } @@ -126,6 +139,24 @@ export default function App() { }> } /> + + + + + + }> + } /> + + + + + + + }> + } /> + }> } /> } /> @@ -139,8 +170,20 @@ export default function App() { {/* Public Media Gallery (purple theme) — feature-gated */} }> } /> + } /> } /> + }> + } /> + + } /> + } /> + }> + } /> + + }> + } /> + } /> {/* Email link alias for video viewer */} } /> @@ -175,6 +218,8 @@ export default function App() { /> } /> + } /> + } /> } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> } /> diff --git a/admin/src/components/AppLayout.tsx b/admin/src/components/AppLayout.tsx index e4ccb0ca..b8f45b66 100644 --- a/admin/src/components/AppLayout.tsx +++ b/admin/src/components/AppLayout.tsx @@ -34,6 +34,7 @@ import { BarChartOutlined, SoundOutlined, EditOutlined, + OrderedListOutlined, } from '@ant-design/icons'; import type { MenuProps } from 'antd'; import { useAuthStore } from '@/stores/auth.store'; @@ -63,6 +64,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me label: 'Influence', children: [ { key: '/app/campaigns', icon: , label: 'Campaigns' }, + { key: '/app/campaign-moderation', icon: , label: 'Campaign Review' }, { key: '/app/representatives', icon: , label: 'Representatives' }, { key: '/app/email-queue', icon: , label: 'Email Queue' }, { key: '/app/responses', icon: , label: 'Responses' }, @@ -120,6 +122,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me label: 'Media Library', children: [ { key: '/app/media/library', icon: , label: 'Videos' }, + { key: '/app/media/curated', icon: , label: 'Curated' }, + { key: '/app/media/moderation', icon: , label: 'Moderation' }, { key: '/app/media/jobs', icon: , label: 'Processing Jobs' }, ], }); diff --git a/admin/src/components/AuthModal.tsx b/admin/src/components/AuthModal.tsx new file mode 100644 index 00000000..bbb099e8 --- /dev/null +++ b/admin/src/components/AuthModal.tsx @@ -0,0 +1,242 @@ +import { useState, useEffect } from 'react'; +import { Modal, Form, Input, Button, Alert, Segmented, Typography } from 'antd'; +import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined } from '@ant-design/icons'; +import { useAuthStore } from '@/stores/auth.store'; +import axios from 'axios'; + +const { Text } = Typography; +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'; + +type AuthMode = 'signin' | 'register'; + +interface AuthModalProps { + open: boolean; + onCancel: () => void; + onSuccess: () => void; + title?: string; + subtitle?: string; +} + +export default function AuthModal({ open, onCancel, onSuccess, title, subtitle }: AuthModalProps) { + const { login, register, isLoading, error, errorCode, registrationMessage, clearError } = useAuthStore(); + const [mode, setMode] = useState('signin'); + const [loginForm] = Form.useForm(); + const [registerForm] = Form.useForm(); + const [resendLoading, setResendLoading] = useState(false); + const [resendSent, setResendSent] = useState(false); + + // Clear errors when switching modes + useEffect(() => { + clearError(); + setResendSent(false); + }, [mode]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleLogin = async (values: { email: string; password: string }) => { + try { + await login(values.email, values.password); + loginForm.resetFields(); + onSuccess(); + } catch { + // Error is set in store + } + }; + + const handleRegister = async (values: { name: string; email: string; password: string }) => { + try { + const result = await register(values.name, values.email, values.password); + if (result?.requiresVerification) { + // Stay open to show verification message — don't call onSuccess + registerForm.resetFields(); + return; + } + registerForm.resetFields(); + onSuccess(); + } catch { + // Error is set in store + } + }; + + const handleResendVerification = async () => { + const email = loginForm.getFieldValue('email'); + if (!email) return; + setResendLoading(true); + try { + await axios.post(`${API_URL}/api/auth/resend-verification`, { email }); + setResendSent(true); + } catch { + // Ignore — always show success for security + setResendSent(true); + } finally { + setResendLoading(false); + } + }; + + const handleCancel = () => { + loginForm.resetFields(); + registerForm.resetFields(); + clearError(); + onCancel(); + }; + + return ( + + {title && ( +
+ {title} +
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} + +
+ setMode(val as AuthMode)} + /> +
+ + {/* Registration success — verification required */} + {registrationMessage && ( + } + closable + onClose={() => clearError()} + style={{ marginBottom: 16 }} + /> + )} + + {error && ( + clearError()} + description={ + errorCode === 'EMAIL_NOT_VERIFIED' ? ( + resendSent ? ( + Verification email sent! Check your inbox. + ) : ( + + ) + ) : errorCode === 'ACCOUNT_PENDING' ? ( + + An admin will review your account shortly. + + ) : undefined + } + style={{ marginBottom: 16 }} + /> + )} + + {mode === 'signin' ? ( +
+ + } placeholder="Email" autoFocus /> + + + + } placeholder="Password" /> + + + + + +
+ ) : ( +
+ + } placeholder="Full Name" autoFocus /> + + + + } placeholder="Email" /> + + + + } placeholder="Password (12+ chars)" /> + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('Passwords do not match')); + }, + }), + ]} + > + } placeholder="Confirm Password" /> + + + + + +
+ )} +
+ ); +} diff --git a/admin/src/components/MediaPublicLayout.tsx b/admin/src/components/MediaPublicLayout.tsx index 5e5bd52d..ba6ddeb5 100644 --- a/admin/src/components/MediaPublicLayout.tsx +++ b/admin/src/components/MediaPublicLayout.tsx @@ -3,15 +3,24 @@ import { ConfigProvider, Layout, theme, Grid } from 'antd'; import { Outlet } from 'react-router-dom'; import MediaSidebar from '@/components/media/MediaSidebar'; import MediaBottomNav from '@/components/media/MediaBottomNav'; +import ChatNotificationToast from '@/components/media/ChatNotificationToast'; +import { ChatBarProvider } from '@/components/media/chatbar/ChatBarContext'; +import ChatBar from '@/components/media/chatbar/ChatBar'; +import { useChatNotifications } from '@/hooks/useChatNotifications'; +import { useSettingsStore } from '@/stores/settings.store'; +import { hexToRgba } from '@/utils/color'; const { useBreakpoint } = Grid; export default function MediaPublicLayout() { - // Purple theme tokens matching media-manager aesthetic - const colorPrimary = '#9333ea'; // purple-600 - const colorBgBase = '#0d0d12'; // nearly black - const colorBgContainer = '#18181b'; // zinc-900 - const colorBgElevated = '#27272a'; // zinc-800 + const { settings } = useSettingsStore(); + const { notifications, clearNotification } = useChatNotifications(); + + // Read colors from site settings (same source as PublicLayout) + const colorPrimary = settings?.publicColorPrimary ?? '#3498db'; + const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a'; + const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838'; + const orgName = settings?.organizationName ?? 'Changemaker Lite'; const screens = useBreakpoint(); const isMobile = !screens.md; // < 768px @@ -43,8 +52,8 @@ export default function MediaPublicLayout() { // Set document title for media pages useEffect(() => { - document.title = 'Media Gallery | Changemaker Lite'; - }, []); + document.title = `Media Gallery | ${orgName}`; + }, [orgName]); // Calculate main content left margin based on sidebar state and screen size const mainContentMarginLeft = isMobile ? 0 : sidebarCollapsed ? 64 : 256; @@ -57,48 +66,57 @@ export default function MediaPublicLayout() { colorPrimary, colorBgBase, colorBgContainer, - colorBgElevated, - colorBorder: 'rgba(147, 51, 234, 0.2)', // purple border + colorBgElevated: colorBgContainer, + colorBorder: hexToRgba(colorPrimary, 0.2), colorBorderSecondary: 'rgba(255,255,255,0.06)', borderRadius: 12, - colorLink: '#a855f7', // purple-500 - colorLinkHover: '#c084fc', // purple-400 + colorLink: colorPrimary, colorText: 'rgba(255, 255, 255, 0.85)', colorTextSecondary: 'rgba(255, 255, 255, 0.65)', colorTextTertiary: 'rgba(255, 255, 255, 0.45)', }, }} > - - {/* Desktop: Show sidebar, Mobile: Hide */} - {!isMobile && } + + + {/* Desktop: Show sidebar, Mobile: Hide */} + {!isMobile && } - {/* Main content area */} -
-
- -
-
+
+ +
+ - {/* Mobile: Show bottom nav, Desktop: Hide */} - -
+ {/* Mobile: Show bottom nav, Desktop: Hide */} + + + {/* Chat reply notifications */} + + + {/* Messenger-style chat bar */} + +
+ ); } diff --git a/admin/src/components/ProtectedRoute.tsx b/admin/src/components/ProtectedRoute.tsx index 1a1fb4fb..16d4d160 100644 --- a/admin/src/components/ProtectedRoute.tsx +++ b/admin/src/components/ProtectedRoute.tsx @@ -1,6 +1,7 @@ import { Navigate } from 'react-router-dom'; import { Spin, Result } from 'antd'; import { useAuthStore } from '@/stores/auth.store'; +import { hasAnyRole } from '@/utils/roles'; import type { UserRole } from '@/types/api'; interface ProtectedRouteProps { @@ -33,7 +34,7 @@ export default function ProtectedRoute({ return ; } - if (requiredRoles && user && !requiredRoles.includes(user.role)) { + if (requiredRoles && user && !hasAnyRole(user, requiredRoles)) { return ( { e.currentTarget.style.color = '#fff'; }} + onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} + > + {icon} + {label} + + ); +} + +function NavButton({ onClick, icon, label }: { onClick: () => void; icon: React.ReactNode; label: string }) { + return ( + { if (e.key === 'Enter') onClick(); }} + style={navItemStyle} + onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }} + onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} + > + {icon} + {label} + + ); +} + export default function PublicLayout() { const { settings } = useSettingsStore(); + const { isAuthenticated, logout } = useAuthStore(); + const navigate = useNavigate(); + const [authModalOpen, setAuthModalOpen] = useState(false); const colorPrimary = settings?.publicColorPrimary ?? '#3498db'; const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a'; @@ -75,28 +127,20 @@ export default function PublicLayout() { {/* Right: Navigation */} - - { - e.currentTarget.style.color = '#fff'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; - }} - > - - Media Gallery - + + {isAuthenticated ? ( + <> + } label="Create Campaign" /> + } label="My Campaigns" /> + logout()} icon={} label="Logout" /> + + ) : ( + <> + setAuthModalOpen(true)} icon={} label="Create Campaign" /> + setAuthModalOpen(true)} icon={} label="Sign In" /> + + )} + } label="Media Gallery" /> {' • '} + + Create Campaign + + {' • '} Media Gallery + + setAuthModalOpen(false)} + onSuccess={() => { + setAuthModalOpen(false); + navigate('/campaigns/create'); + }} + title="Sign in to Create a Campaign" + subtitle="Sign in or create an account to submit your own campaign" + /> ); diff --git a/admin/src/components/canvass/CanvassMarkerGroup.tsx b/admin/src/components/canvass/CanvassMarkerGroup.tsx index 3796aafa..0a842236 100644 --- a/admin/src/components/canvass/CanvassMarkerGroup.tsx +++ b/admin/src/components/canvass/CanvassMarkerGroup.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Marker, Popup } from 'react-leaflet'; import { Alert, theme } from 'antd'; import L from 'leaflet'; @@ -76,6 +76,150 @@ function apartmentSvg(color: string, size: number, selected: boolean): string { `; } +/** Compact dropdown UI for multi-unit buildings (replaces long scrollable list) */ +function MultiUnitPopup({ + group, + addresses, + selectedAddressId, + onAddressClick, + token, +}: { + group: AddressGroup; + addresses: CanvassAddress[]; + selectedAddressId: string | null; + onAddressClick: (addressId: string) => void; + token: ReturnType['token']; +}) { + // Default to the selected address in this building, or the first address + const initialId = addresses.find((a) => a.id === selectedAddressId)?.id ?? addresses[0]?.id ?? ''; + const [viewingId, setViewingId] = useState(initialId); + const viewingAddr = addresses.find((a) => a.id === viewingId) ?? addresses[0]; + + const visitedCount = addresses.filter((a) => a.lastVisit).length; + + return ( + <> + {/* Header */} +
+
+ 🏢 {group.baseAddress} +
+
+ {addresses.length} units · {visitedCount} visited +
+
+ + {/* Building notes */} + {group.buildingNotes && ( + + } + type="info" + showIcon + style={{ marginBottom: 12, fontSize: 11 }} + /> + )} + + {/* Native setViewingId(e.target.value)} + style={{ + width: '100%', + padding: '8px 10px', + fontSize: 13, + borderRadius: 6, + border: '1px solid #d9d9d9', + background: '#fafafa', + marginBottom: 10, + cursor: 'pointer', + appearance: 'auto', + }} + aria-label="Select unit" + > + {addresses.map((addr) => { + const status = addr.lastVisit + ? VISIT_OUTCOME_LABELS[addr.lastVisit.outcome] + : 'Not visited'; + return ( + + ); + })} + + + {/* Selected unit detail card */} + {viewingAddr && ( + + )} + + ); +} + function CanvassMarkerGroup({ group, selectedAddressId, onAddressClick }: CanvassMarkerGroupProps) { const addresses = group.addresses; const isMultiUnit = group.isMultiUnit; @@ -115,107 +259,14 @@ function CanvassMarkerGroup({ group, selectedAddressId, onAddressClick }: Canvas
{isMultiUnit ? ( - // Multi-unit building display - <> -
-
- 🏢 {group.baseAddress} -
-
- {addresses.length} units -
-
- - {/* Building notes */} - {group.buildingNotes && ( - - } - type="info" - showIcon - style={{ marginBottom: 12, fontSize: 11 }} - /> - )} - - {/* Already sorted in groupAddressesByLocation helper */} - {addresses.map((addr, i) => ( - - ))} - + // Multi-unit building display — compact dropdown + ) : ( // Single unit display
onAddressClick(addresses[0]!.id)}> diff --git a/admin/src/components/dashboard/ContainerMemoryChart.tsx b/admin/src/components/dashboard/ContainerMemoryChart.tsx new file mode 100644 index 00000000..9eea589c --- /dev/null +++ b/admin/src/components/dashboard/ContainerMemoryChart.tsx @@ -0,0 +1,49 @@ +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, Cell, +} from 'recharts'; +import { Typography } from 'antd'; +import type { ContainerResource } from '@/types/api'; + +const { Text } = Typography; + +interface ContainerMemoryChartProps { + containers: ContainerResource[]; + height?: number; +} + +function memColor(mb: number, maxMb: number): string { + const ratio = maxMb > 0 ? mb / maxMb : 0; + if (ratio > 0.7) return '#ff4d4f'; + if (ratio > 0.4) return '#faad14'; + return '#52c41a'; +} + +export default function ContainerMemoryChart({ containers, height = 180 }: ContainerMemoryChartProps) { + const sorted = [...containers] + .filter(c => c.memoryMB > 0) + .sort((a, b) => b.memoryMB - a.memoryMB); + + if (sorted.length === 0) { + return No container data; + } + + const maxMem = sorted[0]?.memoryMB ?? 1; + const chartData = sorted.map(c => ({ name: c.label, memory: c.memoryMB })); + + return ( + + + + + + `${v} MB`} contentStyle={{ fontSize: 12, borderRadius: 6 }} /> + + {chartData.map((entry, i) => ( + + ))} + + + + ); +} diff --git a/admin/src/components/dashboard/ContainerPopover.tsx b/admin/src/components/dashboard/ContainerPopover.tsx new file mode 100644 index 00000000..68effdc9 --- /dev/null +++ b/admin/src/components/dashboard/ContainerPopover.tsx @@ -0,0 +1,58 @@ +import { Popover, Progress, Typography, Space, Flex } from 'antd'; +import type { ContainerResource } from '@/types/api'; + +const { Text } = Typography; + +interface ContainerPopoverProps { + resource?: ContainerResource; + children: React.ReactNode; +} + +export default function ContainerPopover({ resource, children }: ContainerPopoverProps) { + if (!resource) return <>{children}; + + const memPct = resource.memoryLimitMB > 0 + ? Math.round((resource.memoryMB / resource.memoryLimitMB) * 100) + : 0; + + const content = ( + + + CPU + {resource.cpuPercent.toFixed(1)}% + + 80 ? '#ff4d4f' : resource.cpuPercent > 50 ? '#faad14' : '#52c41a'} + /> + + Memory + {resource.memoryMB}MB{resource.memoryLimitMB > 0 ? ` / ${resource.memoryLimitMB}MB` : ''} + + {resource.memoryLimitMB > 0 && ( + 80 ? '#ff4d4f' : memPct > 60 ? '#faad14' : '#52c41a'} + /> + )} + + Network Rx + {resource.networkRxKBps.toFixed(1)} KB/s + + + Network Tx + {resource.networkTxKBps.toFixed(1)} KB/s + + + ); + + return ( + + {children} + + ); +} diff --git a/admin/src/components/dashboard/LatencyBandsChart.tsx b/admin/src/components/dashboard/LatencyBandsChart.tsx new file mode 100644 index 00000000..18ae6ef3 --- /dev/null +++ b/admin/src/components/dashboard/LatencyBandsChart.tsx @@ -0,0 +1,50 @@ +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, + Legend, ResponsiveContainer, +} from 'recharts'; +import { Typography } from 'antd'; +import type { TimeSeriesResult } from '@/types/api'; + +const { Text } = Typography; + +interface LatencyBandsChartProps { + data: TimeSeriesResult; + height?: number; +} + +function formatTime(ts: number): string { + const d = new Date(ts * 1000); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +export default function LatencyBandsChart({ data, height = 200 }: LatencyBandsChartProps) { + const p50 = data.latency_p50; + const p95 = data.latency_p95; + const p99 = data.latency_p99; + + if (!p50?.timestamps?.length) { + return No latency data; + } + + const chartData = p50.timestamps.map((ts, i) => ({ + time: formatTime(ts), + p50: Math.round((p50.values[i] || 0) * 1000), + p95: Math.round((p95?.values[i] || 0) * 1000), + p99: Math.round((p99?.values[i] || 0) * 1000), + })); + + return ( + + + + + + `${v}ms`} /> + + + + + + + ); +} diff --git a/admin/src/components/dashboard/MiniDonutChart.tsx b/admin/src/components/dashboard/MiniDonutChart.tsx new file mode 100644 index 00000000..90fd3937 --- /dev/null +++ b/admin/src/components/dashboard/MiniDonutChart.tsx @@ -0,0 +1,49 @@ +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; + +interface DonutDatum { + name: string; + value: number; + color: string; +} + +interface MiniDonutChartProps { + data: DonutDatum[]; + height?: number; + innerRadius?: number; + outerRadius?: number; +} + +export default function MiniDonutChart({ + data, + height = 120, + innerRadius = 28, + outerRadius = 48, +}: MiniDonutChartProps) { + const filtered = data.filter(d => d.value > 0); + if (filtered.length === 0) return null; + + return ( + + + + {filtered.map((entry, i) => ( + + ))} + + [`${value}`, `${name}`]} + contentStyle={{ fontSize: 12, padding: '4px 8px', borderRadius: 6 }} + /> + + + ); +} diff --git a/admin/src/components/dashboard/RequestTrafficChart.tsx b/admin/src/components/dashboard/RequestTrafficChart.tsx new file mode 100644 index 00000000..42c3af1a --- /dev/null +++ b/admin/src/components/dashboard/RequestTrafficChart.tsx @@ -0,0 +1,50 @@ +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, + Legend, ResponsiveContainer, +} from 'recharts'; +import { Typography } from 'antd'; +import type { TimeSeriesResult } from '@/types/api'; + +const { Text } = Typography; + +interface RequestTrafficChartProps { + data: TimeSeriesResult; + height?: number; +} + +function formatTime(ts: number): string { + const d = new Date(ts * 1000); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +export default function RequestTrafficChart({ data, height = 200 }: RequestTrafficChartProps) { + const series2xx = data.request_rate_2xx; + const series4xx = data.request_rate_4xx; + const series5xx = data.request_rate_5xx; + + if (!series2xx?.timestamps?.length) { + return No traffic data; + } + + const chartData = series2xx.timestamps.map((ts, i) => ({ + time: formatTime(ts), + '2xx': series2xx.values[i] || 0, + '4xx': series4xx?.values[i] || 0, + '5xx': series5xx?.values[i] || 0, + })); + + return ( + + + + + + + + + + + + + ); +} diff --git a/admin/src/components/dashboard/SystemGauges.tsx b/admin/src/components/dashboard/SystemGauges.tsx new file mode 100644 index 00000000..277e33f3 --- /dev/null +++ b/admin/src/components/dashboard/SystemGauges.tsx @@ -0,0 +1,58 @@ +import { Progress, Flex, Typography } from 'antd'; +import type { SystemInfo } from '@/types/api'; + +const { Text } = Typography; + +function gaugeColor(percent: number): string { + if (percent > 90) return '#ff4d4f'; + if (percent > 70) return '#faad14'; + return '#52c41a'; +} + +interface SystemGaugesProps { + systemInfo: SystemInfo; +} + +export default function SystemGauges({ systemInfo }: SystemGaugesProps) { + const cpuPercent = Math.min( + Math.round(((systemInfo.cpu.loadAvg[0] ?? 0) / systemInfo.cpu.cores) * 100), + 100, + ); + + return ( + +
+ {p}%} + /> +
CPU
+
+
+ {p}%} + /> +
Memory
+
+ {systemInfo.disk && ( +
+ {p}%} + /> +
Disk
+
+ )} +
+ ); +} diff --git a/admin/src/components/map/AreaImportWizard.tsx b/admin/src/components/map/AreaImportWizard.tsx new file mode 100644 index 00000000..bba7a126 --- /dev/null +++ b/admin/src/components/map/AreaImportWizard.tsx @@ -0,0 +1,586 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + Steps, + Button, + Space, + Card, + Checkbox, + Select, + Slider, + InputNumber, + Statistic, + Alert, + Progress, + Tag, + Row, + Col, + Typography, + Spin, + Result, +} from 'antd'; +import { + GlobalOutlined, + DatabaseOutlined, + CompassOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + LoadingOutlined, + MinusCircleOutlined, +} from '@ant-design/icons'; +import { api } from '@/lib/api'; +import type { + Cut, + MapSettings, + AreaImportPreviewResult, + AreaImportProgress, + AreaImportSourceStatus, +} from '@/types/api'; + +const { Text, Title } = Typography; + +interface AreaImportWizardProps { + cuts: Cut[]; + onComplete?: () => void; +} + +const SOURCE_STATUS_ICONS: Record = { + pending: , + running: , + complete: , + failed: , + skipped: , +}; + +export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardProps) { + const [currentStep, setCurrentStep] = useState(0); + + // Step 0: Define area + const [areaType, setAreaType] = useState<'cut' | 'viewport'>('cut'); + const [selectedCutId, setSelectedCutId] = useState(); + const [mapSettings, setMapSettings] = useState(null); + const [mapSettingsLoading, setMapSettingsLoading] = useState(false); + + // Step 1: Sources + const [osmEnabled, setOsmEnabled] = useState(true); + const [narEnabled, setNarEnabled] = useState(true); + const [narResidentialOnly, setNarResidentialOnly] = useState(true); + const [rgEnabled, setRgEnabled] = useState(false); + const [rgSpacing, setRgSpacing] = useState(100); + const [rgMaxPoints, setRgMaxPoints] = useState(500); + + // Step 2: Preview + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + + // Step 3: Progress + const [progress, setProgress] = useState(null); + const [importing, setImporting] = useState(false); + const pollRef = useRef>(undefined); + + // Load map settings for viewport mode + useEffect(() => { + if (areaType === 'viewport' && !mapSettings) { + setMapSettingsLoading(true); + api.get('/map/settings') + .then(({ data }) => setMapSettings(data)) + .catch(() => {}) + .finally(() => setMapSettingsLoading(false)); + } + }, [areaType, mapSettings]); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + const buildRequestBody = useCallback(() => { + const sources: Record = { + osm: osmEnabled, + nar: narEnabled ? { residentialOnly: narResidentialOnly } : false, + reverseGeocode: rgEnabled ? { gridSpacingMeters: rgSpacing, maxPoints: rgMaxPoints } : false, + }; + + const body: Record = { sources }; + + if (areaType === 'cut') { + body.areaType = 'cut'; + body.cutId = selectedCutId; + } else { + body.areaType = 'viewport'; + body.center = { + lat: mapSettings?.latitude ? Number(mapSettings.latitude) : 53.5, + lng: mapSettings?.longitude ? Number(mapSettings.longitude) : -113.5, + }; + body.zoom = mapSettings?.zoom ?? 13; + } + + return body; + }, [areaType, selectedCutId, mapSettings, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]); + + const fetchPreview = async () => { + setPreviewLoading(true); + setPreviewError(null); + try { + const { data } = await api.post('/map/area-import/preview', buildRequestBody()); + setPreview(data); + } catch (err: any) { + setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed'); + } finally { + setPreviewLoading(false); + } + }; + + const startImport = async () => { + setImporting(true); + try { + const body = { ...buildRequestBody(), deduplicateRadius: 5, batchSize: 1000 }; + const { data } = await api.post('/map/area-import', body); + const currentImportId = data.importId; + setCurrentStep(3); + + // Start polling + pollRef.current = setInterval(async () => { + try { + const { data: prog } = await api.get(`/map/area-import/status/${currentImportId}`); + setProgress(prog); + if (prog.status === 'complete' || prog.status === 'failed') { + if (pollRef.current) clearInterval(pollRef.current); + } + } catch { + // Ignore polling errors + } + }, 2000); + } catch (err: any) { + setPreviewError(err?.response?.data?.error?.message || 'Failed to start import'); + setImporting(false); + } + }; + + const canProceedStep0 = areaType === 'cut' ? !!selectedCutId : (!!mapSettings?.latitude && !!mapSettings?.longitude); + const canProceedStep1 = osmEnabled || narEnabled || rgEnabled; + + const steps = [ + { + title: 'Define Area', + content: ( +
+
+ Area Source: + ({ value: c.id, label: c.name }))} + /> +
+ )} + + {areaType === 'viewport' && ( +
+ {mapSettingsLoading ? ( + + ) : mapSettings?.latitude && mapSettings?.longitude ? ( + + + + + + + + + + + + + + A bounding box will be derived from the map center and zoom level. + + + ) : ( + + )} +
+ )} +
+ ), + }, + { + title: 'Sources', + content: ( +
+ setOsmEnabled(!osmEnabled)} + > + { e.stopPropagation(); setOsmEnabled(e.target.checked); }}> + + + OpenStreetMap (Overpass API) + + + + Fetches address nodes and building footprints from OSM. Best for urban areas with good community mapping. + + + + setNarEnabled(!narEnabled)} + > + { e.stopPropagation(); setNarEnabled(e.target.checked); }}> + + + NAR (National Address Register) + + + + Official Canadian address data. Requires NAR files on server. Highest priority for deduplication. + + {narEnabled && ( +
e.stopPropagation()}> + setNarResidentialOnly(e.target.checked)}> + Residential only + +
+ )} +
+ + setRgEnabled(!rgEnabled)} + > + { e.stopPropagation(); setRgEnabled(e.target.checked); }}> + + + Reverse Geocode Grid + + + + Lays a grid of points and reverse geocodes each one. Slow but fills gaps not covered by other sources. Low confidence (40). + + {rgEnabled && ( +
e.stopPropagation()}> + +
+ Grid spacing (meters): + +
+
+ Max points: + v && setRgMaxPoints(v)} size="small" /> +
+
+
+ )} +
+ + {!canProceedStep1 && ( + + )} +
+ ), + }, + { + title: 'Preview', + content: ( +
+ {previewLoading && ( +
+ +
+ Estimating import size... +
+
+ )} + + {previewError && ( + + )} + + {preview && !previewLoading && ( + <> + + + + + + + + + + + + + + = 0 ? preview.estimates.osm : 0) + + preview.estimates.nar + + preview.estimates.reverseGeocode + } + valueStyle={{ fontSize: 16 }} + /> + + + + + + + {osmEnabled && ( + + OSM} + value={preview.estimates.osm >= 0 ? preview.estimates.osm : '?'} + valueStyle={{ fontSize: 16 }} + /> + + )} + {narEnabled && ( + + NAR} + value={preview.estimates.nar} + suffix={preview.narProvincesDetected.length > 0 ? '' : undefined} + valueStyle={{ fontSize: 16 }} + /> + {preview.narProvincesDetected.length > 0 && ( + + Provinces: {preview.narProvincesDetected.join(', ')} + + )} + {preview.narProvincesDetected.length === 0 && ( + No NAR data for this area + )} + + )} + {rgEnabled && ( + + Rev. Geocode} + value={preview.estimates.reverseGeocode} + suffix="points" + valueStyle={{ fontSize: 16 }} + /> + + )} + + + + {(preview.estimates.osm + preview.estimates.nar + preview.estimates.reverseGeocode) > 10000 && ( + + )} + + {preview.areaSqKm > 100 && osmEnabled && ( + + )} + + + + )} +
+ ), + }, + { + title: 'Progress', + content: ( +
+ {progress ? ( + <> + {progress.status === 'complete' ? ( + onComplete?.()}> + Done + , + ]} + /> + ) : progress.status === 'failed' ? ( + { setCurrentStep(2); setImporting(false); }}> + Back to Preview + , + ]} + /> + ) : ( + <> + + {progress.status === 'initializing' ? 'Initializing...' : + progress.status === 'creating-records' ? 'Creating records...' : 'Running sources...'} + + + + {(['osm', 'nar', 'reverseGeocode'] as const).map((source) => { + const sp = progress.sources[source]; + const labels = { osm: 'OpenStreetMap', nar: 'NAR', reverseGeocode: 'Reverse Geocode' }; + return ( +
+ + {SOURCE_STATUS_ICONS[sp.status]} + {labels[source]} + {sp.status} + {sp.candidatesFound > 0 && ( + {sp.candidatesFound} found + )} + + {sp.message && ( + + {sp.message} + + )} + {sp.error && ( + + {sp.error} + + )} +
+ ); + })} +
+ + + + + + + + + + + + + + {progress.status === 'creating-records' && progress.totalCandidates > 0 && ( + + )} + + )} + + ) : ( +
+ +
+ Starting import... +
+
+ )} +
+ ), + }, + ]; + + const handleNext = () => { + if (currentStep === 1) { + // Moving to preview step — fetch preview + setCurrentStep(2); + // Fetch preview after state update + setTimeout(() => fetchPreview(), 0); + } else { + setCurrentStep(currentStep + 1); + } + }; + + return ( +
+ ({ title: s.title }))} + /> + +
+ {steps[currentStep]?.content} +
+ + {currentStep < 2 && ( +
+ + +
+ )} + + {currentStep === 2 && !previewLoading && !preview && !previewError && ( +
+ + +
+ )} + + {currentStep === 2 && (preview || previewError) && !importing && ( +
+ +
+ )} +
+ ); +} diff --git a/admin/src/components/media/AddToPlaylistModal.tsx b/admin/src/components/media/AddToPlaylistModal.tsx new file mode 100644 index 00000000..7783ec6f --- /dev/null +++ b/admin/src/components/media/AddToPlaylistModal.tsx @@ -0,0 +1,242 @@ +import { useState, useEffect } from 'react'; +import { Modal, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd'; +import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons'; +import { mediaApi } from '@/lib/media-api'; +import { mediaPublicApi } from '@/lib/media-public-api'; +import type { PlaylistSummary } from '@/types/media'; + +const { Text } = Typography; + +interface AddToPlaylistModalProps { + videoId: number; + open: boolean; + onClose: () => void; +} + +interface PlaylistWithSelected extends PlaylistSummary { + hasVideo: boolean; +} + +export default function AddToPlaylistModal({ + videoId, + open, + onClose, +}: AddToPlaylistModalProps) { + const { token } = theme.useToken(); + const [playlists, setPlaylists] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [selections, setSelections] = useState>({}); + + // Inline create state + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(''); + const [creating, setCreating] = useState(false); + + // Fetch user's playlists and check which ones contain the video + useEffect(() => { + if (!open) return; + + const fetchPlaylists = async () => { + try { + setLoading(true); + const { data } = await mediaApi.get('/playlists/my'); + const userPlaylists: PlaylistSummary[] = data.data || []; + + // For each playlist, check if it contains the video + const withSelection = await Promise.all( + userPlaylists.map(async (p) => { + try { + const { data: detail } = await mediaPublicApi.get( + `/playlists/${p.id}` + ); + const hasVideo = (detail.videos || []).some( + (v: any) => v.mediaId === videoId + ); + return { ...p, hasVideo }; + } catch { + return { ...p, hasVideo: false }; + } + }) + ); + + setPlaylists(withSelection); + // Initialize selections from current state + const initial: Record = {}; + withSelection.forEach((p) => { + initial[p.id] = p.hasVideo; + }); + setSelections(initial); + } catch { + message.error('Failed to load playlists'); + } finally { + setLoading(false); + } + }; + + fetchPlaylists(); + }, [open, videoId]); + + const handleToggle = (playlistId: number, checked: boolean) => { + setSelections((prev) => ({ ...prev, [playlistId]: checked })); + }; + + const handleSave = async () => { + try { + setSaving(true); + + const promises: Promise[] = []; + + for (const playlist of playlists) { + const wasInPlaylist = playlist.hasVideo; + const shouldBeInPlaylist = selections[playlist.id]; + + if (shouldBeInPlaylist && !wasInPlaylist) { + // Add to playlist + promises.push( + mediaApi.post(`/playlists/${playlist.id}/videos`, { + mediaId: videoId, + }) + ); + } else if (!shouldBeInPlaylist && wasInPlaylist) { + // Remove from playlist + promises.push( + mediaApi.delete(`/playlists/${playlist.id}/videos/${videoId}`) + ); + } + } + + await Promise.all(promises); + message.success('Playlists updated'); + onClose(); + } catch { + message.error('Failed to update playlists'); + } finally { + setSaving(false); + } + }; + + const handleCreateNew = async () => { + if (!newName.trim()) return; + + try { + setCreating(true); + const { data } = await mediaApi.post('/playlists/', { + name: newName.trim(), + isPublic: false, + }); + + // Add video to the new playlist + await mediaApi.post(`/playlists/${data.id}/videos`, { mediaId: videoId }); + + message.success(`Created "${data.name}" and added video`); + setNewName(''); + setShowCreate(false); + + // Refresh the list + setPlaylists((prev) => [ + ...prev, + { ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null }, + ]); + setSelections((prev) => ({ ...prev, [data.id]: true })); + } catch (error: any) { + if (error.response?.status === 409) { + message.error('You already have a playlist with this name'); + } else { + message.error('Failed to create playlist'); + } + } finally { + setCreating(false); + } + }; + + return ( + + {loading ? ( +
+ +
+ ) : ( + <> + {playlists.length === 0 && !showCreate ? ( +
+ + + You don't have any playlists yet + +
+ ) : ( +
+ {playlists.map((p) => ( +
+ handleToggle(p.id, e.target.checked)} + > + + {p.name} + + ({p.videoCount} videos) + + + +
+ ))} +
+ )} + + + + {showCreate ? ( + + setNewName(e.target.value)} + onPressEnter={handleCreateNew} + maxLength={100} + autoFocus + /> + + + + ) : ( + + )} + + )} +
+ ); +} diff --git a/admin/src/components/media/BulkAddToPlaylistModal.tsx b/admin/src/components/media/BulkAddToPlaylistModal.tsx new file mode 100644 index 00000000..d6ade629 --- /dev/null +++ b/admin/src/components/media/BulkAddToPlaylistModal.tsx @@ -0,0 +1,188 @@ +import { useState, useEffect } from 'react'; +import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { mediaApi } from '@/lib/media-api'; +import type { PlaylistSummary } from '@/types/media'; + +const { Text } = Typography; + +interface BulkAddToPlaylistModalProps { + videoIds: number[]; + open: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +export default function BulkAddToPlaylistModal({ + videoIds, + open, + onClose, + onSuccess, +}: BulkAddToPlaylistModalProps) { + const [playlists, setPlaylists] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [selectedPlaylistId, setSelectedPlaylistId] = useState(null); + + // Inline create state + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(''); + const [creating, setCreating] = useState(false); + + useEffect(() => { + if (!open) return; + + const fetchPlaylists = async () => { + try { + setLoading(true); + const { data } = await mediaApi.get('/playlists/my'); + setPlaylists(data.data || []); + } catch { + message.error('Failed to load playlists'); + } finally { + setLoading(false); + } + }; + + fetchPlaylists(); + setSelectedPlaylistId(null); + setShowCreate(false); + setNewName(''); + }, [open]); + + const handleAdd = async () => { + if (!selectedPlaylistId || videoIds.length === 0) return; + + try { + setSaving(true); + let added = 0; + let skipped = 0; + + for (const mediaId of videoIds) { + try { + await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId }); + added++; + } catch (error: any) { + if (error.response?.status === 409) { + skipped++; + } else { + throw error; + } + } + } + + const parts: string[] = []; + if (added > 0) parts.push(`${added} video${added > 1 ? 's' : ''} added`); + if (skipped > 0) parts.push(`${skipped} already in playlist`); + message.success(parts.join(', ')); + onSuccess?.(); + } catch { + message.error('Failed to add videos to playlist'); + } finally { + setSaving(false); + } + }; + + const handleCreateNew = async () => { + if (!newName.trim()) return; + + try { + setCreating(true); + const { data } = await mediaApi.post('/playlists/', { + name: newName.trim(), + isPublic: false, + }); + + setPlaylists((prev) => [...prev, data]); + setSelectedPlaylistId(data.id); + setNewName(''); + setShowCreate(false); + message.success(`Created "${data.name}"`); + } catch (error: any) { + if (error.response?.status === 409) { + message.error('You already have a playlist with this name'); + } else { + message.error('Failed to create playlist'); + } + } finally { + setCreating(false); + } + }; + + const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId); + + return ( + 1 ? 's' : ''} to playlist`} + open={open} + onOk={handleAdd} + onCancel={onClose} + confirmLoading={saving} + okText="Add" + okButtonProps={{ disabled: !selectedPlaylistId }} + > + {loading ? ( +
+ +
+ ) : ( + <> + setNewName(e.target.value)} + onPressEnter={handleCreateNew} + maxLength={100} + autoFocus + /> + + + + ) : ( + + )} + + )} +
+ ); +} diff --git a/admin/src/components/media/ChatNotificationToast.tsx b/admin/src/components/media/ChatNotificationToast.tsx new file mode 100644 index 00000000..a793f6d1 --- /dev/null +++ b/admin/src/components/media/ChatNotificationToast.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; +import { notification, Button, Space, Typography } from 'antd'; +import { MessageOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import type { ChatNotification } from '@/hooks/useChatNotifications'; + +const { Text } = Typography; + +interface ChatNotificationToastProps { + notifications: ChatNotification[]; + clearNotification: (id: string) => void; +} + +export default function ChatNotificationToast({ + notifications, + clearNotification, +}: ChatNotificationToastProps) { + const [api, contextHolder] = notification.useNotification(); + const navigate = useNavigate(); + const shownRef = useRef>(new Set()); + + useEffect(() => { + for (const notif of notifications) { + if (shownRef.current.has(notif.id)) continue; + shownRef.current.add(notif.id); + + api.info({ + key: notif.id, + message: ( + + + {notif.commenterName} + replied + + ), + description: ( +
+ + on {notif.videoTitle} + +
+ {notif.contentPreview} +
+
+ ), + placement: 'bottomRight', + duration: 8, + btn: ( + + ), + onClose: () => { + clearNotification(notif.id); + }, + }); + } + }, [notifications, api, clearNotification, navigate]); + + // Cleanup shown IDs when notifications are cleared + useEffect(() => { + const currentIds = new Set(notifications.map((n) => n.id)); + for (const id of shownRef.current) { + if (!currentIds.has(id)) { + shownRef.current.delete(id); + } + } + }, [notifications]); + + return <>{contextHolder}; +} diff --git a/admin/src/components/media/CommentSection.tsx b/admin/src/components/media/CommentSection.tsx index 4bc2e15d..af75e6f0 100644 --- a/admin/src/components/media/CommentSection.tsx +++ b/admin/src/components/media/CommentSection.tsx @@ -13,6 +13,7 @@ import { } from 'antd'; import { UserOutlined, SendOutlined } from '@ant-design/icons'; import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api'; +import { useAuthStore } from '@/stores/auth.store'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -85,8 +86,7 @@ export default function CommentSection({ videoId }: CommentSectionProps) { } // Check if user is logged in - const accessToken = localStorage.getItem('accessToken'); - if (!accessToken) { + if (!useAuthStore.getState().isAuthenticated) { message.warning('Please log in to comment'); return; } diff --git a/admin/src/components/media/CreatePlaylistModal.tsx b/admin/src/components/media/CreatePlaylistModal.tsx new file mode 100644 index 00000000..5f64b00c --- /dev/null +++ b/admin/src/components/media/CreatePlaylistModal.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd'; +import { mediaApi } from '@/lib/media-api'; + +interface CreatePlaylistModalProps { + open: boolean; + onClose: () => void; + onCreated?: (playlist: any) => void; +} + +export default function CreatePlaylistModal({ + open, + onClose, + onCreated, +}: CreatePlaylistModalProps) { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + setLoading(true); + + const { data } = await mediaApi.post('/playlists/', { + name: values.name, + description: values.description || undefined, + isPublic: values.isPublic ?? false, + }); + + message.success('Playlist created'); + form.resetFields(); + onCreated?.(data); + onClose(); + } catch (error: any) { + if (error.response?.status === 409) { + message.error('You already have a playlist with this name'); + } else if (!error.errorFields) { + message.error('Failed to create playlist'); + } + } finally { + setLoading(false); + } + }; + + return ( + { + form.resetFields(); + onClose(); + }} + placement="right" + width={420} + style={{ top: 64 }} + styles={{ body: { paddingTop: 24 } }} + extra={ + + + + + } + > +
+ + + + + + + + + + + +
+
+ ); +} diff --git a/admin/src/components/media/EditPlaylistModal.tsx b/admin/src/components/media/EditPlaylistModal.tsx new file mode 100644 index 00000000..779edddd --- /dev/null +++ b/admin/src/components/media/EditPlaylistModal.tsx @@ -0,0 +1,260 @@ +import { useState, useEffect } from 'react'; +import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme } from 'antd'; +import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +import { mediaApi } from '@/lib/media-api'; +import { mediaPublicApi } from '@/lib/media-public-api'; +import type { PlaylistVideoItem } from '@/types/media'; + +const { Text } = Typography; + +interface EditPlaylistModalProps { + playlistId: number | null; + open: boolean; + onClose: () => void; + onUpdated?: () => void; +} + +function formatDuration(seconds: number | null): string { + if (!seconds) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +export default function EditPlaylistModal({ + playlistId, + open, + onClose, + onUpdated, +}: EditPlaylistModalProps) { + const [form] = Form.useForm(); + const { token } = theme.useToken(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [videos, setVideos] = useState([]); + + useEffect(() => { + if (!open || !playlistId) return; + + const fetchPlaylist = async () => { + try { + setLoading(true); + const { data } = await mediaPublicApi.get(`/playlists/${playlistId}`); + setVideos(data.videos || []); + form.setFieldsValue({ + name: data.name, + description: data.description, + isPublic: data.isPublic, + }); + } catch { + message.error('Failed to load playlist'); + } finally { + setLoading(false); + } + }; + + fetchPlaylist(); + }, [open, playlistId]); + + const handleSaveDetails = async () => { + try { + const values = await form.validateFields(); + setSaving(true); + + await mediaApi.put(`/playlists/${playlistId}`, { + name: values.name, + description: values.description || undefined, + isPublic: values.isPublic, + }); + + message.success('Playlist updated'); + onUpdated?.(); + } catch (error: any) { + if (error.response?.status === 409) { + message.error('You already have a playlist with this name'); + } else if (!error.errorFields) { + message.error('Failed to update playlist'); + } + } finally { + setSaving(false); + } + }; + + const handleRemoveVideo = async (mediaId: number) => { + try { + await mediaApi.delete(`/playlists/${playlistId}/videos/${mediaId}`); + setVideos((prev) => prev.filter((v) => v.mediaId !== mediaId)); + message.success('Video removed'); + onUpdated?.(); + } catch { + message.error('Failed to remove video'); + } + }; + + const handleMoveVideo = async (index: number, direction: 'up' | 'down') => { + const newVideos = [...videos]; + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= newVideos.length) return; + + const temp = newVideos[index]!; + newVideos[index] = newVideos[targetIndex]!; + newVideos[targetIndex] = temp; + + // Update positions + const reordered = newVideos.map((v, i) => ({ + ...v, + position: i, + })); + + setVideos(reordered); + + try { + await mediaApi.put(`/playlists/${playlistId}/videos/reorder`, { + items: reordered.map((v) => ({ mediaId: v.mediaId, position: v.position })), + }); + } catch { + message.error('Failed to reorder'); + } + }; + + return ( + { + form.resetFields(); + onClose(); + }} + placement="right" + width={520} + style={{ top: 64 }} + loading={loading} + > + + + + + + + + + + + + + + + + ), + }, + { + key: 'videos', + label: `Videos (${videos.length})`, + children: ( + { + const title = item.video.title || item.video.filename.replace(/\.[^/.]+$/, ''); + return ( + } + disabled={index === 0} + onClick={() => handleMoveVideo(index, 'up')} + />, +