Bunch of stuff again

This commit is contained in:
bunker-admin 2026-02-16 18:48:54 -07:00
parent 7895ce683e
commit a7978de5a0
135 changed files with 19366 additions and 1002 deletions

View File

@ -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 <Navigate to="/login" replace />;
if (user && ADMIN_ROLES.includes(user.role)) return <Navigate to="/app" replace />;
if (user && isAdmin(user)) return <Navigate to="/app" replace />;
return <Navigate to="/volunteer" replace />;
}
@ -126,6 +139,24 @@ export default function App() {
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route index element={<CampaignsListPage />} />
</Route>
<Route path="/campaigns/create" element={
<FeatureGate feature="enableInfluence">
<ProtectedRoute>
<PublicLayout />
</ProtectedRoute>
</FeatureGate>
}>
<Route index element={<CreateCampaignPage />} />
</Route>
<Route path="/campaigns/mine" element={
<FeatureGate feature="enableInfluence">
<ProtectedRoute>
<PublicLayout />
</ProtectedRoute>
</FeatureGate>
}>
<Route index element={<MyCampaignsPage />} />
</Route>
<Route path="/campaign" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route path=":slug" element={<CampaignPage />} />
<Route path=":slug/responses" element={<ResponseWallPage />} />
@ -139,8 +170,20 @@ export default function App() {
{/* Public Media Gallery (purple theme) — feature-gated */}
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<MediaGalleryPage />} />
<Route path="shorts" element={<ShortsPage />} />
<Route path=":category" element={<MediaGalleryPage />} />
</Route>
<Route path="/gallery/curated" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<PlaylistBrowsePage />} />
</Route>
<Route path="/gallery/curated/share/:token" element={<FeatureGate feature="enableMediaFeatures"><PlaylistViewerPage /></FeatureGate>} />
<Route path="/gallery/curated/:playlistId" element={<FeatureGate feature="enableMediaFeatures"><PlaylistViewerPage /></FeatureGate>} />
<Route path="/gallery/my-stats" element={<FeatureGate feature="enableMediaFeatures"><ProtectedRoute><MediaPublicLayout /></ProtectedRoute></FeatureGate>}>
<Route index element={<MyStatsPage />} />
</Route>
<Route path="/gallery/my-settings" element={<FeatureGate feature="enableMediaFeatures"><ProtectedRoute><MediaPublicLayout /></ProtectedRoute></FeatureGate>}>
<Route index element={<MySettingsPage />} />
</Route>
<Route path="/gallery/watch/:id" element={<FeatureGate feature="enableMediaFeatures"><MediaViewerPage /></FeatureGate>} />
{/* Email link alias for video viewer */}
<Route path="/media/:id" element={<MediaViewerPage />} />
@ -175,6 +218,8 @@ export default function App() {
/>
<Route path="/login" element={<LoginPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route
path="/app"
element={
@ -232,6 +277,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="campaign-moderation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CampaignModerationPage />
</ProtectedRoute>
}
/>
<Route
path="listmonk"
element={
@ -424,6 +477,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="media/curated"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<PlaylistManagementPage />
</ProtectedRoute>
}
/>
<Route
path="media/moderation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CommentModerationPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<RoleAwareRedirect />} />
</Routes>

View File

@ -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: <SendOutlined />, label: 'Campaigns' },
{ key: '/app/campaign-moderation', icon: <FileTextOutlined />, label: 'Campaign Review' },
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
@ -120,6 +122,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
label: 'Media Library',
children: [
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Videos' },
{ key: '/app/media/curated', icon: <OrderedListOutlined />, label: 'Curated' },
{ key: '/app/media/moderation', icon: <MessageOutlined />, label: 'Moderation' },
{ key: '/app/media/jobs', icon: <HistoryOutlined />, label: 'Processing Jobs' },
],
});

View File

@ -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<AuthMode>('signin');
const [loginForm] = Form.useForm();
const [registerForm] = Form.useForm();
const [resendLoading, setResendLoading] = useState(false);
const [resendSent, setResendSent] = useState(false);
// Clear errors when switching modes
useEffect(() => {
clearError();
setResendSent(false);
}, [mode]); // eslint-disable-line react-hooks/exhaustive-deps
const handleLogin = async (values: { email: string; password: string }) => {
try {
await login(values.email, values.password);
loginForm.resetFields();
onSuccess();
} catch {
// Error is set in store
}
};
const handleRegister = async (values: { name: string; email: string; password: string }) => {
try {
const result = await register(values.name, values.email, values.password);
if (result?.requiresVerification) {
// Stay open to show verification message — don't call onSuccess
registerForm.resetFields();
return;
}
registerForm.resetFields();
onSuccess();
} catch {
// Error is set in store
}
};
const handleResendVerification = async () => {
const email = loginForm.getFieldValue('email');
if (!email) return;
setResendLoading(true);
try {
await axios.post(`${API_URL}/api/auth/resend-verification`, { email });
setResendSent(true);
} catch {
// Ignore — always show success for security
setResendSent(true);
} finally {
setResendLoading(false);
}
};
const handleCancel = () => {
loginForm.resetFields();
registerForm.resetFields();
clearError();
onCancel();
};
return (
<Modal
open={open}
onCancel={handleCancel}
footer={null}
destroyOnHidden
width={420}
>
{title && (
<div style={{ textAlign: 'center', marginBottom: 4 }}>
<Text strong style={{ fontSize: 18 }}>{title}</Text>
</div>
)}
{subtitle && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: 13 }}>{subtitle}</Text>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 20 }}>
<Segmented
options={[
{ label: 'Sign In', value: 'signin' },
{ label: 'Register', value: 'register' },
]}
value={mode}
onChange={(val) => setMode(val as AuthMode)}
/>
</div>
{/* Registration success — verification required */}
{registrationMessage && (
<Alert
message="Check Your Email"
description={registrationMessage}
type="success"
showIcon
icon={<CheckCircleOutlined />}
closable
onClose={() => clearError()}
style={{ marginBottom: 16 }}
/>
)}
{error && (
<Alert
message={error}
type="error"
showIcon
closable
onClose={() => clearError()}
description={
errorCode === 'EMAIL_NOT_VERIFIED' ? (
resendSent ? (
<Text type="success" style={{ fontSize: 12 }}>Verification email sent! Check your inbox.</Text>
) : (
<Button
type="link"
size="small"
loading={resendLoading}
onClick={handleResendVerification}
style={{ padding: 0 }}
>
Resend verification email
</Button>
)
) : errorCode === 'ACCOUNT_PENDING' ? (
<Text type="secondary" style={{ fontSize: 12 }}>
An admin will review your account shortly.
</Text>
) : undefined
}
style={{ marginBottom: 16 }}
/>
)}
{mode === 'signin' ? (
<Form form={loginForm} onFinish={handleLogin} layout="vertical" size="large">
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" autoFocus />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Please enter your password' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={isLoading} block>
Sign In
</Button>
</Form.Item>
</Form>
) : (
<Form form={registerForm} onFinish={handleRegister} layout="vertical" size="large">
<Form.Item
name="name"
rules={[{ required: true, message: 'Please enter your name' }]}
>
<Input prefix={<UserOutlined />} placeholder="Full Name" autoFocus />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: 'Please enter a password' },
{ min: 12, message: 'Password must be at least 12 characters' },
{
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Must contain uppercase, lowercase, and a digit',
},
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password (12+ chars)" />
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: 'Please confirm your password' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('Passwords do not match'));
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Confirm Password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={isLoading} block>
Create Account
</Button>
</Form.Item>
</Form>
)}
</Modal>
);
}

View File

@ -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)',
},
}}
>
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
{/* Desktop: Show sidebar, Mobile: Hide */}
{!isMobile && <MediaSidebar />}
<ChatBarProvider>
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
{/* Desktop: Show sidebar, Mobile: Hide */}
{!isMobile && <MediaSidebar />}
{/* Main content area */}
<main
style={{
marginLeft: mainContentMarginLeft,
minHeight: '100vh',
overflowY: 'auto',
paddingBottom: isMobile ? 56 : 0, // Space for mobile bottom nav
transition: 'margin-left 0.3s ease',
background: colorBgBase,
}}
>
<div
{/* Main content area */}
<main
style={{
width: '100%',
margin: '0 auto',
padding: isMobile ? '16px 12px' : '24px 32px',
maxWidth: 1400, // Wider for video grid
marginLeft: mainContentMarginLeft,
minHeight: '100vh',
overflowY: 'auto',
paddingBottom: 48, // Space for bottom search bar
transition: 'margin-left 0.3s ease',
background: colorBgBase,
}}
>
<Outlet />
</div>
</main>
<div
style={{
width: '100%',
margin: '0 auto',
padding: isMobile ? '8px 8px' : '12px 12px',
}}
>
<Outlet />
</div>
</main>
{/* Mobile: Show bottom nav, Desktop: Hide */}
<MediaBottomNav />
</Layout>
{/* Mobile: Show bottom nav, Desktop: Hide */}
<MediaBottomNav />
{/* Chat reply notifications */}
<ChatNotificationToast
notifications={notifications}
clearNotification={clearNotification}
/>
{/* Messenger-style chat bar */}
<ChatBar />
</Layout>
</ChatBarProvider>
</ConfigProvider>
);
}

View File

@ -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 <Navigate to="/login" replace />;
}
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
if (requiredRoles && user && !hasAnyRole(user, requiredRoles)) {
return (
<Result
status="403"

View File

@ -1,13 +1,65 @@
import { useEffect } from 'react';
import { useState, useEffect } from 'react';
import { ConfigProvider, Layout, Typography, theme, Space } from 'antd';
import { Outlet, Link } from 'react-router-dom';
import { PlayCircleOutlined } from '@ant-design/icons';
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { PlayCircleOutlined, PlusCircleOutlined, FileTextOutlined, LoginOutlined, LogoutOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import AuthModal from '@/components/AuthModal';
const { Header, Content, Footer } = Layout;
const navItemStyle: React.CSSProperties = {
color: 'rgba(255, 255, 255, 0.85)',
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
transition: 'color 0.2s',
whiteSpace: 'nowrap',
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
font: 'inherit',
};
function NavLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
return (
<Link
to={to}
style={navItemStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<span>{label}</span>
</Link>
);
}
function NavButton({ onClick, icon, label }: { onClick: () => void; icon: React.ReactNode; label: string }) {
return (
<span
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') onClick(); }}
style={navItemStyle}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }}
>
{icon}
<span>{label}</span>
</span>
);
}
export default function PublicLayout() {
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() {
</Link>
{/* Right: Navigation */}
<Space size={24}>
<Link
to="/gallery"
style={{
color: 'rgba(255, 255, 255, 0.85)',
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
transition: 'color 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)';
}}
>
<PlayCircleOutlined />
<span>Media Gallery</span>
</Link>
<Space size={16} wrap>
{isAuthenticated ? (
<>
<NavLink to="/campaigns/create" icon={<PlusCircleOutlined />} label="Create Campaign" />
<NavLink to="/campaigns/mine" icon={<FileTextOutlined />} label="My Campaigns" />
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
</>
) : (
<>
<NavButton onClick={() => setAuthModalOpen(true)} icon={<PlusCircleOutlined />} label="Create Campaign" />
<NavButton onClick={() => setAuthModalOpen(true)} icon={<LoginOutlined />} label="Sign In" />
</>
)}
<NavLink to="/gallery" icon={<PlayCircleOutlined />} label="Media Gallery" />
</Space>
</Header>
<Content
@ -124,11 +168,26 @@ export default function PublicLayout() {
Campaigns
</Link>
{' • '}
<Link to="/campaigns/create" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Create Campaign
</Link>
{' • '}
<Link to="/gallery" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Media Gallery
</Link>
</div>
</Footer>
<AuthModal
open={authModalOpen}
onCancel={() => setAuthModalOpen(false)}
onSuccess={() => {
setAuthModalOpen(false);
navigate('/campaigns/create');
}}
title="Sign in to Create a Campaign"
subtitle="Sign in or create an account to submit your own campaign"
/>
</Layout>
</ConfigProvider>
);

View File

@ -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 {
</svg>`;
}
/** Compact dropdown UI for multi-unit buildings (replaces long scrollable list) */
function MultiUnitPopup({
group,
addresses,
selectedAddressId,
onAddressClick,
token,
}: {
group: AddressGroup;
addresses: CanvassAddress[];
selectedAddressId: string | null;
onAddressClick: (addressId: string) => void;
token: ReturnType<typeof theme.useToken>['token'];
}) {
// Default to the selected address in this building, or the first address
const initialId = addresses.find((a) => a.id === selectedAddressId)?.id ?? addresses[0]?.id ?? '';
const [viewingId, setViewingId] = useState(initialId);
const viewingAddr = addresses.find((a) => a.id === viewingId) ?? addresses[0];
const visitedCount = addresses.filter((a) => a.lastVisit).length;
return (
<>
{/* Header */}
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: `2px solid ${token.colorPrimary}` }}>
<div style={{ fontWeight: 600, fontSize: 14, color: token.colorPrimary }}>
🏢 {group.baseAddress}
</div>
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{addresses.length} units · {visitedCount} visited
</div>
</div>
{/* Building notes */}
{group.buildingNotes && (
<Alert
message="Building Notes"
description={
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(group.buildingNotes) }} />
}
type="info"
showIcon
style={{ marginBottom: 12, fontSize: 11 }}
/>
)}
{/* Native <select> dropdown — Ant Design Select doesn't work inside Leaflet popups */}
<select
value={viewingId}
onChange={(e) => setViewingId(e.target.value)}
style={{
width: '100%',
padding: '8px 10px',
fontSize: 13,
borderRadius: 6,
border: '1px solid #d9d9d9',
background: '#fafafa',
marginBottom: 10,
cursor: 'pointer',
appearance: 'auto',
}}
aria-label="Select unit"
>
{addresses.map((addr) => {
const status = addr.lastVisit
? VISIT_OUTCOME_LABELS[addr.lastVisit.outcome]
: 'Not visited';
return (
<option key={addr.id} value={addr.id}>
Unit {addr.unitNumber || '—'} {status}
</option>
);
})}
</select>
{/* Selected unit detail card */}
{viewingAddr && (
<button
type="button"
onClick={() => onAddressClick(viewingAddr.id)}
aria-label={`Record visit for unit ${viewingAddr.unitNumber || 'main'}`}
style={{
all: 'unset',
display: 'block',
width: '100%',
boxSizing: 'border-box',
padding: 10,
borderRadius: 8,
border: `1px solid ${viewingAddr.id === selectedAddressId ? token.colorPrimary : '#e8e8e8'}`,
background: viewingAddr.id === selectedAddressId ? 'rgba(52, 152, 219, 0.06)' : '#fafafa',
cursor: 'pointer',
}}
>
{viewingAddr.unitNumber && (
<div style={{ fontSize: 13, fontWeight: 600, color: '#333', marginBottom: 4 }}>
Unit {viewingAddr.unitNumber}
</div>
)}
{viewingAddr.firstName && (
<div style={{ fontSize: 12, color: '#555', marginBottom: 4 }}>
{viewingAddr.firstName} {viewingAddr.lastName}
</div>
)}
<div style={{ fontSize: 12, marginBottom: viewingAddr.notes ? 4 : 0 }}>
{viewingAddr.lastVisit ? (
<>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(viewingAddr),
marginRight: 5,
verticalAlign: 'middle',
}}
/>
<span style={{ verticalAlign: 'middle' }}>
{VISIT_OUTCOME_LABELS[viewingAddr.lastVisit.outcome]}
</span>
{viewingAddr.lastVisit.visitorName && (
<span style={{ fontSize: 11, color: '#888', marginLeft: 6 }}>
by {viewingAddr.lastVisit.visitorName}
</span>
)}
</>
) : (
<span style={{ color: '#999' }}>Not visited</span>
)}
</div>
{viewingAddr.notes && (
<div style={{ fontSize: 11, color: '#888', fontStyle: 'italic' }}>
Note: {viewingAddr.notes}
</div>
)}
<div style={{ fontSize: 11, color: '#1890ff', marginTop: 8, textAlign: 'center' }}>
Tap to record visit
</div>
</button>
)}
</>
);
}
function CanvassMarkerGroup({ group, selectedAddressId, onAddressClick }: CanvassMarkerGroupProps) {
const addresses = group.addresses;
const isMultiUnit = group.isMultiUnit;
@ -115,107 +259,14 @@ function CanvassMarkerGroup({ group, selectedAddressId, onAddressClick }: Canvas
<Popup maxWidth={350} minWidth={250}>
<div style={{ minWidth: 230, maxWidth: 330 }}>
{isMultiUnit ? (
// Multi-unit building display
<>
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: `2px solid ${token.colorPrimary}` }}>
<div style={{ fontWeight: 600, fontSize: 14, color: token.colorPrimary }}>
🏢 {group.baseAddress}
</div>
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{addresses.length} units
</div>
</div>
{/* Building notes */}
{group.buildingNotes && (
<Alert
message="Building Notes"
description={
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(group.buildingNotes) }} />
}
type="info"
showIcon
style={{ marginBottom: 12, fontSize: 11 }}
/>
)}
{/* Already sorted in groupAddressesByLocation helper */}
{addresses.map((addr, i) => (
<button
key={addr.id}
type="button"
style={{
all: 'unset',
display: 'block',
width: '100%',
boxSizing: 'border-box',
marginBottom: i < addresses.length - 1 ? 8 : 0,
paddingBottom: i < addresses.length - 1 ? 8 : 0,
borderBottom: i < addresses.length - 1 ? '1px solid #eee' : 'none',
cursor: 'pointer',
padding: 4,
borderRadius: 4,
background: addr.id === selectedAddressId ? 'rgba(52, 152, 219, 0.1)' : 'transparent',
}}
onClick={() => onAddressClick(addr.id)}
onKeyPress={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onAddressClick(addr.id);
}
}}
aria-label={`Unit ${addr.unitNumber || 'main'}, ${
addr.firstName ? `${addr.firstName} ${addr.lastName}, ` : ''
}${addr.lastVisit ? VISIT_OUTCOME_LABELS[addr.lastVisit.outcome] : 'not visited'}`}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
{addr.unitNumber && (
<div style={{ fontSize: 12, fontWeight: 600, color: '#555' }}>
Unit {addr.unitNumber}
</div>
)}
{addr.firstName && (
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{addr.firstName} {addr.lastName}
</div>
)}
</div>
<div style={{ marginLeft: 8, textAlign: 'right' }}>
{addr.lastVisit ? (
<>
<div style={{ fontSize: 11, marginBottom: 2 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(addr),
marginRight: 4,
}}
/>
{VISIT_OUTCOME_LABELS[addr.lastVisit.outcome]}
</div>
{addr.lastVisit.visitorName && (
<div style={{ fontSize: 10, color: '#999' }}>
by {addr.lastVisit.visitorName}
</div>
)}
</>
) : (
<div style={{ fontSize: 11, color: '#999' }}>Not visited</div>
)}
</div>
</div>
{addr.notes && (
<div style={{ fontSize: 10, color: '#888', marginTop: 4, fontStyle: 'italic' }}>
Note: {addr.notes}
</div>
)}
</button>
))}
</>
// Multi-unit building display — compact dropdown
<MultiUnitPopup
group={group}
addresses={addresses}
selectedAddressId={selectedAddressId}
onAddressClick={onAddressClick}
token={token}
/>
) : (
// Single unit display
<div style={{ cursor: 'pointer' }} onClick={() => onAddressClick(addresses[0]!.id)}>

View File

@ -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 <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 16 }}>No container data</Text>;
}
const maxMem = sorted[0]?.memoryMB ?? 1;
const chartData = sorted.map(c => ({ name: c.label, memory: c.memoryMB }));
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 16, left: 4, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10 }} unit="MB" />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={70} />
<Tooltip formatter={(v) => `${v} MB`} contentStyle={{ fontSize: 12, borderRadius: 6 }} />
<Bar dataKey="memory" radius={[0, 4, 4, 0]}>
{chartData.map((entry, i) => (
<Cell key={i} fill={memColor(entry.memory, maxMem)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View File

@ -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 = (
<Space direction="vertical" size={4} style={{ width: 200 }}>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>CPU</Text>
<Text style={{ fontSize: 12 }}>{resource.cpuPercent.toFixed(1)}%</Text>
</Flex>
<Progress
percent={Math.min(resource.cpuPercent, 100)}
size="small"
showInfo={false}
strokeColor={resource.cpuPercent > 80 ? '#ff4d4f' : resource.cpuPercent > 50 ? '#faad14' : '#52c41a'}
/>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>Memory</Text>
<Text style={{ fontSize: 12 }}>{resource.memoryMB}MB{resource.memoryLimitMB > 0 ? ` / ${resource.memoryLimitMB}MB` : ''}</Text>
</Flex>
{resource.memoryLimitMB > 0 && (
<Progress
percent={memPct}
size="small"
showInfo={false}
strokeColor={memPct > 80 ? '#ff4d4f' : memPct > 60 ? '#faad14' : '#52c41a'}
/>
)}
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>Network Rx</Text>
<Text style={{ fontSize: 12 }}>{resource.networkRxKBps.toFixed(1)} KB/s</Text>
</Flex>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>Network Tx</Text>
<Text style={{ fontSize: 12 }}>{resource.networkTxKBps.toFixed(1)} KB/s</Text>
</Flex>
</Space>
);
return (
<Popover content={content} title={resource.label} trigger="hover" placement="top">
{children}
</Popover>
);
}

View File

@ -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 <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 24 }}>No latency data</Text>;
}
const chartData = p50.timestamps.map((ts, i) => ({
time: formatTime(ts),
p50: Math.round((p50.values[i] || 0) * 1000),
p95: Math.round((p95?.values[i] || 0) * 1000),
p99: Math.round((p99?.values[i] || 0) * 1000),
}));
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} unit="ms" />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} formatter={(v) => `${v}ms`} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area type="monotone" dataKey="p99" stroke="#ff4d4f" fill="#ff4d4f" fillOpacity={0.15} />
<Area type="monotone" dataKey="p95" stroke="#faad14" fill="#faad14" fillOpacity={0.25} />
<Area type="monotone" dataKey="p50" stroke="#52c41a" fill="#52c41a" fillOpacity={0.4} />
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -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 (
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={filtered}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{filtered.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value, name) => [`${value}`, `${name}`]}
contentStyle={{ fontSize: 12, padding: '4px 8px', borderRadius: 6 }}
/>
</PieChart>
</ResponsiveContainer>
);
}

View File

@ -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 <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 24 }}>No traffic data</Text>;
}
const chartData = series2xx.timestamps.map((ts, i) => ({
time: formatTime(ts),
'2xx': series2xx.values[i] || 0,
'4xx': series4xx?.values[i] || 0,
'5xx': series5xx?.values[i] || 0,
}));
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area type="monotone" dataKey="2xx" stackId="1" stroke="#52c41a" fill="#52c41a" fillOpacity={0.6} />
<Area type="monotone" dataKey="4xx" stackId="1" stroke="#faad14" fill="#faad14" fillOpacity={0.6} />
<Area type="monotone" dataKey="5xx" stackId="1" stroke="#ff4d4f" fill="#ff4d4f" fillOpacity={0.6} />
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -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 (
<Flex justify="space-around" align="center" wrap="wrap" gap={8}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={cpuPercent}
size={72}
strokeColor={gaugeColor(cpuPercent)}
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
/>
<div><Text type="secondary" style={{ fontSize: 11 }}>CPU</Text></div>
</div>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={systemInfo.memory.usagePercent}
size={72}
strokeColor={gaugeColor(systemInfo.memory.usagePercent)}
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
/>
<div><Text type="secondary" style={{ fontSize: 11 }}>Memory</Text></div>
</div>
{systemInfo.disk && (
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={systemInfo.disk.usagePercent}
size={72}
strokeColor={gaugeColor(systemInfo.disk.usagePercent)}
format={(p) => <span style={{ fontSize: 14 }}>{p}%</span>}
/>
<div><Text type="secondary" style={{ fontSize: 11 }}>Disk</Text></div>
</div>
)}
</Flex>
);
}

View File

@ -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<AreaImportSourceStatus, React.ReactNode> = {
pending: <MinusCircleOutlined style={{ color: '#8c8c8c' }} />,
running: <LoadingOutlined style={{ color: '#1890ff' }} spin />,
complete: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
skipped: <MinusCircleOutlined style={{ color: '#d9d9d9' }} />,
};
export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardProps) {
const [currentStep, setCurrentStep] = useState(0);
// Step 0: Define area
const [areaType, setAreaType] = useState<'cut' | 'viewport'>('cut');
const [selectedCutId, setSelectedCutId] = useState<string | undefined>();
const [mapSettings, setMapSettings] = useState<MapSettings | null>(null);
const [mapSettingsLoading, setMapSettingsLoading] = useState(false);
// Step 1: Sources
const [osmEnabled, setOsmEnabled] = useState(true);
const [narEnabled, setNarEnabled] = useState(true);
const [narResidentialOnly, setNarResidentialOnly] = useState(true);
const [rgEnabled, setRgEnabled] = useState(false);
const [rgSpacing, setRgSpacing] = useState(100);
const [rgMaxPoints, setRgMaxPoints] = useState(500);
// Step 2: Preview
const [preview, setPreview] = useState<AreaImportPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
// Step 3: Progress
const [progress, setProgress] = useState<AreaImportProgress | null>(null);
const [importing, setImporting] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
// Load map settings for viewport mode
useEffect(() => {
if (areaType === 'viewport' && !mapSettings) {
setMapSettingsLoading(true);
api.get('/map/settings')
.then(({ data }) => setMapSettings(data))
.catch(() => {})
.finally(() => setMapSettingsLoading(false));
}
}, [areaType, mapSettings]);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const buildRequestBody = useCallback(() => {
const sources: Record<string, unknown> = {
osm: osmEnabled,
nar: narEnabled ? { residentialOnly: narResidentialOnly } : false,
reverseGeocode: rgEnabled ? { gridSpacingMeters: rgSpacing, maxPoints: rgMaxPoints } : false,
};
const body: Record<string, unknown> = { sources };
if (areaType === 'cut') {
body.areaType = 'cut';
body.cutId = selectedCutId;
} else {
body.areaType = 'viewport';
body.center = {
lat: mapSettings?.latitude ? Number(mapSettings.latitude) : 53.5,
lng: mapSettings?.longitude ? Number(mapSettings.longitude) : -113.5,
};
body.zoom = mapSettings?.zoom ?? 13;
}
return body;
}, [areaType, selectedCutId, mapSettings, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]);
const fetchPreview = async () => {
setPreviewLoading(true);
setPreviewError(null);
try {
const { data } = await api.post('/map/area-import/preview', buildRequestBody());
setPreview(data);
} catch (err: any) {
setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed');
} finally {
setPreviewLoading(false);
}
};
const startImport = async () => {
setImporting(true);
try {
const body = { ...buildRequestBody(), deduplicateRadius: 5, batchSize: 1000 };
const { data } = await api.post('/map/area-import', body);
const currentImportId = data.importId;
setCurrentStep(3);
// Start polling
pollRef.current = setInterval(async () => {
try {
const { data: prog } = await api.get(`/map/area-import/status/${currentImportId}`);
setProgress(prog);
if (prog.status === 'complete' || prog.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current);
}
} catch {
// Ignore polling errors
}
}, 2000);
} catch (err: any) {
setPreviewError(err?.response?.data?.error?.message || 'Failed to start import');
setImporting(false);
}
};
const canProceedStep0 = areaType === 'cut' ? !!selectedCutId : (!!mapSettings?.latitude && !!mapSettings?.longitude);
const canProceedStep1 = osmEnabled || narEnabled || rgEnabled;
const steps = [
{
title: 'Define Area',
content: (
<div>
<div style={{ marginBottom: 16 }}>
<Text strong>Area Source:</Text>
<Select
value={areaType}
onChange={(val) => setAreaType(val)}
style={{ width: 200, marginLeft: 12 }}
options={[
{ value: 'cut', label: 'From Cut Boundary' },
{ value: 'viewport', label: 'From Map Settings' },
]}
/>
</div>
{areaType === 'cut' && (
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Select a cut polygon to define the import area.
</Text>
<Select
placeholder="Select a cut..."
value={selectedCutId}
onChange={setSelectedCutId}
style={{ width: '100%' }}
showSearch
optionFilterProp="label"
options={cuts.map((c) => ({ value: c.id, label: c.name }))}
/>
</div>
)}
{areaType === 'viewport' && (
<div>
{mapSettingsLoading ? (
<Spin size="small" />
) : mapSettings?.latitude && mapSettings?.longitude ? (
<Card size="small">
<Row gutter={16}>
<Col span={8}>
<Statistic title="Center Lat" value={Number(mapSettings.latitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="Center Lng" value={Number(mapSettings.longitude).toFixed(4)} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="Zoom" value={mapSettings.zoom ?? 13} valueStyle={{ fontSize: 16 }} />
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: 'block' }}>
A bounding box will be derived from the map center and zoom level.
</Text>
</Card>
) : (
<Alert
message="Map settings not configured"
description="Please set a center and zoom level in Map Settings first."
type="warning"
showIcon
/>
)}
</div>
)}
</div>
),
},
{
title: 'Sources',
content: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Card
size="small"
style={{ borderColor: osmEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
onClick={() => setOsmEnabled(!osmEnabled)}
>
<Checkbox checked={osmEnabled} onChange={(e) => { e.stopPropagation(); setOsmEnabled(e.target.checked); }}>
<Space>
<GlobalOutlined />
<Text strong>OpenStreetMap (Overpass API)</Text>
</Space>
</Checkbox>
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
Fetches address nodes and building footprints from OSM. Best for urban areas with good community mapping.
</Text>
</Card>
<Card
size="small"
style={{ borderColor: narEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
onClick={() => setNarEnabled(!narEnabled)}
>
<Checkbox checked={narEnabled} onChange={(e) => { e.stopPropagation(); setNarEnabled(e.target.checked); }}>
<Space>
<DatabaseOutlined />
<Text strong>NAR (National Address Register)</Text>
</Space>
</Checkbox>
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
Official Canadian address data. Requires NAR files on server. Highest priority for deduplication.
</Text>
{narEnabled && (
<div style={{ marginLeft: 24, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
<Checkbox checked={narResidentialOnly} onChange={(e) => setNarResidentialOnly(e.target.checked)}>
Residential only
</Checkbox>
</div>
)}
</Card>
<Card
size="small"
style={{ borderColor: rgEnabled ? '#1890ff' : undefined, cursor: 'pointer' }}
onClick={() => setRgEnabled(!rgEnabled)}
>
<Checkbox checked={rgEnabled} onChange={(e) => { e.stopPropagation(); setRgEnabled(e.target.checked); }}>
<Space>
<CompassOutlined />
<Text strong>Reverse Geocode Grid</Text>
</Space>
</Checkbox>
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
Lays a grid of points and reverse geocodes each one. Slow but fills gaps not covered by other sources. Low confidence (40).
</Text>
{rgEnabled && (
<div style={{ marginLeft: 24, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text style={{ fontSize: 12 }}>Grid spacing (meters):</Text>
<Slider min={20} max={500} step={10} value={rgSpacing} onChange={setRgSpacing} />
</div>
<div>
<Text style={{ fontSize: 12 }}>Max points: </Text>
<InputNumber min={10} max={2000} value={rgMaxPoints} onChange={(v) => v && setRgMaxPoints(v)} size="small" />
</div>
</Space>
</div>
)}
</Card>
{!canProceedStep1 && (
<Alert message="Select at least one source to continue." type="info" showIcon />
)}
</div>
),
},
{
title: 'Preview',
content: (
<div>
{previewLoading && (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin size="large" />
<div style={{ marginTop: 12 }}>
<Text type="secondary">Estimating import size...</Text>
</div>
</div>
)}
{previewError && (
<Alert message="Preview Error" description={previewError} type="error" showIcon style={{ marginBottom: 16 }} />
)}
{preview && !previewLoading && (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card size="small">
<Statistic title="Area" value={preview.areaSqKm.toFixed(2)} suffix="km2" valueStyle={{ fontSize: 16 }} />
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic title="Existing Locations" value={preview.existingLocations} valueStyle={{ fontSize: 16 }} />
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic
title="Est. Total"
value={
(preview.estimates.osm >= 0 ? preview.estimates.osm : 0) +
preview.estimates.nar +
preview.estimates.reverseGeocode
}
valueStyle={{ fontSize: 16 }}
/>
</Card>
</Col>
</Row>
<Card size="small" title="Estimated Candidates by Source" style={{ marginBottom: 16 }}>
<Row gutter={16}>
{osmEnabled && (
<Col span={8}>
<Statistic
title={<Space><GlobalOutlined /> OSM</Space>}
value={preview.estimates.osm >= 0 ? preview.estimates.osm : '?'}
valueStyle={{ fontSize: 16 }}
/>
</Col>
)}
{narEnabled && (
<Col span={8}>
<Statistic
title={<Space><DatabaseOutlined /> NAR</Space>}
value={preview.estimates.nar}
suffix={preview.narProvincesDetected.length > 0 ? '' : undefined}
valueStyle={{ fontSize: 16 }}
/>
{preview.narProvincesDetected.length > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}>
Provinces: {preview.narProvincesDetected.join(', ')}
</Text>
)}
{preview.narProvincesDetected.length === 0 && (
<Text type="warning" style={{ fontSize: 11 }}>No NAR data for this area</Text>
)}
</Col>
)}
{rgEnabled && (
<Col span={8}>
<Statistic
title={<Space><CompassOutlined /> Rev. Geocode</Space>}
value={preview.estimates.reverseGeocode}
suffix="points"
valueStyle={{ fontSize: 16 }}
/>
</Col>
)}
</Row>
</Card>
{(preview.estimates.osm + preview.estimates.nar + preview.estimates.reverseGeocode) > 10000 && (
<Alert
message="Large Import"
description="Estimated candidates exceed 10,000. This may take a while. Consider narrowing the area or disabling reverse geocode."
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{preview.areaSqKm > 100 && osmEnabled && (
<Alert
message="Large OSM Query"
description={`Area is ${preview.areaSqKm.toFixed(0)} km2. Large Overpass queries may be slow or fail. Consider using a private Overpass instance.`}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Button
type="primary"
size="large"
block
onClick={startImport}
loading={importing}
>
Start Import
</Button>
</>
)}
</div>
),
},
{
title: 'Progress',
content: (
<div>
{progress ? (
<>
{progress.status === 'complete' ? (
<Result
status="success"
title="Import Complete"
subTitle={`${progress.locationsCreated} locations and ${progress.addressesCreated} addresses created`}
extra={[
<Button key="done" type="primary" onClick={() => onComplete?.()}>
Done
</Button>,
]}
/>
) : progress.status === 'failed' ? (
<Result
status="error"
title="Import Failed"
subTitle={progress.error || 'An unknown error occurred'}
extra={[
<Button key="back" onClick={() => { setCurrentStep(2); setImporting(false); }}>
Back to Preview
</Button>,
]}
/>
) : (
<>
<Title level={5} style={{ marginBottom: 16 }}>
{progress.status === 'initializing' ? 'Initializing...' :
progress.status === 'creating-records' ? 'Creating records...' : 'Running sources...'}
</Title>
<Card size="small" title="Source Progress" style={{ marginBottom: 16 }}>
{(['osm', 'nar', 'reverseGeocode'] as const).map((source) => {
const sp = progress.sources[source];
const labels = { osm: 'OpenStreetMap', nar: 'NAR', reverseGeocode: 'Reverse Geocode' };
return (
<div key={source} style={{ marginBottom: 8 }}>
<Space>
{SOURCE_STATUS_ICONS[sp.status]}
<Text strong>{labels[source]}</Text>
<Tag>{sp.status}</Tag>
{sp.candidatesFound > 0 && (
<Text type="secondary">{sp.candidatesFound} found</Text>
)}
</Space>
{sp.message && (
<Text type="secondary" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
{sp.message}
</Text>
)}
{sp.error && (
<Text type="danger" style={{ display: 'block', marginLeft: 24, fontSize: 12 }}>
{sp.error}
</Text>
)}
</div>
);
})}
</Card>
<Row gutter={16}>
<Col span={8}>
<Statistic title="Locations Created" value={progress.locationsCreated} valueStyle={{ fontSize: 16, color: '#52c41a' }} />
</Col>
<Col span={8}>
<Statistic title="Addresses Created" value={progress.addressesCreated} valueStyle={{ fontSize: 16, color: '#52c41a' }} />
</Col>
<Col span={8}>
<Statistic title="Duplicates Skipped" value={progress.skippedDuplicate} valueStyle={{ fontSize: 16, color: '#faad14' }} />
</Col>
</Row>
{progress.status === 'creating-records' && progress.totalCandidates > 0 && (
<Progress
percent={Math.round((progress.locationsCreated / progress.totalCandidates) * 100)}
style={{ marginTop: 16 }}
status="active"
/>
)}
</>
)}
</>
) : (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin size="large" />
<div style={{ marginTop: 12 }}>
<Text type="secondary">Starting import...</Text>
</div>
</div>
)}
</div>
),
},
];
const handleNext = () => {
if (currentStep === 1) {
// Moving to preview step — fetch preview
setCurrentStep(2);
// Fetch preview after state update
setTimeout(() => fetchPreview(), 0);
} else {
setCurrentStep(currentStep + 1);
}
};
return (
<div>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={steps.map((s) => ({ title: s.title }))}
/>
<div style={{ minHeight: 200 }}>
{steps[currentStep]?.content}
</div>
{currentStep < 2 && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'space-between' }}>
<Button disabled={currentStep === 0} onClick={() => setCurrentStep(currentStep - 1)}>
Back
</Button>
<Button
type="primary"
onClick={handleNext}
disabled={currentStep === 0 ? !canProceedStep0 : !canProceedStep1}
>
Next
</Button>
</div>
)}
{currentStep === 2 && !previewLoading && !preview && !previewError && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'space-between' }}>
<Button onClick={() => setCurrentStep(1)}>Back</Button>
<Button type="primary" onClick={fetchPreview}>Load Preview</Button>
</div>
)}
{currentStep === 2 && (preview || previewError) && !importing && (
<div style={{ marginTop: 16 }}>
<Button onClick={() => { setCurrentStep(1); setPreview(null); setPreviewError(null); }}>Back</Button>
</div>
)}
</div>
);
}

View File

@ -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<PlaylistWithSelected[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [selections, setSelections] = useState<Record<number, boolean>>({});
// Inline create state
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
// Fetch user's playlists and check which ones contain the video
useEffect(() => {
if (!open) return;
const fetchPlaylists = async () => {
try {
setLoading(true);
const { data } = await mediaApi.get('/playlists/my');
const userPlaylists: PlaylistSummary[] = data.data || [];
// For each playlist, check if it contains the video
const withSelection = await Promise.all(
userPlaylists.map(async (p) => {
try {
const { data: detail } = await mediaPublicApi.get(
`/playlists/${p.id}`
);
const hasVideo = (detail.videos || []).some(
(v: any) => v.mediaId === videoId
);
return { ...p, hasVideo };
} catch {
return { ...p, hasVideo: false };
}
})
);
setPlaylists(withSelection);
// Initialize selections from current state
const initial: Record<number, boolean> = {};
withSelection.forEach((p) => {
initial[p.id] = p.hasVideo;
});
setSelections(initial);
} catch {
message.error('Failed to load playlists');
} finally {
setLoading(false);
}
};
fetchPlaylists();
}, [open, videoId]);
const handleToggle = (playlistId: number, checked: boolean) => {
setSelections((prev) => ({ ...prev, [playlistId]: checked }));
};
const handleSave = async () => {
try {
setSaving(true);
const promises: Promise<any>[] = [];
for (const playlist of playlists) {
const wasInPlaylist = playlist.hasVideo;
const shouldBeInPlaylist = selections[playlist.id];
if (shouldBeInPlaylist && !wasInPlaylist) {
// Add to playlist
promises.push(
mediaApi.post(`/playlists/${playlist.id}/videos`, {
mediaId: videoId,
})
);
} else if (!shouldBeInPlaylist && wasInPlaylist) {
// Remove from playlist
promises.push(
mediaApi.delete(`/playlists/${playlist.id}/videos/${videoId}`)
);
}
}
await Promise.all(promises);
message.success('Playlists updated');
onClose();
} catch {
message.error('Failed to update playlists');
} finally {
setSaving(false);
}
};
const handleCreateNew = async () => {
if (!newName.trim()) return;
try {
setCreating(true);
const { data } = await mediaApi.post('/playlists/', {
name: newName.trim(),
isPublic: false,
});
// Add video to the new playlist
await mediaApi.post(`/playlists/${data.id}/videos`, { mediaId: videoId });
message.success(`Created "${data.name}" and added video`);
setNewName('');
setShowCreate(false);
// Refresh the list
setPlaylists((prev) => [
...prev,
{ ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
]);
setSelections((prev) => ({ ...prev, [data.id]: true }));
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else {
message.error('Failed to create playlist');
}
} finally {
setCreating(false);
}
};
return (
<Modal
title="Add to Playlist"
open={open}
onOk={handleSave}
onCancel={onClose}
confirmLoading={saving}
okText="Save"
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
) : (
<>
{playlists.length === 0 && !showCreate ? (
<div style={{ textAlign: 'center', padding: 24 }}>
<UnorderedListOutlined
style={{ fontSize: 36, color: token.colorTextSecondary, marginBottom: 12 }}
/>
<Text
type="secondary"
style={{ display: 'block', marginBottom: 16 }}
>
You don't have any playlists yet
</Text>
</div>
) : (
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{playlists.map((p) => (
<div
key={p.id}
style={{
padding: '8px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
}}
>
<Checkbox
checked={selections[p.id] ?? false}
onChange={(e) => handleToggle(p.id, e.target.checked)}
>
<Space>
<Text>{p.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
({p.videoCount} videos)
</Text>
</Space>
</Checkbox>
</div>
))}
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{showCreate ? (
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="New playlist name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onPressEnter={handleCreateNew}
maxLength={100}
autoFocus
/>
<Button
type="primary"
onClick={handleCreateNew}
loading={creating}
disabled={!newName.trim()}
>
Create
</Button>
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
</Space.Compact>
) : (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setShowCreate(true)}
block
>
Create New Playlist
</Button>
)}
</>
)}
</Modal>
);
}

View File

@ -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<PlaylistSummary[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedPlaylistId, setSelectedPlaylistId] = useState<number | null>(null);
// Inline create state
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!open) return;
const fetchPlaylists = async () => {
try {
setLoading(true);
const { data } = await mediaApi.get('/playlists/my');
setPlaylists(data.data || []);
} catch {
message.error('Failed to load playlists');
} finally {
setLoading(false);
}
};
fetchPlaylists();
setSelectedPlaylistId(null);
setShowCreate(false);
setNewName('');
}, [open]);
const handleAdd = async () => {
if (!selectedPlaylistId || videoIds.length === 0) return;
try {
setSaving(true);
let added = 0;
let skipped = 0;
for (const mediaId of videoIds) {
try {
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
added++;
} catch (error: any) {
if (error.response?.status === 409) {
skipped++;
} else {
throw error;
}
}
}
const parts: string[] = [];
if (added > 0) parts.push(`${added} video${added > 1 ? 's' : ''} added`);
if (skipped > 0) parts.push(`${skipped} already in playlist`);
message.success(parts.join(', '));
onSuccess?.();
} catch {
message.error('Failed to add videos to playlist');
} finally {
setSaving(false);
}
};
const handleCreateNew = async () => {
if (!newName.trim()) return;
try {
setCreating(true);
const { data } = await mediaApi.post('/playlists/', {
name: newName.trim(),
isPublic: false,
});
setPlaylists((prev) => [...prev, data]);
setSelectedPlaylistId(data.id);
setNewName('');
setShowCreate(false);
message.success(`Created "${data.name}"`);
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else {
message.error('Failed to create playlist');
}
} finally {
setCreating(false);
}
};
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
return (
<Modal
title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`}
open={open}
onOk={handleAdd}
onCancel={onClose}
confirmLoading={saving}
okText="Add"
okButtonProps={{ disabled: !selectedPlaylistId }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
) : (
<>
<Select
placeholder="Select a playlist"
value={selectedPlaylistId}
onChange={setSelectedPlaylistId}
style={{ width: '100%', marginBottom: 12 }}
options={playlists.map((p) => ({
value: p.id,
label: `${p.name} (${p.videoCount} videos)`,
}))}
showSearch
filterOption={(input, option) =>
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
{selectedPlaylist && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
{selectedPlaylist.isPublic ? 'Public' : 'Private'} playlist
{selectedPlaylist.videoCount > 0 && ` with ${selectedPlaylist.videoCount} videos`}
</Text>
)}
<Divider style={{ margin: '12px 0' }} />
{showCreate ? (
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="New playlist name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onPressEnter={handleCreateNew}
maxLength={100}
autoFocus
/>
<Button
type="primary"
onClick={handleCreateNew}
loading={creating}
disabled={!newName.trim()}
>
Create
</Button>
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
</Space.Compact>
) : (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setShowCreate(true)}
block
>
Create New Playlist
</Button>
)}
</>
)}
</Modal>
);
}

View File

@ -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<Set<string>>(new Set());
useEffect(() => {
for (const notif of notifications) {
if (shownRef.current.has(notif.id)) continue;
shownRef.current.add(notif.id);
api.info({
key: notif.id,
message: (
<Space size={4}>
<MessageOutlined />
<Text strong>{notif.commenterName}</Text>
<Text type="secondary">replied</Text>
</Space>
),
description: (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
on {notif.videoTitle}
</Text>
<div style={{ marginTop: 4 }}>
<Text style={{ fontSize: 13 }}>{notif.contentPreview}</Text>
</div>
</div>
),
placement: 'bottomRight',
duration: 8,
btn: (
<Button
type="primary"
size="small"
onClick={() => {
navigate(`/gallery/watch/${notif.videoId}`);
api.destroy(notif.id);
clearNotification(notif.id);
}}
>
View
</Button>
),
onClose: () => {
clearNotification(notif.id);
},
});
}
}, [notifications, api, clearNotification, navigate]);
// Cleanup shown IDs when notifications are cleared
useEffect(() => {
const currentIds = new Set(notifications.map((n) => n.id));
for (const id of shownRef.current) {
if (!currentIds.has(id)) {
shownRef.current.delete(id);
}
}
}, [notifications]);
return <>{contextHolder}</>;
}

View File

@ -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;
}

View File

@ -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 (
<Drawer
title="Create Playlist"
open={open}
onClose={() => {
form.resetFields();
onClose();
}}
placement="right"
width={420}
style={{ top: 64 }}
styles={{ body: { paddingTop: 24 } }}
extra={
<Space>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="Name"
rules={[
{ required: true, message: 'Please enter a playlist name' },
{ max: 100, message: 'Name must be 100 characters or less' },
]}
>
<Input placeholder="My Playlist" maxLength={100} />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea
placeholder="Optional description..."
rows={3}
maxLength={500}
/>
</Form.Item>
<Form.Item
name="isPublic"
label="Public"
valuePropName="checked"
initialValue={false}
>
<Switch checkedChildren="Public" unCheckedChildren="Private" />
</Form.Item>
</Form>
</Drawer>
);
}

View File

@ -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<PlaylistVideoItem[]>([]);
useEffect(() => {
if (!open || !playlistId) return;
const fetchPlaylist = async () => {
try {
setLoading(true);
const { data } = await mediaPublicApi.get(`/playlists/${playlistId}`);
setVideos(data.videos || []);
form.setFieldsValue({
name: data.name,
description: data.description,
isPublic: data.isPublic,
});
} catch {
message.error('Failed to load playlist');
} finally {
setLoading(false);
}
};
fetchPlaylist();
}, [open, playlistId]);
const handleSaveDetails = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await mediaApi.put(`/playlists/${playlistId}`, {
name: values.name,
description: values.description || undefined,
isPublic: values.isPublic,
});
message.success('Playlist updated');
onUpdated?.();
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have a playlist with this name');
} else if (!error.errorFields) {
message.error('Failed to update playlist');
}
} finally {
setSaving(false);
}
};
const handleRemoveVideo = async (mediaId: number) => {
try {
await mediaApi.delete(`/playlists/${playlistId}/videos/${mediaId}`);
setVideos((prev) => prev.filter((v) => v.mediaId !== mediaId));
message.success('Video removed');
onUpdated?.();
} catch {
message.error('Failed to remove video');
}
};
const handleMoveVideo = async (index: number, direction: 'up' | 'down') => {
const newVideos = [...videos];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newVideos.length) return;
const temp = newVideos[index]!;
newVideos[index] = newVideos[targetIndex]!;
newVideos[targetIndex] = temp;
// Update positions
const reordered = newVideos.map((v, i) => ({
...v,
position: i,
}));
setVideos(reordered);
try {
await mediaApi.put(`/playlists/${playlistId}/videos/reorder`, {
items: reordered.map((v) => ({ mediaId: v.mediaId, position: v.position })),
});
} catch {
message.error('Failed to reorder');
}
};
return (
<Drawer
title="Edit Playlist"
open={open}
onClose={() => {
form.resetFields();
onClose();
}}
placement="right"
width={520}
style={{ top: 64 }}
loading={loading}
>
<Tabs
items={[
{
key: 'details',
label: 'Details',
children: (
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
name="name"
label="Name"
rules={[
{ required: true, message: 'Please enter a playlist name' },
{ max: 100, message: 'Name must be 100 characters or less' },
]}
>
<Input maxLength={100} />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} maxLength={500} />
</Form.Item>
<Form.Item
name="isPublic"
label="Public"
valuePropName="checked"
>
<Switch checkedChildren="Public" unCheckedChildren="Private" />
</Form.Item>
<Button type="primary" onClick={handleSaveDetails} loading={saving}>
Save Changes
</Button>
</Form>
),
},
{
key: 'videos',
label: `Videos (${videos.length})`,
children: (
<List
dataSource={videos}
locale={{ emptyText: 'No videos in this playlist' }}
renderItem={(item, index) => {
const title = item.video.title || item.video.filename.replace(/\.[^/.]+$/, '');
return (
<List.Item
style={{ padding: '8px 0' }}
actions={[
<Button
key="up"
type="text"
size="small"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => handleMoveVideo(index, 'up')}
/>,
<Button
key="down"
type="text"
size="small"
icon={<ArrowDownOutlined />}
disabled={index === videos.length - 1}
onClick={() => handleMoveVideo(index, 'down')}
/>,
<Button
key="remove"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleRemoveVideo(item.mediaId)}
/>,
]}
>
<Space>
<div
style={{
width: 28,
height: 28,
borderRadius: 4,
background: token.colorBgTextHover,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
}}
>
{index + 1}
</div>
{item.video.thumbnailUrl && (
<img
src={`/media${item.video.thumbnailUrl}`}
alt=""
style={{
width: 48,
height: 28,
objectFit: 'cover',
borderRadius: 4,
flexShrink: 0,
}}
/>
)}
<div style={{ minWidth: 0 }}>
<Text
ellipsis
style={{ fontSize: 13, display: 'block', maxWidth: 280 }}
>
{title}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDuration(item.video.durationSeconds)}
</Text>
</div>
</Space>
</List.Item>
);
}}
/>
),
},
]}
/>
</Drawer>
);
}

View File

@ -1,4 +1,4 @@
import { Drawer, Form, Input, Select, Button, Space, message, Spin } from 'antd';
import { Drawer, Form, Input, Select, Switch, Button, Space, message, Spin } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import { mediaApi } from '@/lib/media-api';
@ -39,6 +39,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
category: v.category || undefined,
tags: Array.isArray(v.tags) ? v.tags : [],
quality: v.quality || '',
isShort: v.isShort ?? false,
});
})
.catch(() => {
@ -50,6 +51,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
category: video.category || undefined,
tags: Array.isArray(video.tags) ? video.tags : [],
quality: video.quality || '',
isShort: video.isShort ?? false,
});
})
.finally(() => setFetching(false));
@ -70,6 +72,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
payload.creator = values.creator || null;
payload.category = values.category || null;
payload.tags = values.tags && values.tags.length > 0 ? values.tags : null;
if (values.isShort !== undefined) payload.isShort = values.isShort;
await mediaApi.patch(`/videos/${video.id}`, payload);
message.success('Video updated successfully');
@ -136,6 +139,10 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
/>
</Form.Item>
<Form.Item name="isShort" label="Short Video" valuePropName="checked">
<Switch checkedChildren="Yes" unCheckedChildren="No" />
</Form.Item>
<Form.Item name="tags" label="Tags">
<Select
mode="tags"

View File

@ -6,13 +6,16 @@ import {
LikeFilled,
EyeOutlined,
CommentOutlined,
OrderedListOutlined,
} from '@ant-design/icons';
import { useExpandedVideo, type VideoData } from '@/contexts/ExpandedVideoContext';
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
import { useAuthStore } from '@/stores/auth.store';
import VideoPlayer, { VideoPlayerRef } from './VideoPlayer';
import LiveChat from './LiveChat';
import ProgressBarMarkers from './ProgressBarMarkers';
import ReactionButtons from './ReactionButtons';
import AddToPlaylistModal from './AddToPlaylistModal';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
@ -35,30 +38,14 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
const [upvoteCount, setUpvoteCount] = useState(video.upvoteCount);
const [upvoting, setUpvoting] = useState(false);
const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
const [addToPlaylistOpen, setAddToPlaylistOpen] = useState(false);
const [videoHeight, setVideoHeight] = useState<number>(0);
const [currentTime, setCurrentTime] = useState(0);
const [isExpanding, setIsExpanding] = useState(true);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// Read sidebar collapse state for full-width calculation
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const saved = localStorage.getItem('media_sidebar_collapsed');
return saved ? JSON.parse(saved) : false;
});
useEffect(() => {
const handleStorage = () => {
const saved = localStorage.getItem('media_sidebar_collapsed');
if (saved !== null) setSidebarCollapsed(JSON.parse(saved));
};
window.addEventListener('storage', handleStorage);
const interval = setInterval(handleStorage, 200);
return () => {
window.removeEventListener('storage', handleStorage);
clearInterval(interval);
};
}, []);
const sidebarWidth = isMobile ? 0 : sidebarCollapsed ? 64 : 256;
// Parent padding to break out of (matches MediaPublicLayout)
const pad = isMobile ? 8 : 12;
// Extract title from filename
const title = video.filename.replace(/\.[^/.]+$/, '');
@ -153,10 +140,6 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
return count.toString();
};
// Break out of parent container (maxWidth + padding) to use full content area
// Use viewport width minus sidebar to go truly edge-to-edge
const fullWidth = `calc(100vw - ${sidebarWidth}px)`;
return (
<div
ref={containerRef}
@ -168,12 +151,10 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
maxHeight: isExpanding ? 0 : 3000,
opacity: isExpanding ? 0 : 1,
// Break out of parent padding + maxWidth to fill full content area
width: fullWidth,
position: 'relative',
left: '50%',
transform: 'translateX(-50%)',
marginLeft: `calc(-50% - ${sidebarWidth / 2}px + 50%)`,
// Break out of parent padding to fill full content area
marginLeft: -pad,
marginRight: -pad,
width: `calc(100% + ${pad * 2}px)`,
}}
>
{/* Main content: video (left) + chat (right) */}
@ -192,6 +173,8 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
position: 'relative',
width: '100%',
aspectRatio: video.orientation === 'V' ? '9/16' : '16/9',
// Cap height so player controls stay above the info bar
maxHeight: isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 50px)',
background: '#000',
}}
>
@ -306,6 +289,18 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
{formatCount(upvoteCount)}
</Button>
{/* Add to Playlist */}
{isAuthenticated && (
<Button
type="text"
icon={<OrderedListOutlined />}
onClick={() => setAddToPlaylistOpen(true)}
size="small"
style={{ flexShrink: 0 }}
title="Add to playlist"
/>
)}
{/* Mobile chat toggle */}
{isMobile && (
<Button
@ -337,6 +332,13 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
</MediaAuthProvider>
</div>
)}
{/* Add to Playlist Modal */}
<AddToPlaylistModal
videoId={video.id}
open={addToPlaylistOpen}
onClose={() => setAddToPlaylistOpen(false)}
/>
</div>
);
}

View File

@ -0,0 +1,152 @@
import { useState, useEffect, useRef } from 'react';
import { Typography, Spin, theme, Grid } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import PlaylistCard from './PlaylistCard';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PlaylistSummary } from '@/types/media';
const { useBreakpoint } = Grid;
export default function FeaturedPlaylistCarousel() {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const scrollRef = useRef<HTMLDivElement>(null);
const [playlists, setPlaylists] = useState<PlaylistSummary[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFeatured = async () => {
try {
const { data } = await mediaPublicApi.get('/playlists/featured', {
params: { limit: 12 },
});
setPlaylists(data.data || []);
} catch {
// Silent fail — not critical
} finally {
setLoading(false);
}
};
fetchFeatured();
}, []);
const scroll = (direction: 'left' | 'right') => {
if (!scrollRef.current) return;
const scrollAmount = isMobile ? 260 : 300;
scrollRef.current.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin />
</div>
);
}
if (playlists.length === 0) return null;
return (
<div style={{ marginBottom: 24 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
Featured Playlists
</Typography.Title>
{playlists.length > 3 && (
<div style={{ display: 'flex', gap: 8 }}>
<div
onClick={() => scroll('left')}
style={{
width: 32,
height: 32,
borderRadius: '50%',
border: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: token.colorTextSecondary,
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = token.colorPrimary;
e.currentTarget.style.color = token.colorPrimary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = token.colorBorderSecondary;
e.currentTarget.style.color = token.colorTextSecondary;
}}
>
<LeftOutlined style={{ fontSize: 12 }} />
</div>
<div
onClick={() => scroll('right')}
style={{
width: 32,
height: 32,
borderRadius: '50%',
border: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: token.colorTextSecondary,
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = token.colorPrimary;
e.currentTarget.style.color = token.colorPrimary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = token.colorBorderSecondary;
e.currentTarget.style.color = token.colorTextSecondary;
}}
>
<RightOutlined style={{ fontSize: 12 }} />
</div>
</div>
)}
</div>
<div
ref={scrollRef}
style={{
display: 'flex',
gap: 16,
overflowX: 'auto',
scrollSnapType: 'x mandatory',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
paddingBottom: 4,
}}
>
{playlists.map((playlist) => (
<div
key={playlist.id}
style={{
minWidth: isMobile ? 240 : 280,
maxWidth: isMobile ? 240 : 280,
scrollSnapAlign: 'start',
flexShrink: 0,
}}
>
<PlaylistCard playlist={playlist} />
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,540 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Drawer,
Input,
Button,
Space,
Card,
Tag,
Progress,
Typography,
message,
Empty,
Collapse,
List,
Tooltip,
} from 'antd';
import {
CloudDownloadOutlined,
StopOutlined,
ReloadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
ClockCircleOutlined,
ExpandOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
const { TextArea } = Input;
const { Text } = Typography;
interface FetchJob {
id: string;
urls: string[];
urlCount: number;
state: string;
progress: number;
returnvalue: {
results: Array<{
url: string;
success: boolean;
videoId?: number;
title?: string;
error?: string;
}>;
totalUrls: number;
successCount: number;
failCount: number;
} | null;
failedReason: string | null;
timestamp: number;
finishedOn: number | null;
processedOn: number | null;
}
interface FetchVideosDrawerProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
const STATE_COLORS: Record<string, string> = {
active: 'processing',
waiting: 'default',
delayed: 'warning',
completed: 'success',
failed: 'error',
};
const STATE_ICONS: Record<string, React.ReactNode> = {
active: <LoadingOutlined />,
waiting: <ClockCircleOutlined />,
completed: <CheckCircleOutlined />,
failed: <CloseCircleOutlined />,
};
export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVideosDrawerProps) {
const [urls, setUrls] = useState('');
const [submitting, setSubmitting] = useState(false);
const [jobs, setJobs] = useState<FetchJob[]>([]);
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
const [logLines, setLogLines] = useState<string[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
const prevCompletedRef = useRef<Set<string>>(new Set());
// Poll for job updates
const fetchJobs = useCallback(async () => {
try {
const { data } = await mediaApi.get<{ jobs: FetchJob[] }>('/videos/fetch/jobs');
setJobs(data.jobs);
// Check for newly completed jobs to trigger refresh
const currentCompleted = new Set(
data.jobs.filter(j => j.state === 'completed').map(j => j.id)
);
const prev = prevCompletedRef.current;
for (const id of currentCompleted) {
if (!prev.has(id)) {
// A job just completed
onSuccess?.();
break;
}
}
prevCompletedRef.current = currentCompleted;
} catch (err) {
// Silently ignore poll errors
}
}, [onSuccess]);
useEffect(() => {
if (open) {
fetchJobs();
pollRef.current = setInterval(fetchJobs, 3000);
}
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [open, fetchJobs]);
// SSE log connection
useEffect(() => {
if (!expandedJobId) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setLogLines([]);
return;
}
// Construct SSE URL through the media API proxy
const baseUrl = '/media/api/videos/fetch/jobs/' + expandedJobId + '/log';
// We need the auth token for the SSE connection
// Use fetch with EventSource-like manual parsing since ES doesn't support auth headers
const controller = new AbortController();
let cancelled = false;
const connectSSE = async () => {
try {
// Get auth token from localStorage
const stored = localStorage.getItem('auth-storage');
let token = '';
if (stored) {
try {
const parsed = JSON.parse(stored);
token = parsed?.state?.accessToken || '';
} catch {}
}
const response = await fetch(baseUrl, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
},
signal: controller.signal,
});
if (!response.ok || !response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!cancelled) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = line.slice(6);
try {
const parsed = JSON.parse(payload);
if (parsed.type === 'log' && parsed.data) {
setLogLines(prev => [...prev, parsed.data]);
} else if (parsed.type === 'status') {
// Job completed or failed
fetchJobs();
}
} catch {
// Not JSON, might be raw text
}
} else if (line.startsWith('event: progress')) {
// Progress events are handled by polling
} else if (line.startsWith('event: done')) {
fetchJobs();
}
}
}
} catch (err) {
if (!cancelled) {
// Connection error, will retry on next expand
}
}
};
setLogLines([]);
connectSSE();
return () => {
cancelled = true;
controller.abort();
};
}, [expandedJobId, fetchJobs]);
// Auto-scroll log to bottom
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logLines]);
// Clean up on close
useEffect(() => {
if (!open) {
setExpandedJobId(null);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}
}, [open]);
const handleSubmit = async () => {
const urlList = urls
.split('\n')
.map(u => u.trim())
.filter(u => u.length > 0);
if (urlList.length === 0) {
message.warning('Please enter at least one URL');
return;
}
if (urlList.length > 20) {
message.error('Maximum 20 URLs per submission');
return;
}
setSubmitting(true);
try {
const { data } = await mediaApi.post('/videos/fetch', { urls: urlList });
message.success(`Fetch job submitted with ${data.urlCount} URL(s)`);
setUrls('');
// Immediately expand the new job
setExpandedJobId(data.jobId);
fetchJobs();
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to submit fetch job');
} finally {
setSubmitting(false);
}
};
const handleCancel = async (jobId: string) => {
try {
await mediaApi.delete(`/videos/fetch/jobs/${jobId}`);
message.success('Job cancelled');
fetchJobs();
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to cancel job');
}
};
const formatTime = (ts: number | null) => {
if (!ts) return '-';
return new Date(ts).toLocaleTimeString();
};
const activeJobs = jobs.filter(j => j.state === 'active' || j.state === 'waiting');
const recentJobs = jobs.filter(j => j.state === 'completed' || j.state === 'failed');
return (
<Drawer
title={
<Space>
<CloudDownloadOutlined />
Fetch Videos
</Space>
}
open={open}
onClose={onClose}
width={560}
destroyOnClose
>
{/* URL Input Section */}
<Card
size="small"
title="Download from URL"
style={{ marginBottom: 16 }}
>
<TextArea
rows={4}
placeholder={'Paste video URLs here, one per line.\n\nSupports YouTube, Twitter/X, Reddit, Vimeo, and 1000+ sites via yt-dlp.'}
value={urls}
onChange={(e) => setUrls(e.target.value)}
disabled={submitting}
style={{ marginBottom: 12, fontFamily: 'monospace', fontSize: 12 }}
/>
<Space>
<Button
type="primary"
icon={<CloudDownloadOutlined />}
loading={submitting}
onClick={handleSubmit}
disabled={!urls.trim()}
>
Fetch
</Button>
<Text type="secondary" style={{ fontSize: 12 }}>
Max 20 URLs per submission
</Text>
</Space>
</Card>
{/* Active Jobs */}
{activeJobs.length > 0 && (
<Card
size="small"
title={`Active Jobs (${activeJobs.length})`}
style={{ marginBottom: 16 }}
>
{activeJobs.map(job => (
<div key={job.id} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<Space size="small">
<Tag color={STATE_COLORS[job.state]} icon={STATE_ICONS[job.state]}>
{job.state}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{job.urlCount} URL{job.urlCount !== 1 ? 's' : ''}
</Text>
</Space>
<Space size="small">
<Tooltip title={expandedJobId === job.id ? 'Collapse log' : 'Expand log'}>
<Button
size="small"
type="text"
icon={<ExpandOutlined />}
onClick={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}
/>
</Tooltip>
<Button
size="small"
danger
icon={<StopOutlined />}
onClick={() => handleCancel(job.id)}
>
Cancel
</Button>
</Space>
</div>
{job.state === 'active' && (
<Progress
percent={job.progress || 0}
size="small"
status="active"
/>
)}
{/* Log viewer */}
{expandedJobId === job.id && (
<div
ref={logContainerRef}
style={{
marginTop: 8,
maxHeight: 300,
overflow: 'auto',
background: '#1a1a2e',
borderRadius: 4,
padding: 8,
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.5,
color: '#e0e0e0',
}}
>
{logLines.length === 0 ? (
<Text type="secondary" style={{ fontSize: 11 }}>Waiting for output...</Text>
) : (
logLines.map((line, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line.startsWith('[stderr]') ? (
<span style={{ color: '#ff6b6b' }}>{line}</span>
) : line.startsWith('FAILED:') ? (
<span style={{ color: '#ff6b6b' }}>{line}</span>
) : line.startsWith('---') ? (
<span style={{ color: '#74b9ff' }}>{line}</span>
) : line.includes('Imported as video') ? (
<span style={{ color: '#55efc4' }}>{line}</span>
) : (
line
)}
</div>
))
)}
</div>
)}
{/* URL list */}
<div style={{ marginTop: 4 }}>
{job.urls.slice(0, 3).map((url, i) => (
<Text key={i} type="secondary" ellipsis style={{ display: 'block', fontSize: 11 }}>
{url}
</Text>
))}
{job.urls.length > 3 && (
<Text type="secondary" style={{ fontSize: 11 }}>
...and {job.urls.length - 3} more
</Text>
)}
</div>
</div>
))}
</Card>
)}
{/* Recent Jobs */}
{recentJobs.length > 0 && (
<Card size="small" title="Recent Jobs">
<Collapse
accordion
ghost
activeKey={expandedJobId && recentJobs.some(j => j.id === expandedJobId) ? expandedJobId : undefined}
onChange={(key) => setExpandedJobId(typeof key === 'string' ? key : key?.[0] || null)}
items={recentJobs.map(job => ({
key: job.id,
label: (
<Space>
<Tag color={STATE_COLORS[job.state]} icon={STATE_ICONS[job.state]}>
{job.state}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{job.urlCount} URL{job.urlCount !== 1 ? 's' : ''}
{job.returnvalue && (
<> {job.returnvalue.successCount} ok, {job.returnvalue.failCount} failed</>
)}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatTime(job.finishedOn || job.timestamp)}
</Text>
</Space>
),
children: (
<div>
{/* Results list */}
{job.returnvalue?.results && (
<List
size="small"
dataSource={job.returnvalue.results}
renderItem={(result) => (
<List.Item>
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Text ellipsis style={{ fontSize: 12, maxWidth: 450 }}>
{result.url}
</Text>
{result.success ? (
<Tag color="success" style={{ fontSize: 11 }}>
Imported: {result.title || `Video #${result.videoId}`}
</Tag>
) : (
<Tag color="error" style={{ fontSize: 11 }}>
{result.error || 'Unknown error'}
</Tag>
)}
</Space>
</List.Item>
)}
/>
)}
{job.failedReason && (
<Text type="danger" style={{ fontSize: 12 }}>
{job.failedReason}
</Text>
)}
{/* Log viewer for recent jobs */}
{expandedJobId === job.id && logLines.length > 0 && (
<div
ref={logContainerRef}
style={{
marginTop: 8,
maxHeight: 200,
overflow: 'auto',
background: '#1a1a2e',
borderRadius: 4,
padding: 8,
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.5,
color: '#e0e0e0',
}}
>
{logLines.map((line, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line}
</div>
))}
</div>
)}
</div>
),
}))}
/>
</Card>
)}
{/* Empty state */}
{jobs.length === 0 && !submitting && (
<Empty
description="No fetch jobs yet"
style={{ marginTop: 32 }}
/>
)}
{/* Refresh button */}
{jobs.length > 0 && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Button
type="text"
icon={<ReloadOutlined />}
onClick={fetchJobs}
size="small"
>
Refresh
</Button>
</div>
)}
</Drawer>
);
}

View File

@ -19,6 +19,7 @@ import {
} from '@ant-design/icons';
import { useMediaAuth } from '@/contexts/MediaAuthContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
const { Text } = Typography;
const { TextArea } = Input;
@ -163,7 +164,7 @@ export default function LiveChat({
if (!isOpen || eventSourceRef.current) return;
// Use relative URL to go through nginx proxy
const sseUrl = `/media/public/${videoId}/stream`;
const sseUrl = `/media/public/${videoId}/chat-stream`;
const eventSource = new EventSource(sseUrl);
@ -260,13 +261,24 @@ export default function LiveChat({
};
}, []);
// Mark thread as read when chat opens
const markAsRead = useCallback(async () => {
if (!isAuthenticated) return;
try {
await mediaApi.post(`/media/chat/threads/${videoId}/read`);
} catch {
// Non-critical
}
}, [videoId, isAuthenticated]);
// Fetch timeline and setup SSE when component opens
useEffect(() => {
if (isOpen) {
fetchInitialTimeline();
setupSSE();
markAsRead();
}
}, [isOpen, videoId, setupSSE]);
}, [isOpen, videoId, setupSSE, markAsRead]);
// Handle comment submission
const handleSubmitComment = async () => {

View File

@ -1,108 +1,101 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { Typography } from 'antd';
import {
HomeOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
AppstoreOutlined,
StarOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { useState, useEffect } from 'react';
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom';
import { Input, Select, theme, Grid } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
const { Text } = Typography;
interface NavItem {
key: string;
label: string;
icon: React.ReactNode;
path: string;
}
const { useBreakpoint } = Grid;
export default function MediaBottomNav() {
const navigate = useNavigate();
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Navigation items (shortened labels for mobile)
const navItems: NavItem[] = [
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
{ key: 'videos', label: 'Vids', icon: <VideoCameraOutlined />, path: '/gallery/videos' },
{
key: 'compilations',
label: 'Comps',
icon: <AppstoreOutlined />,
path: '/gallery/compilations',
},
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
{ key: 'playback', label: 'Play', icon: <PlayCircleOutlined />, path: '/gallery/playback' },
];
// Initialize from URL params
const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
const sort = (searchParams.get('sort') as 'recent' | 'popular' | 'most_viewed') || 'recent';
// Determine active nav item from current path
const getActiveKey = () => {
const path = location.pathname;
if (path === '/gallery') return 'all';
const match = navItems.find((item) => path.startsWith(item.path));
return match ? match.key : 'all';
const isShorts = location.pathname === '/gallery/shorts';
// Debounce search → URL param (skip on shorts — search navigates to gallery on Enter)
useEffect(() => {
if (isShorts) return;
const timer = setTimeout(() => {
const params = new URLSearchParams(searchParams);
if (searchInput) {
params.set('search', searchInput);
} else {
params.delete('search');
}
setSearchParams(params, { replace: true });
}, 300);
return () => clearTimeout(timer);
}, [searchInput, isShorts]);
const handleSortChange = (value: 'recent' | 'popular' | 'most_viewed') => {
if (isShorts) {
// Navigate to gallery with sort param
navigate(`/gallery?sort=${value}`);
return;
}
const params = new URLSearchParams(searchParams);
if (value !== 'recent') {
params.set('sort', value);
} else {
params.delete('sort');
}
setSearchParams(params, { replace: true });
};
const activeKey = getActiveKey();
const handleNavigate = (path: string) => {
navigate(path);
// On shorts page, Enter in search navigates to gallery with the search term
const handleSearchSubmit = () => {
if (isShorts && searchInput.trim()) {
navigate(`/gallery?search=${encodeURIComponent(searchInput.trim())}`);
}
};
return (
<div
className="md:hidden" // Hide on desktop (>= 768px), show on mobile (< 768px)
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 56,
background: '#18181b', // zinc-900
borderTop: '1px solid rgba(255,255,255,0.06)',
height: 48,
background: isShorts ? 'rgba(0, 0, 0, 0.75)' : token.colorBgContainer,
backdropFilter: isShorts ? 'blur(12px)' : undefined,
borderTop: isShorts ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
padding: '0 4px',
gap: 8,
padding: isMobile ? '0 8px' : '0 16px',
zIndex: 1000,
}}
>
{navItems.map((item) => {
const isActive = activeKey === item.key;
return (
<div
key={item.key}
onClick={() => handleNavigate(item.path)}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
padding: '6px 4px',
cursor: 'pointer',
color: isActive ? '#9333ea' : 'rgba(255,255,255,0.65)',
transition: 'color 0.2s ease',
}}
>
<span style={{ fontSize: 20 }}>{item.icon}</span>
<Text
style={{
fontSize: 10,
color: 'inherit',
fontWeight: isActive ? 500 : 400,
textAlign: 'center',
lineHeight: 1.2,
}}
>
{item.label}
</Text>
</div>
);
})}
<Input
placeholder="Search videos..."
prefix={<SearchOutlined style={{ color: token.colorTextTertiary }} />}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onPressEnter={handleSearchSubmit}
allowClear
size="small"
style={{ flex: 1 }}
/>
<Select
value={sort}
onChange={handleSortChange}
size="small"
style={{ width: isMobile ? 110 : 140, flexShrink: 0 }}
options={[
{ value: 'recent', label: 'Recent' },
{ value: 'popular', label: 'Popular' },
{ value: 'most_viewed', label: 'Most Viewed' },
]}
/>
</div>
);
}

View File

@ -1,11 +1,10 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Typography, Space, Tooltip } from 'antd';
import { Typography, Space, Tooltip, Badge, theme } from 'antd';
import {
HomeOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
AppstoreOutlined,
StarOutlined,
PlayCircleOutlined,
TeamOutlined,
@ -14,12 +13,15 @@ import {
LoginOutlined,
LogoutOutlined,
BarChartOutlined,
MessageOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
DownOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color';
import { mediaApi } from '@/lib/media-api';
const { Text } = Typography;
@ -30,6 +32,17 @@ interface NavItem {
path: string;
}
interface ChatThread {
mediaId: number;
videoTitle: string;
unreadCount: number;
lastMessage: {
content: string;
userName: string;
createdAt: string;
} | null;
}
interface SectionState {
content: boolean;
activity: boolean;
@ -40,6 +53,7 @@ interface SectionState {
export default function MediaSidebar() {
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
// Only hydrate auth if tokens exist (prevents 401 errors on public pages)
const user = useAuthStore((state) => state.user);
@ -47,11 +61,9 @@ export default function MediaSidebar() {
const hydrate = useAuthStore((state) => state.hydrate);
useEffect(() => {
// Check if auth tokens exist before attempting to hydrate
const accessToken = localStorage.getItem('access_token');
const refreshToken = localStorage.getItem('refresh_token');
if (accessToken || refreshToken) {
// Check if auth data exists before attempting to hydrate
const authData = localStorage.getItem('cml-auth');
if (authData) {
hydrate();
}
}, [hydrate]);
@ -70,8 +82,29 @@ export default function MediaSidebar() {
: { content: true, activity: true, online: true, account: true };
});
// Mock data for activity feed (currently empty)
const recentVideos: any[] = [];
// Chat threads state
const [chatThreads, setChatThreads] = useState<ChatThread[]>([]);
const fetchChatThreads = useCallback(async () => {
if (!user) return;
try {
const { data } = await mediaApi.get('/media/chat/threads', { params: { limit: '5' } });
setChatThreads(data.threads || []);
} catch {
// Silent fail for sidebar data
}
}, [user]);
// Fetch chat threads periodically when user is logged in
useEffect(() => {
if (user) {
fetchChatThreads();
const interval = setInterval(fetchChatThreads, 30000); // Refresh every 30s
return () => clearInterval(interval);
} else {
setChatThreads([]);
}
}, [user, fetchChatThreads]);
// Save collapse state to localStorage
useEffect(() => {
@ -82,26 +115,26 @@ export default function MediaSidebar() {
localStorage.setItem('media_sidebar_sections', JSON.stringify(sections));
}, [sections]);
// Derived hover colors
const hoverBg = hexToRgba(token.colorPrimary, 0.1);
const userInfoBg = hexToRgba(token.colorPrimary, 0.05);
// Navigation items
const navItems: NavItem[] = [
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
{ key: 'videos', label: 'Videos', icon: <VideoCameraOutlined />, path: '/gallery/videos' },
{
key: 'compilations',
label: 'Compilations',
icon: <AppstoreOutlined />,
path: '/gallery/compilations',
},
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
{ key: 'curated', label: 'Curated', icon: <StarOutlined />, path: '/gallery/curated' },
{ key: 'playback', label: 'Playback', icon: <PlayCircleOutlined />, path: '/gallery/playback' },
];
// Determine active nav item from current path
// Determine active nav item from current path (longest match wins)
const getActiveKey = () => {
const path = location.pathname;
if (path === '/gallery') return 'all';
const match = navItems.find((item) => path.startsWith(item.path));
const match = [...navItems]
.sort((a, b) => b.path.length - a.path.length)
.find((item) => path.startsWith(item.path));
return match ? match.key : 'all';
};
@ -131,7 +164,7 @@ export default function MediaSidebar() {
style={{
width: sidebarWidth,
height: '100vh',
background: '#18181b', // zinc-900
background: token.colorBgContainer,
borderRight: '1px solid rgba(255,255,255,0.06)',
display: 'flex',
flexDirection: 'column',
@ -157,7 +190,7 @@ export default function MediaSidebar() {
strong
style={{
fontSize: 18,
color: '#9333ea',
color: token.colorPrimary,
letterSpacing: '0.5px',
}}
>
@ -178,7 +211,7 @@ export default function MediaSidebar() {
<PlayCircleOutlined
style={{
fontSize: 24,
color: '#9333ea',
color: token.colorPrimary,
}}
/>
)}
@ -245,7 +278,7 @@ export default function MediaSidebar() {
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
background: isActive ? '#9333ea' : 'transparent',
background: isActive ? token.colorPrimary : 'transparent',
borderRadius: collapsed ? 0 : 8,
color: isActive
? '#fff'
@ -255,7 +288,7 @@ export default function MediaSidebar() {
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
e.currentTarget.style.background = hoverBg;
}
}}
onMouseLeave={(e) => {
@ -306,7 +339,7 @@ export default function MediaSidebar() {
letterSpacing: '1px',
}}
>
ACTIVITY
MY CHATS
</Text>
{sections.activity ? (
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
@ -323,7 +356,7 @@ export default function MediaSidebar() {
overflowY: 'auto',
}}
>
{recentVideos.length === 0 ? (
{chatThreads.length === 0 ? (
<div style={{ padding: '12px 16px' }}>
<Text
type="secondary"
@ -332,30 +365,60 @@ export default function MediaSidebar() {
color: 'rgba(255,255,255,0.35)',
}}
>
No recent activity
{user ? 'No chat threads yet' : 'Sign in to see chats'}
</Text>
</div>
) : (
recentVideos.slice(0, 10).map((video, index) => (
chatThreads.map((thread) => (
<div
key={index}
key={thread.mediaId}
onClick={() => navigate(`/gallery/watch/${thread.mediaId}`)}
style={{
padding: '8px 16px',
fontSize: 12,
color: 'rgba(255,255,255,0.65)',
borderBottom: '1px solid rgba(255,255,255,0.03)',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<div style={{ marginBottom: 4 }}>{video.title}</div>
<Text
type="secondary"
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.35)',
}}
>
{video.timestamp}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<MessageOutlined style={{ fontSize: 12, color: token.colorPrimary }} />
<Text
ellipsis
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.85)',
fontWeight: thread.unreadCount > 0 ? 600 : 400,
flex: 1,
}}
>
{thread.videoTitle}
</Text>
{thread.unreadCount > 0 && (
<Badge count={thread.unreadCount} size="small" />
)}
</div>
{thread.lastMessage && (
<Text
type="secondary"
ellipsis
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.35)',
display: 'block',
paddingLeft: 18,
}}
>
{thread.lastMessage.userName}: {thread.lastMessage.content}
</Text>
)}
</div>
))
)}
@ -398,7 +461,7 @@ export default function MediaSidebar() {
{sections.online && (
<div style={{ padding: '12px 16px' }}>
<Space>
<TeamOutlined style={{ color: '#9333ea' }} />
<TeamOutlined style={{ color: token.colorPrimary }} />
<Text
style={{
fontSize: 13,
@ -460,12 +523,12 @@ export default function MediaSidebar() {
style={{
padding: '12px 16px',
marginBottom: 8,
background: 'rgba(147, 51, 234, 0.05)',
background: userInfoBg,
borderRadius: 8,
}}
>
<Space>
<UserOutlined style={{ color: '#9333ea' }} />
<UserOutlined style={{ color: token.colorPrimary }} />
<Text
style={{
fontSize: 13,
@ -479,60 +542,72 @@ export default function MediaSidebar() {
)}
{/* My Stats */}
<Tooltip title={collapsed ? 'My Stats' : ''} placement="right">
<div
onClick={() => navigate('/app')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
borderRadius: collapsed ? 0 : 8,
color: 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<BarChartOutlined style={{ fontSize: 18 }} />
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>My Stats</Text>}
</div>
</Tooltip>
{(() => {
const isActive = location.pathname === '/gallery/my-stats';
return (
<Tooltip title={collapsed ? 'My Stats' : ''} placement="right">
<div
onClick={() => navigate('/gallery/my-stats')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
background: isActive ? token.colorPrimary : 'transparent',
borderRadius: collapsed ? 0 : 8,
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<BarChartOutlined style={{ fontSize: 18 }} />
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit', fontWeight: isActive ? 500 : 400 }}>My Stats</Text>}
</div>
</Tooltip>
);
})()}
{/* Settings */}
<Tooltip title={collapsed ? 'Settings' : ''} placement="right">
<div
onClick={() => navigate('/app/settings')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
borderRadius: collapsed ? 0 : 8,
color: 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<SettingOutlined style={{ fontSize: 18 }} />
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>Settings</Text>}
</div>
</Tooltip>
{(() => {
const isActive = location.pathname === '/gallery/my-settings';
return (
<Tooltip title={collapsed ? 'Settings' : ''} placement="right">
<div
onClick={() => navigate('/gallery/my-settings')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: collapsed ? '12px 0' : '12px 16px',
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
background: isActive ? token.colorPrimary : 'transparent',
borderRadius: collapsed ? 0 : 8,
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<SettingOutlined style={{ fontSize: 18 }} />
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit', fontWeight: isActive ? 500 : 400 }}>Settings</Text>}
</div>
</Tooltip>
);
})()}
{/* Sign Out */}
<Tooltip title={collapsed ? 'Sign Out' : ''} placement="right">
@ -551,7 +626,7 @@ export default function MediaSidebar() {
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
@ -575,12 +650,12 @@ export default function MediaSidebar() {
margin: collapsed ? '4px 0' : '2px 0',
cursor: 'pointer',
borderRadius: collapsed ? 0 : 8,
color: '#9333ea',
color: token.colorPrimary,
transition: 'all 0.2s ease',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
@ -630,8 +705,8 @@ export default function MediaSidebar() {
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
e.currentTarget.style.color = '#9333ea';
e.currentTarget.style.background = hoverBg;
e.currentTarget.style.color = token.colorPrimary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';

View File

@ -0,0 +1,178 @@
import { Card, Typography, Space, theme } from 'antd';
import {
PlayCircleOutlined,
EyeOutlined,
UnorderedListOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import type { PlaylistSummary } from '@/types/media';
interface PlaylistCardProps {
playlist: PlaylistSummary;
}
function formatDuration(totalSeconds: number): string {
if (!totalSeconds) return '0:00';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatCount(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
}
export default function PlaylistCard({ playlist }: PlaylistCardProps) {
const { token } = theme.useToken();
const navigate = useNavigate();
return (
<Card
hoverable
style={{
borderRadius: 12,
overflow: 'hidden',
border: `1px solid ${token.colorBorderSecondary}`,
transition: 'all 0.2s ease',
cursor: 'pointer',
width: '100%',
}}
styles={{ body: { padding: 12 } }}
onClick={() => navigate(`/gallery/curated/${playlist.id}`)}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`;
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}}
cover={
<div
style={{
position: 'relative',
paddingTop: '56.25%',
background: '#000',
overflow: 'hidden',
}}
>
{playlist.thumbnailUrl ? (
<img
src={`/media${playlist.thumbnailUrl}`}
alt={playlist.name}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: `linear-gradient(135deg, ${token.colorPrimary}22, ${token.colorPrimary}44)`,
}}
>
<UnorderedListOutlined style={{ fontSize: 48, color: token.colorPrimary }} />
</div>
)}
{/* Playlist overlay badge */}
<div
style={{
position: 'absolute',
bottom: 0,
right: 0,
background: 'rgba(0,0,0,0.85)',
color: '#fff',
padding: '4px 10px',
fontSize: 12,
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 4,
borderTopLeftRadius: 8,
}}
>
<PlayCircleOutlined />
{playlist.videoCount} videos
</div>
</div>
}
>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Typography.Text
strong
style={{
fontSize: 14,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorText,
}}
title={playlist.name}
>
{playlist.name}
</Typography.Text>
<Typography.Text
style={{
fontSize: 12,
color: token.colorTextSecondary,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{playlist.creator.name || playlist.creator.email}
</Typography.Text>
{playlist.description && (
<Typography.Text
style={{
fontSize: 12,
color: token.colorTextTertiary,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical' as const,
overflow: 'hidden',
lineHeight: '1.4',
}}
>
{playlist.description}
</Typography.Text>
)}
<Space size={12} style={{ fontSize: 12, color: token.colorTextSecondary }}>
<Space size={4}>
<ClockCircleOutlined />
<span>{formatDuration(playlist.totalDurationSeconds)}</span>
</Space>
<Space size={4}>
<EyeOutlined />
<span>{formatCount(playlist.viewCount)}</span>
</Space>
</Space>
</Space>
</Card>
);
}

View File

@ -0,0 +1,228 @@
import { useEffect, useRef } from 'react';
import { Typography, Space, theme } from 'antd';
import { PlayCircleOutlined, ClockCircleOutlined } from '@ant-design/icons';
import type { PlaylistVideoItem } from '@/types/media';
const { Text } = Typography;
interface PlaylistSidebarPanelProps {
playlistName: string;
description?: string | null;
videos: PlaylistVideoItem[];
currentVideoId: number | null;
onVideoSelect: (mediaId: number) => void;
}
function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export default function PlaylistSidebarPanel({
playlistName,
description,
videos,
currentVideoId,
onVideoSelect,
}: PlaylistSidebarPanelProps) {
const { token } = theme.useToken();
const currentRef = useRef<HTMLDivElement>(null);
// Auto-scroll to the current playing video
useEffect(() => {
if (currentRef.current) {
currentRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [currentVideoId]);
const currentIndex = videos.findIndex((v) => v.mediaId === currentVideoId);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: token.colorBgContainer,
borderLeft: `1px solid rgba(255,255,255,0.06)`,
}}
>
{/* Header */}
<div
style={{
padding: '16px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
}}
>
<Text strong style={{ fontSize: 15, display: 'block', marginBottom: 4 }}>
{playlistName}
</Text>
{description && (
<Text
type="secondary"
style={{
fontSize: 12,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical' as const,
overflow: 'hidden',
marginBottom: 4,
}}
>
{description}
</Text>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{currentIndex + 1} / {videos.length} videos
</Text>
</div>
{/* Video list */}
<div
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
}}
>
{videos.map((item, index) => {
const isCurrent = item.mediaId === currentVideoId;
const isNext = index === currentIndex + 1;
const title =
item.video.title || item.video.filename.replace(/\.[^/.]+$/, '');
return (
<div
key={item.id}
ref={isCurrent ? currentRef : undefined}
onClick={() => onVideoSelect(item.mediaId)}
style={{
display: 'flex',
gap: 10,
padding: '10px 16px',
cursor: 'pointer',
background: isCurrent
? `${token.colorPrimary}22`
: 'transparent',
borderLeft: isCurrent
? `3px solid ${token.colorPrimary}`
: '3px solid transparent',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!isCurrent) {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
}
}}
onMouseLeave={(e) => {
if (!isCurrent) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{/* Position number */}
<div
style={{
width: 24,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{isCurrent ? (
<PlayCircleOutlined
style={{ color: token.colorPrimary, fontSize: 16 }}
/>
) : (
<Text
type="secondary"
style={{ fontSize: 12, fontWeight: 500 }}
>
{index + 1}
</Text>
)}
</div>
{/* Thumbnail */}
<div
style={{
width: 64,
height: 36,
flexShrink: 0,
borderRadius: 4,
overflow: 'hidden',
background: '#000',
}}
>
{item.video.thumbnailUrl ? (
<img
src={`/media${item.video.thumbnailUrl}`}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#444',
fontSize: 16,
}}
>
<PlayCircleOutlined />
</div>
)}
</div>
{/* Video info */}
<div style={{ flex: 1, minWidth: 0 }}>
{isNext && (
<Text
style={{
fontSize: 10,
color: token.colorPrimary,
textTransform: 'uppercase',
fontWeight: 600,
letterSpacing: '0.5px',
display: 'block',
marginBottom: 2,
}}
>
Up Next
</Text>
)}
<Text
ellipsis
style={{
fontSize: 13,
display: 'block',
fontWeight: isCurrent ? 500 : 400,
}}
>
{title}
</Text>
<Space size={8} style={{ fontSize: 11 }}>
<Text type="secondary">
<ClockCircleOutlined style={{ marginRight: 3 }} />
{formatDuration(item.video.durationSeconds)}
</Text>
</Space>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -3,6 +3,7 @@ import { Card, Tag, Space, Typography, theme, Modal } from 'antd';
import { PlayCircleOutlined, LikeOutlined, EyeOutlined, CommentOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { hexToRgba } from '@/utils/color';
interface PublicVideoCardProps {
video: {
@ -28,6 +29,7 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
// Hover video preview state
const [hovering, setHovering] = useState(false);
const [thumbnailError, setThumbnailError] = useState(false);
const hoverTimeout = useRef<number | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
@ -157,10 +159,11 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
objectFit: 'cover',
}}
/>
) : video.thumbnailPath ? (
) : video.thumbnailPath && !thumbnailError ? (
<img
src={`/media/public/${video.id}/thumbnail`}
alt={title}
onError={() => setThumbnailError(true)}
style={{
position: 'absolute',
top: 0,
@ -231,7 +234,7 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
width: 64,
height: 64,
borderRadius: '50%',
background: `rgba(147, 51, 234, 0.9)`,
background: hexToRgba(token.colorPrimary, 0.9),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',

View File

@ -1,6 +1,8 @@
import { useState } from 'react';
import { Space, Button, message } from 'antd';
import { Space, Button, message, theme } from 'antd';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color';
interface ReactionButtonsProps {
videoId: number;
@ -24,13 +26,15 @@ interface FloatingEmoji {
}
export default function ReactionButtons({ videoId, currentTime }: ReactionButtonsProps) {
const { token } = theme.useToken();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [floatingEmojis, setFloatingEmojis] = useState<FloatingEmoji[]>([]);
const [loading, setLoading] = useState(false);
const hoverBg = hexToRgba(token.colorPrimary, 0.1);
const handleReaction = async (reactionType: string, emoji: string) => {
// Check if user is logged in
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
if (!isAuthenticated) {
message.warning('Please log in to add reactions');
return;
}
@ -91,7 +95,7 @@ export default function ReactionButtons({ videoId, currentTime }: ReactionButton
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.2)';
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
e.currentTarget.style.background = hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';

View File

@ -13,6 +13,7 @@ import {
MoreOutlined,
LinkOutlined,
ClockCircleOutlined,
OrderedListOutlined,
} from '@ant-design/icons';
import { useState } from 'react';
import type { Video } from '@/types/media';
@ -25,6 +26,7 @@ interface VideoActionsProps {
onAnalytics?: (video: Video) => void;
onSchedule?: (video: Video) => void;
onDelete?: (video: Video) => void;
onAddToPlaylist?: (video: Video) => void;
onRefresh?: () => void;
}
@ -35,6 +37,7 @@ export default function VideoActions({
onAnalytics,
onSchedule,
onDelete,
onAddToPlaylist,
onRefresh,
}: VideoActionsProps) {
const [loading, setLoading] = useState(false);
@ -162,6 +165,12 @@ export default function VideoActions({
// Overflow menu items
const menuItems = [
{
key: 'add-to-playlist',
label: 'Add to Playlist',
icon: <OrderedListOutlined />,
onClick: () => onAddToPlaylist?.(video),
},
{
key: 'duplicate',
label: 'Duplicate',

View File

@ -1,10 +1,20 @@
import { Card, Checkbox, Tag, Spin } from 'antd';
import { ClockCircleOutlined, PlayCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { ClockCircleOutlined, PlayCircleOutlined, CheckCircleOutlined, ThunderboltFilled } from '@ant-design/icons';
import { useState } from 'react';
import type { Video } from '@/types/media';
import { getAuthCallbacks } from '@/lib/api';
import VideoActions from './VideoActions';
import ScheduleBadge from './ScheduleBadge';
/** Append JWT access token as query param for <img>/<video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoCardProps {
video: Video;
selected: boolean;
@ -15,6 +25,7 @@ interface VideoCardProps {
onAnalytics?: (video: Video) => void;
onSchedule?: (video: Video) => void;
onDelete?: (video: Video) => void;
onAddToPlaylist?: (video: Video) => void;
onRefresh?: () => void;
onTogglePublish?: (video: Video) => void;
showActions?: boolean;
@ -30,6 +41,7 @@ export default function VideoCard({
onAnalytics,
onSchedule,
onDelete,
onAddToPlaylist,
onRefresh,
onTogglePublish,
showActions = true,
@ -67,7 +79,7 @@ export default function VideoCard({
{video.thumbnailUrl && !thumbnailError ? (
<>
<img
src={video.thumbnailUrl}
src={getAuthenticatedUrl(video.thumbnailUrl)}
alt={video.title}
style={{
position: 'absolute',
@ -189,6 +201,7 @@ export default function VideoCard({
onAnalytics={onAnalytics}
onSchedule={onSchedule}
onDelete={onDelete}
onAddToPlaylist={onAddToPlaylist}
onRefresh={onRefresh}
/>
)}
@ -213,6 +226,29 @@ export default function VideoCard({
{formatDuration(video.duration)}
</div>
{/* Short video badge */}
{video.isShort && (
<div
style={{
position: 'absolute',
bottom: 8,
left: 8,
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<ThunderboltFilled style={{ fontSize: 12 }} />
Short
</div>
)}
{/* Select checkbox */}
<div
style={{

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
import { Alert, Spin } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
export interface VideoMetadata {
id: number;
@ -26,6 +27,8 @@ export interface VideoPlayerProps {
muted?: boolean;
poster?: string;
className?: string;
/** When true, sends auth token for metadata fetch and appends token to stream/thumbnail URLs */
isAdmin?: boolean;
onLoadedMetadata?: (metadata: VideoMetadata) => void;
onError?: (error: Error) => void;
}
@ -55,6 +58,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
muted = false,
poster,
className = '',
isAdmin = false,
onLoadedMetadata,
onError,
}, ref) => {
@ -118,13 +122,31 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
fetchMetadata();
}, [videoId]);
const appendToken = (url: string): string => {
if (!isAdmin) return url;
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${accessToken}`;
};
const fetchMetadata = async () => {
setLoading(true);
setError(null);
try {
// Use relative URL to go through nginx proxy
const response = await fetch(`/media/videos/${videoId}/metadata`);
const headers: Record<string, string> = {};
if (isAdmin) {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
}
const response = await fetch(`/media/videos/${videoId}/metadata`, { headers });
if (!response.ok) {
if (response.status === 404) {
@ -134,6 +156,13 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
}
const data = await response.json();
// For admin, append token to stream/thumbnail URLs so <video>/<img> can access them
if (isAdmin) {
if (data.streamUrl) data.streamUrl = appendToken(data.streamUrl);
if (data.thumbnailUrl) data.thumbnailUrl = appendToken(data.thumbnailUrl);
}
setMetadata(data);
if (onLoadedMetadata) {
@ -215,6 +244,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: 8,
background: '#000',
}}

View File

@ -2,6 +2,16 @@ import { Modal } from 'antd';
import { useEffect, useRef, useState } from 'react';
import type { Video } from '@/types/media';
import { mediaApi } from '@/lib/media-api';
import { getAuthCallbacks } from '@/lib/api';
/** Append JWT access token as query param for <video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoViewerModalProps {
video: Video | null;
@ -165,7 +175,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
>
<video
ref={videoRef}
src={`/media/videos/${video.id}/stream`}
src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)}
controls
autoPlay
style={{

View File

@ -0,0 +1,49 @@
import { Grid } from 'antd';
import { useChatBar } from './ChatBarContext';
import MiniChatWindow from './MiniChatWindow';
import MinimizedChat from './MinimizedChat';
const { useBreakpoint } = Grid;
export default function ChatBar() {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { windows, closeChat, toggleExpanded } = useChatBar();
if (windows.length === 0) return null;
return (
<div
style={{
position: 'fixed',
bottom: isMobile ? 56 : 0, // Above mobile bottom nav
right: 16,
display: 'flex',
gap: 8,
alignItems: 'flex-end',
zIndex: 1000,
pointerEvents: 'none', // Allow clicks to pass through gaps
}}
>
{windows.map((w) =>
w.isExpanded ? (
<div key={w.videoId} style={{ pointerEvents: 'auto' }}>
<MiniChatWindow
window={w}
onToggle={() => toggleExpanded(w.videoId)}
onClose={() => closeChat(w.videoId)}
/>
</div>
) : (
<div key={w.videoId} style={{ pointerEvents: 'auto' }}>
<MinimizedChat
window={w}
onExpand={() => toggleExpanded(w.videoId)}
onClose={() => closeChat(w.videoId)}
/>
</div>
)
)}
</div>
);
}

View File

@ -0,0 +1,127 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { Grid } from 'antd';
const { useBreakpoint } = Grid;
export interface ChatWindow {
videoId: number;
videoTitle: string;
thumbnailPath?: string | null;
isExpanded: boolean;
unreadCount: number;
}
interface ChatBarContextValue {
windows: ChatWindow[];
openChat: (videoId: number, videoTitle: string, thumbnailPath?: string | null) => void;
closeChat: (videoId: number) => void;
toggleExpanded: (videoId: number) => void;
minimizeAll: () => void;
clearUnread: (videoId: number) => void;
incrementUnread: (videoId: number) => void;
maxWindows: number;
}
const ChatBarContext = createContext<ChatBarContextValue | undefined>(undefined);
export function useChatBar() {
const context = useContext(ChatBarContext);
if (!context) {
throw new Error('useChatBar must be used within ChatBarProvider');
}
return context;
}
export function ChatBarProvider({ children }: { children: ReactNode }) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const maxWindows = isMobile ? 1 : 3;
const [windows, setWindows] = useState<ChatWindow[]>([]);
const openChat = useCallback(
(videoId: number, videoTitle: string, thumbnailPath?: string | null) => {
setWindows((prev) => {
// Already open? Just expand it
const existing = prev.find((w) => w.videoId === videoId);
if (existing) {
return prev.map((w) =>
w.videoId === videoId ? { ...w, isExpanded: true, unreadCount: 0 } : w
);
}
const newWindow: ChatWindow = {
videoId,
videoTitle,
thumbnailPath,
isExpanded: true,
unreadCount: 0,
};
// If at max, close the oldest minimized window (or oldest)
if (prev.length >= maxWindows) {
const minimized = prev.filter((w) => !w.isExpanded);
if (minimized.length > 0) {
// Remove oldest minimized
const toRemove = minimized[0]!;
return [...prev.filter((w) => w.videoId !== toRemove.videoId), newWindow];
}
// Remove first window
return [...prev.slice(1), newWindow];
}
return [...prev, newWindow];
});
},
[maxWindows]
);
const closeChat = useCallback((videoId: number) => {
setWindows((prev) => prev.filter((w) => w.videoId !== videoId));
}, []);
const toggleExpanded = useCallback((videoId: number) => {
setWindows((prev) =>
prev.map((w) =>
w.videoId === videoId ? { ...w, isExpanded: !w.isExpanded, unreadCount: 0 } : w
)
);
}, []);
const minimizeAll = useCallback(() => {
setWindows((prev) => prev.map((w) => ({ ...w, isExpanded: false })));
}, []);
const clearUnread = useCallback((videoId: number) => {
setWindows((prev) =>
prev.map((w) => (w.videoId === videoId ? { ...w, unreadCount: 0 } : w))
);
}, []);
const incrementUnread = useCallback((videoId: number) => {
setWindows((prev) =>
prev.map((w) =>
w.videoId === videoId && !w.isExpanded
? { ...w, unreadCount: w.unreadCount + 1 }
: w
)
);
}, []);
return (
<ChatBarContext.Provider
value={{
windows,
openChat,
closeChat,
toggleExpanded,
minimizeAll,
clearUnread,
incrementUnread,
maxWindows,
}}
>
{children}
</ChatBarContext.Provider>
);
}

View File

@ -0,0 +1,93 @@
import { Typography, theme } from 'antd';
import {
CloseOutlined,
MinusOutlined,
ExpandOutlined,
} from '@ant-design/icons';
import type { ChatWindow } from './ChatBarContext';
import MiniLiveChat from './MiniLiveChat';
const { Text } = Typography;
interface MiniChatWindowProps {
window: ChatWindow;
onToggle: () => void;
onClose: () => void;
}
export default function MiniChatWindow({
window: chatWindow,
onToggle,
onClose,
}: MiniChatWindowProps) {
const { token } = theme.useToken();
return (
<div
style={{
width: 320,
height: chatWindow.isExpanded ? 400 : 40,
background: token.colorBgContainer,
borderRadius: '12px 12px 0 0',
border: `1px solid ${token.colorBorder}`,
borderBottom: 'none',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
transition: 'height 0.2s ease',
boxShadow: '0 -4px 20px rgba(0,0,0,0.3)',
}}
>
{/* Title bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
background: token.colorBgElevated,
borderBottom: chatWindow.isExpanded ? `1px solid ${token.colorBorder}` : 'none',
cursor: 'pointer',
userSelect: 'none',
minHeight: 40,
}}
onClick={onToggle}
>
<Text
ellipsis
strong
style={{ flex: 1, fontSize: 13 }}
>
{chatWindow.videoTitle}
</Text>
<div
style={{ display: 'flex', gap: 8 }}
onClick={(e) => e.stopPropagation()}
>
{chatWindow.isExpanded ? (
<MinusOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={onToggle}
/>
) : (
<ExpandOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={onToggle}
/>
)}
<CloseOutlined
style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer' }}
onClick={onClose}
/>
</div>
</div>
{/* Chat content */}
{chatWindow.isExpanded && (
<div style={{ flex: 1, overflow: 'hidden' }}>
<MiniLiveChat videoId={chatWindow.videoId} isExpanded={chatWindow.isExpanded} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,229 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Input, Button, Typography, Tag, Spin, theme } from 'antd';
import { SendOutlined, UserOutlined } from '@ant-design/icons';
import { useMediaAuth } from '@/contexts/MediaAuthContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
const { Text } = Typography;
const { TextArea } = Input;
interface Comment {
id: number;
content: string;
createdAt: string;
user: { id: string; name: string } | null;
}
interface MiniLiveChatProps {
videoId: number;
isExpanded: boolean;
}
export default function MiniLiveChat({ videoId, isExpanded }: MiniLiveChatProps) {
const { token } = theme.useToken();
const { isAuthenticated, isApproved } = useMediaAuth();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [input, setInput] = useState('');
const [submitting, setSubmitting] = useState(false);
const [sseConnected, setSSEConnected] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
// Auto-scroll to bottom
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, []);
// Fetch initial comments
useEffect(() => {
if (!isExpanded) return;
const fetchComments = async () => {
setLoading(true);
try {
const res = await fetch(`/media/public/${videoId}/comments?limit=50`);
if (res.ok) {
const data = await res.json();
setComments(data.comments || []);
setTimeout(scrollToBottom, 100);
}
} catch {
// Silent
} finally {
setLoading(false);
}
};
fetchComments();
// Mark as read
if (isAuthenticated) {
mediaApi.post(`/media/chat/threads/${videoId}/read`).catch(() => {});
}
}, [videoId, isExpanded, scrollToBottom, isAuthenticated]);
// SSE connection — only when expanded
useEffect(() => {
if (!isExpanded) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
setSSEConnected(false);
}
return;
}
const es = new EventSource(`/media/public/${videoId}/chat-stream`);
es.onopen = () => setSSEConnected(true);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'connected') return;
if (data.type === 'new_comment') {
setComments((prev) => {
if (prev.some((c) => c.id === data.comment.id)) return prev;
const updated = [...prev, data.comment];
return updated.slice(-50);
});
setTimeout(scrollToBottom, 100);
}
} catch {
// Ignore
}
};
es.onerror = () => setSSEConnected(false);
eventSourceRef.current = es;
return () => {
es.close();
eventSourceRef.current = null;
};
}, [videoId, isExpanded, scrollToBottom]);
// Submit comment
const handleSubmit = async () => {
if (!input.trim() || submitting || !isAuthenticated) return;
setSubmitting(true);
try {
await mediaPublicApi.post(`/public/${videoId}/comments`, {
content: input.trim(),
});
setInput('');
} catch {
// Silent
} finally {
setSubmitting(false);
}
};
// Format time
const formatTime = (iso: string) => {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header */}
<div
style={{
padding: '6px 10px',
borderBottom: `1px solid ${token.colorBorder}`,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Text strong style={{ fontSize: 12 }}>Chat</Text>
{sseConnected && <Tag color="success" style={{ fontSize: 10, lineHeight: '14px', padding: '0 4px' }}>Live</Tag>}
</div>
{/* Messages */}
<div
ref={scrollRef}
style={{
flex: 1,
overflowY: 'auto',
padding: 4,
}}
>
{loading && (
<div style={{ textAlign: 'center', padding: 20 }}>
<Spin size="small" />
</div>
)}
{!loading && comments.length === 0 && (
<div style={{ textAlign: 'center', padding: 20 }}>
<Text type="secondary" style={{ fontSize: 11 }}>No messages yet</Text>
</div>
)}
{comments.map((c) => (
<div key={c.id} style={{ padding: '4px 6px', fontSize: 12 }}>
<span style={{ display: 'flex', gap: 4, alignItems: 'baseline' }}>
<UserOutlined style={{ fontSize: 10, color: token.colorPrimary }} />
<Text strong style={{ fontSize: 11 }}>{c.user?.name || 'Anon'}</Text>
<Text type="secondary" style={{ fontSize: 10 }}>{formatTime(c.createdAt)}</Text>
</span>
<div style={{ paddingLeft: 14, fontSize: 12, wordBreak: 'break-word' }}>
{c.content}
</div>
</div>
))}
</div>
{/* Input */}
{isAuthenticated && isApproved && (
<div style={{ padding: 6, borderTop: `1px solid ${token.colorBorder}` }}>
<div style={{ display: 'flex', gap: 4 }}>
<TextArea
value={input}
onChange={(e) => setInput(e.target.value)}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder="Message..."
maxLength={1000}
autoSize={{ minRows: 1, maxRows: 2 }}
disabled={submitting}
style={{ fontSize: 12 }}
/>
<Button
type="primary"
size="small"
icon={<SendOutlined />}
onClick={handleSubmit}
loading={submitting}
disabled={!input.trim()}
/>
</div>
</div>
)}
{!isAuthenticated && (
<div style={{ padding: '8px', textAlign: 'center', borderTop: `1px solid ${token.colorBorder}` }}>
<Text type="secondary" style={{ fontSize: 11 }}>Sign in to chat</Text>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { Badge, Typography, theme } from 'antd';
import { CloseOutlined, MessageOutlined } from '@ant-design/icons';
import type { ChatWindow } from './ChatBarContext';
const { Text } = Typography;
interface MinimizedChatProps {
window: ChatWindow;
onExpand: () => void;
onClose: () => void;
}
export default function MinimizedChat({ window: chatWindow, onExpand, onClose }: MinimizedChatProps) {
const { token } = theme.useToken();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
background: token.colorBgElevated,
borderRadius: '12px 12px 0 0',
border: `1px solid ${token.colorBorder}`,
borderBottom: 'none',
cursor: 'pointer',
minWidth: 180,
maxWidth: 240,
}}
onClick={onExpand}
>
<Badge count={chatWindow.unreadCount} size="small" offset={[-2, 2]}>
<MessageOutlined style={{ fontSize: 16, color: token.colorPrimary }} />
</Badge>
<Text
ellipsis
style={{
flex: 1,
fontSize: 13,
fontWeight: chatWindow.unreadCount > 0 ? 600 : 400,
}}
>
{chatWindow.videoTitle}
</Text>
<CloseOutlined
style={{ fontSize: 12, color: token.colorTextSecondary }}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
/>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
export interface VideoData {
id: number;
@ -43,7 +43,6 @@ interface ExpandedVideoProviderProps {
export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [state, setState] = useState<ExpandedVideoState>({
videoId: null,
@ -53,20 +52,20 @@ export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps)
const expandVideo = useCallback((id: number, video: VideoData) => {
setState({ videoId: id, video });
// Update URL with ?expanded=id
const newParams = new URLSearchParams(searchParams);
// Update URL with ?expanded=id (read current params at call time)
const newParams = new URLSearchParams(window.location.search);
newParams.set('expanded', id.toString());
navigate({ search: newParams.toString() }, { replace: true });
}, [navigate, searchParams]);
}, [navigate]);
const collapseVideo = useCallback(() => {
setState({ videoId: null, video: null });
// Remove URL param
const newParams = new URLSearchParams(searchParams);
// Remove URL param (read current params at call time)
const newParams = new URLSearchParams(window.location.search);
newParams.delete('expanded');
navigate({ search: newParams.toString() }, { replace: true });
}, [navigate, searchParams]);
}, [navigate]);
const value: ExpandedVideoContextValue = {
state,

View File

@ -1,12 +1,6 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { jwtDecode } from 'jwt-decode';
interface JwtPayload {
id: string;
email: string;
role: string;
exp: number;
}
import { createContext, useContext, ReactNode } from 'react';
import { useAuthStore } from '@/stores/auth.store';
import { isAdmin } from '@/utils/roles';
interface MediaAuthState {
isAuthenticated: boolean;
@ -16,10 +10,11 @@ interface MediaAuthState {
email: string;
role: string;
} | null;
token: string | null;
}
interface MediaAuthContextValue extends MediaAuthState {
checkAuth: () => void; // Re-check auth state (e.g., after login)
checkAuth: () => void;
}
const MediaAuthContext = createContext<MediaAuthContextValue | undefined>(undefined);
@ -37,81 +32,29 @@ interface MediaAuthProviderProps {
}
export function MediaAuthProvider({ children }: MediaAuthProviderProps) {
const [authState, setAuthState] = useState<MediaAuthState>({
isAuthenticated: false,
isApproved: false,
user: null,
});
// Read auth state directly from the Zustand auth store (single source of truth)
const authUser = useAuthStore((s) => s.user);
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const hydrate = useAuthStore((s) => s.hydrate);
// Approved means user has an admin role (admins can chat)
const isApproved = isAuthenticated && !!authUser && isAdmin(authUser);
const user = authUser
? { id: authUser.id, email: authUser.email, role: authUser.role }
: null;
const checkAuth = () => {
const token = localStorage.getItem('accessToken');
if (!token) {
setAuthState({
isAuthenticated: false,
isApproved: false,
user: null,
});
return;
}
try {
// Decode JWT to extract user info
const decoded = jwtDecode<JwtPayload>(token);
// Check if token is expired
const now = Date.now() / 1000;
if (decoded.exp < now) {
// Token expired
localStorage.removeItem('accessToken');
setAuthState({
isAuthenticated: false,
isApproved: false,
user: null,
});
return;
}
// Approved means NOT a USER or TEMP role
const isApproved = !['USER', 'TEMP'].includes(decoded.role);
setAuthState({
isAuthenticated: true,
isApproved,
user: {
id: decoded.id,
email: decoded.email,
role: decoded.role,
},
});
} catch (error) {
console.error('Failed to decode JWT:', error);
localStorage.removeItem('accessToken');
setAuthState({
isAuthenticated: false,
isApproved: false,
user: null,
});
}
// Re-hydrate from persisted storage
hydrate();
};
// Check auth on mount
useEffect(() => {
checkAuth();
// Listen for storage events (e.g., login in another tab)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'accessToken') {
checkAuth();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
const value: MediaAuthContextValue = {
...authState,
isAuthenticated,
isApproved,
user,
token: accessToken,
checkAuth,
};

View File

@ -0,0 +1,79 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useAuthStore } from '@/stores/auth.store';
export interface ChatNotification {
id: string; // generated client-side
type: 'chat_reply';
videoId: number;
videoTitle: string;
commentId: number;
commenterName: string;
contentPreview: string;
receivedAt: number;
}
export function useChatNotifications() {
const { accessToken, isAuthenticated } = useAuthStore();
const [notifications, setNotifications] = useState<ChatNotification[]>([]);
const eventSourceRef = useRef<EventSource | null>(null);
const notifCounterRef = useRef(0);
const clearNotification = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const clearAll = useCallback(() => {
setNotifications([]);
}, []);
useEffect(() => {
if (!isAuthenticated || !accessToken) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
return;
}
// Use relative URL through nginx proxy
const url = `/media/media/notifications/stream?token=${encodeURIComponent(accessToken)}`;
const es = new EventSource(url);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'connected') return;
if (data.type === 'chat_reply') {
const notif: ChatNotification = {
...data,
id: `notif-${++notifCounterRef.current}-${Date.now()}`,
receivedAt: Date.now(),
};
setNotifications((prev) => {
// Keep max 10 notifications
const updated = [...prev, notif];
return updated.slice(-10);
});
}
} catch {
// Ignore parse errors
}
};
es.onerror = () => {
// Auto-reconnect is handled by EventSource
};
eventSourceRef.current = es;
return () => {
es.close();
eventSourceRef.current = null;
};
}, [isAuthenticated, accessToken]);
return { notifications, clearNotification, clearAll };
}

View File

@ -1,66 +1,849 @@
import { Row, Col, Card, Statistic, Alert, Typography } from 'antd';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Row, Col, Card, Statistic, Typography, Tag, Badge, Button,
Progress, Space, Tooltip, Spin, Grid, Flex,
} from 'antd';
import {
TeamOutlined,
SendOutlined,
EnvironmentOutlined,
MailOutlined,
VideoCameraOutlined,
CalendarOutlined,
ReloadOutlined,
PlusOutlined,
UploadOutlined,
FileTextOutlined,
ScissorOutlined,
CompassOutlined,
RocketOutlined,
CloudServerOutlined,
DatabaseOutlined,
BranchesOutlined,
QrcodeOutlined,
BarChartOutlined,
GlobalOutlined,
CodeOutlined,
BookOutlined,
DesktopOutlined,
HddOutlined,
DashboardOutlined,
ContainerOutlined,
ApiOutlined,
CheckCircleFilled,
CloseCircleFilled,
MinusCircleFilled,
IdcardOutlined,
} from '@ant-design/icons';
import {
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
ResponsiveContainer, Cell,
} from 'recharts';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { hasAnyRole } from '@/utils/roles';
import SystemGauges from '@/components/dashboard/SystemGauges';
import MiniDonutChart from '@/components/dashboard/MiniDonutChart';
import RequestTrafficChart from '@/components/dashboard/RequestTrafficChart';
import LatencyBandsChart from '@/components/dashboard/LatencyBandsChart';
import ContainerPopover from '@/components/dashboard/ContainerPopover';
import ContainerMemoryChart from '@/components/dashboard/ContainerMemoryChart';
import type {
DashboardSummary, QueueStats, ServicesStatus,
SystemInfo, ContainerInfo, WeatherData, ApiMetrics,
TimeSeriesResult, ContainerResource, ContainerResourcesResponse,
} from '@/types/api';
const { Title } = Typography;
const { Text, Title } = Typography;
// --- Pulse animation CSS (injected once) ---
const PULSE_STYLE_ID = 'dashboard-pulse-css';
if (typeof document !== 'undefined' && !document.getElementById(PULSE_STYLE_ID)) {
const style = document.createElement('style');
style.id = PULSE_STYLE_ID;
style.textContent = `
@keyframes dashboard-pulse {
0% { box-shadow: 0 0 0 0 rgba(82,196,26,0.5); }
70% { box-shadow: 0 0 0 6px rgba(82,196,26,0); }
100% { box-shadow: 0 0 0 0 rgba(82,196,26,0); }
}
.svc-badge-online .ant-badge-status-dot {
animation: dashboard-pulse 2s infinite;
}
`;
document.head.appendChild(style);
}
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
INFLUENCE_ADMIN: 'Influence',
MAP_ADMIN: 'Map',
USER: 'User',
TEMP: 'Temp',
};
interface HealthStatus {
status: string;
checks: { database: string; redis: string };
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function getWeatherIcon(code: number, isDay: boolean): string {
if (code === 0) return isDay ? '\u2600\uFE0F' : '\uD83C\uDF19'; // sun / moon
if (code <= 2) return isDay ? '\u26C5' : '\uD83C\uDF19'; // partly cloudy / moon
if (code === 3) return '\u2601\uFE0F'; // overcast
if (code <= 48) return '\uD83C\uDF2B\uFE0F'; // fog
if (code <= 57) return '\uD83C\uDF27\uFE0F'; // drizzle
if (code <= 67) return '\uD83C\uDF27\uFE0F'; // rain
if (code <= 77) return '\u2744\uFE0F'; // snow
if (code <= 82) return '\uD83C\uDF26\uFE0F'; // rain showers
if (code <= 86) return '\uD83C\uDF28\uFE0F'; // snow showers
return '\u26C8\uFE0F'; // thunderstorm
}
/** Container tile background gradient based on status */
function containerTileBg(running: boolean, status: string): { bg: string; border: string } {
if (running) return {
bg: 'linear-gradient(135deg, rgba(82,196,26,0.08) 0%, rgba(82,196,26,0.02) 100%)',
border: 'rgba(82,196,26,0.25)',
};
if (status === 'not_found') return {
bg: 'linear-gradient(135deg, rgba(153,153,153,0.08) 0%, rgba(153,153,153,0.02) 100%)',
border: 'rgba(153,153,153,0.25)',
};
return {
bg: 'linear-gradient(135deg, rgba(255,77,79,0.10) 0%, rgba(255,77,79,0.03) 100%)',
border: 'rgba(255,77,79,0.3)',
};
}
/** Status code color for donut and bar charts */
function statusColor(code: string): string {
if (code.startsWith('2')) return '#52c41a';
if (code.startsWith('3')) return '#1890ff';
if (code.startsWith('4')) return '#faad14';
return '#ff4d4f';
}
const TIME_SERIES_METRICS = 'request_rate_2xx,request_rate_4xx,request_rate_5xx,latency_p50,latency_p95,latency_p99,cpu_usage,memory_usage';
export default function DashboardPage() {
const navigate = useNavigate();
const { user } = useAuthStore();
const { settings } = useSettingsStore();
const screens = Grid.useBreakpoint();
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [queue, setQueue] = useState<QueueStats | null>(null);
const [health, setHealth] = useState<HealthStatus | null>(null);
const [services, setServices] = useState<ServicesStatus | null>(null);
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [containers, setContainers] = useState<ContainerInfo[] | null>(null);
const [weather, setWeather] = useState<WeatherData | null>(null);
const [apiMetrics, setApiMetrics] = useState<ApiMetrics | null>(null);
const [timeSeries, setTimeSeries] = useState<TimeSeriesResult | null>(null);
const [containerResources, setContainerResources] = useState<ContainerResource[] | null>(null);
const [loading, setLoading] = useState(true);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
const fetchData = useCallback(async () => {
const promises: Promise<void>[] = [
api.get<DashboardSummary>('/dashboard/summary').then(({ data }) => setSummary(data)).catch(() => {}),
api.get<QueueStats>('/email-queue/stats').then(({ data }) => setQueue(data)).catch(() => {}),
api.get<HealthStatus>('/health').then(({ data }) => setHealth(data)).catch(() => {}),
api.get<WeatherData>('/dashboard/weather').then(({ data }) => {
if ('error' in data) setWeather(null);
else setWeather(data);
}).catch(() => {}),
];
if (isSuperAdmin) {
promises.push(
api.get<ServicesStatus>('/services/status').then(({ data }) => setServices(data)).catch(() => {}),
api.get<SystemInfo>('/dashboard/system').then(({ data }) => setSystemInfo(data)).catch(() => {}),
api.get<ContainerInfo[]>('/dashboard/containers').then(({ data }) => setContainers(data)).catch(() => {}),
api.get<ApiMetrics>('/dashboard/api-metrics').then(({ data }) => {
if ('error' in data) setApiMetrics(null);
else setApiMetrics(data);
}).catch(() => {}),
api.get<TimeSeriesResult>('/dashboard/time-series', {
params: { metrics: TIME_SERIES_METRICS, range: '1h', step: '5m' },
}).then(({ data }) => setTimeSeries(data)).catch(() => {}),
api.get<ContainerResourcesResponse>('/dashboard/container-resources').then(({ data }) => {
setContainerResources(data.containers || []);
}).catch(() => {}),
);
}
await Promise.allSettled(promises);
setLastRefresh(new Date());
setLoading(false);
}, [isSuperAdmin]);
useEffect(() => {
fetchData();
intervalRef.current = setInterval(fetchData, 60_000);
return () => clearInterval(intervalRef.current);
}, [fetchData]);
const handleRefresh = () => {
setLoading(true);
fetchData();
};
const showInfluence = settings?.enableInfluence !== false;
const showMap = settings?.enableMap !== false;
const showMedia = settings?.enableMediaFeatures === true;
const geocodePct = summary && summary.locations.total > 0
? Math.round((summary.locations.geocoded / summary.locations.total) * 100) : 0;
// Build container resource map for popovers
const containerResourceMap = useMemo(() => {
const map = new Map<string, ContainerResource>();
if (containerResources) {
for (const cr of containerResources) map.set(cr.name, cr);
}
return map;
}, [containerResources]);
// Campaign status donut data
const campaignDonutData = useMemo(() => {
if (!summary) return [];
return [
{ name: 'Active', value: summary.campaigns.active, color: '#52c41a' },
{ name: 'Draft', value: summary.campaigns.draft, color: '#1890ff' },
{ name: 'Paused', value: summary.campaigns.paused, color: '#faad14' },
{ name: 'Archived', value: summary.campaigns.archived, color: '#999' },
];
}, [summary]);
// Status code donut data
const statusDonutData = useMemo(() => {
if (!apiMetrics?.statusBreakdown?.length) return [];
return apiMetrics.statusBreakdown.map(s => ({
name: s.status,
value: s.count,
color: statusColor(s.status),
}));
}, [apiMetrics]);
// Top routes horizontal bar chart data
const routeBarData = useMemo(() => {
if (!apiMetrics?.topRoutes?.length) return [];
return apiMetrics.topRoutes.slice(0, 8).map(r => ({
name: `${r.method} ${r.route}`.slice(0, 30),
count: r.count,
}));
}, [apiMetrics]);
if (loading && !summary) {
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>;
}
return (
<>
<Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>
<div>
{/* === Welcome Banner === */}
<Card
style={{ marginBottom: 16, background: 'linear-gradient(135deg, #1890ff 0%, #722ed1 100%)', border: 'none' }}
styles={{ body: { padding: screens.md ? '20px 24px' : '16px' } }}
>
<Flex justify="space-between" align="center" wrap="wrap" gap={12}>
<div>
<Title level={4} style={{ color: '#fff', margin: 0 }}>
Welcome{user?.name ? `, ${user.name}` : ''}
</Title>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12 }}>
{lastRefresh && `Updated ${lastRefresh.toLocaleTimeString()}`}
</Text>
</div>
<Flex gap={6} wrap="wrap" justify="flex-end">
{showInfluence && (
<Button size="small" icon={<PlusOutlined />} onClick={() => navigate('/app/campaigns')}>Campaign</Button>
)}
{showMap && (
<Button size="small" icon={<EnvironmentOutlined />} onClick={() => navigate('/app/map')}>Location</Button>
)}
{showMedia && (
<Button size="small" icon={<UploadOutlined />} onClick={() => navigate('/app/media/library')}>Video</Button>
)}
<Button size="small" icon={<FileTextOutlined />} onClick={() => navigate('/app/pages')}>Page</Button>
{isSuperAdmin && (
<>
<Button size="small" icon={<BarChartOutlined />} onClick={() => navigate('/app/observability')}>Monitoring</Button>
<Button size="small" icon={<CloudServerOutlined />} onClick={() => navigate('/app/tunnel')}>Tunnel</Button>
<Button size="small" icon={<DatabaseOutlined />} onClick={() => navigate('/app/services/nocodb')}>NocoDB</Button>
<Button size="small" icon={<BranchesOutlined />} onClick={() => navigate('/app/services/n8n')}>Workflows</Button>
<Button size="small" icon={<GlobalOutlined />} onClick={() => navigate('/app/services/gitea')}>Git</Button>
<Button size="small" icon={<CodeOutlined />} onClick={() => navigate('/app/code')}>Code</Button>
<Button size="small" icon={<BookOutlined />} onClick={() => navigate('/app/docs')}>Docs</Button>
<Button size="small" icon={<QrcodeOutlined />} onClick={() => navigate('/app/services/miniqr')}>QR</Button>
<Button size="small" icon={<DashboardOutlined />} onClick={() => navigate('/app/map/data-quality')}>Data Quality</Button>
</>
)}
<Button size="small" icon={<ReloadOutlined spin={loading} />} onClick={handleRefresh}>Refresh</Button>
</Flex>
</Flex>
</Card>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Total Users"
value="--"
prefix={<TeamOutlined />}
/>
{/* === Weather + Key Metrics Row === */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{weather && (
<Col xs={12} sm={12} lg={4}>
<Card size="small" style={{ height: '100%', borderTop: '3px solid #1890ff' }} styles={{ body: { padding: '12px 16px' } }}>
<div style={{ fontSize: 28, lineHeight: 1 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</div>
<Text strong style={{ fontSize: 22 }}>{Math.round(weather.temperature)}{'°C'}</Text>
<br />
<Text type="secondary" style={{ fontSize: 11 }}>
{weather.weatherDescription}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 11 }}>
{'Feels ' + Math.round(weather.apparentTemperature) + '° · ' + weather.humidity + '% · ' + Math.round(weather.windSpeed) + ' km/h'}
</Text>
</Card>
</Col>
)}
<Col xs={12} sm={12} lg={4}>
<StatCard title="Users" value={summary?.users.total} subtitle={summary ? `${summary.users.active} active` : ''} icon={<TeamOutlined />} color="#1890ff" onClick={() => navigate('/app/users')} />
</Col>
{showInfluence && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Campaigns" value={summary?.campaigns.active} subtitle={summary ? `of ${summary.campaigns.total} total` : ''} icon={<SendOutlined />} color="#52c41a" onClick={() => navigate('/app/campaigns')} />
</Col>
)}
{showMap && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Locations" value={summary?.locations.total} subtitle={summary ? `${geocodePct}% geocoded` : ''} icon={<EnvironmentOutlined />} color="#722ed1" onClick={() => navigate('/app/map')} />
</Col>
)}
{showInfluence && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Emails Sent" value={summary?.emails.sent} subtitle={summary?.emails.failed ? `${summary.emails.failed} failed` : '0 failed'} icon={<MailOutlined />} color="#faad14" onClick={() => navigate('/app/email-queue')} />
</Col>
)}
{showMedia && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Videos" value={summary?.videos.published} subtitle={summary ? `of ${summary.videos.total} total` : ''} icon={<VideoCameraOutlined />} color="#13c2c2" onClick={() => navigate('/app/media/library')} />
</Col>
)}
{showMap && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Shifts" value={summary?.shifts.upcoming} subtitle={summary ? `${summary.shifts.open} open` : ''} icon={<CalendarOutlined />} color="#eb2f96" onClick={() => navigate('/app/map/shifts')} />
</Col>
)}
</Row>
{/* === Module Overview Row === */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{showInfluence && (
<Col xs={24} lg={12}>
<Card
title={<><SendOutlined style={{ marginRight: 6 }} />Influence</>}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/campaigns')}>View</Button>}
style={{ height: '100%' }}
>
{summary && (
<Flex gap={12} align="flex-start">
<div style={{ flex: 1 }}>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Flex gap={4} wrap="wrap">
<Text strong>Campaigns:</Text>
<Tag color="green">{summary.campaigns.active} Active</Tag>
<Tag>{summary.campaigns.draft} Draft</Tag>
{summary.campaigns.paused > 0 && <Tag color="orange">{summary.campaigns.paused} Paused</Tag>}
</Flex>
<div>
<Text strong>Responses: </Text>
<Text>{summary.responses.total} total</Text>
{summary.responses.pending > 0 && <Tag color="orange" style={{ marginLeft: 6 }}>{summary.responses.pending} pending</Tag>}
</div>
<div>
<Text strong>Queue: </Text>
{queue ? (
<>
<Text>{queue.waiting} waiting, {queue.active} active</Text>
{queue.paused && <Tag color="red" style={{ marginLeft: 6 }}>Paused</Tag>}
</>
) : <Text type="secondary">unavailable</Text>}
</div>
{summary.campaignModeration.pendingReview > 0 && (
<div>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate('/app/campaign-moderation')}>
<Tag color="orange">{summary.campaignModeration.pendingReview} pending review</Tag>
</Button>
</div>
)}
</Space>
</div>
{/* Campaign status donut */}
{summary.campaigns.total > 0 && screens.sm && (
<div style={{ width: 100, flexShrink: 0 }}>
<MiniDonutChart data={campaignDonutData} height={100} innerRadius={24} outerRadius={42} />
</div>
)}
</Flex>
)}
</Card>
</Col>
)}
{showMap && (
<Col xs={24} lg={12}>
<Card
title={<><CompassOutlined style={{ marginRight: 6 }} />Map & Canvassing</>}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/map')}>View</Button>}
style={{ height: '100%' }}
>
{summary && (
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Flex align="center" gap={8}>
<Text strong>Geocoding:</Text>
<Progress percent={geocodePct} size="small" style={{ flex: 1 }} />
<Text type="secondary" style={{ fontSize: 12 }}>{summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()}</Text>
</Flex>
<div>
<Text strong>Addresses: </Text>
<Text>{summary.locations.addresses.toLocaleString()}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}><ScissorOutlined /> {summary.cuts.total} cuts</Text>
</div>
<div>
<Text strong>Canvassing: </Text>
<Text>{summary.canvass.totalVisits} visits</Text>
{summary.canvass.activeSessions > 0 && <Tag color="green" style={{ marginLeft: 6 }}>{summary.canvass.activeSessions} active</Tag>}
</div>
</Space>
)}
</Card>
</Col>
)}
<Col xs={24} lg={12}>
<Card
title={<><FileTextOutlined style={{ marginRight: 6 }} />Content</>}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/pages')}>View</Button>}
style={{ height: '100%' }}
>
{summary && (
<Flex gap={16} wrap="wrap" align="center">
<div>
<Text strong>Pages: </Text>
<Text>{summary.pages.published} published</Text>
<Text type="secondary"> / {summary.pages.total}</Text>
</div>
<div>
<Text strong>Templates: </Text>
<Text>{summary.emailTemplates.total}</Text>
</div>
{showInfluence && (
<div>
<Text strong>Rep Cache: </Text>
<Text>{summary.representatives.totalCached}</Text>
</div>
)}
{showMedia && (
<div>
<Text strong>Videos: </Text>
<Text>{summary.videos.published} published / {summary.videos.total}</Text>
</div>
)}
</Flex>
)}
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Active Campaigns"
value="--"
prefix={<SendOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Map Locations"
value="--"
prefix={<EnvironmentOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Emails Sent"
value="--"
prefix={<MailOutlined />}
/>
<Col xs={24} lg={12}>
<Card
title={<><IdcardOutlined style={{ marginRight: 6 }} />Users</>}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/users')}>Manage</Button>}
style={{ height: '100%' }}
>
{summary && (
<Flex gap={4} wrap="wrap">
{Object.entries(summary.users.byRole)
.filter(([, count]) => count > 0)
.map(([role, count]) => (
<Tag key={role} color={ROLE_COLORS[role] || 'default'}>
{ROLE_LABELS[role] || role}: {count}
</Tag>
))}
{summary.users.suspended > 0 && <Tag color="volcano">Suspended: {summary.users.suspended}</Tag>}
</Flex>
)}
</Card>
</Col>
</Row>
<Alert
message="Dashboard analytics coming soon"
description="Statistics and charts will be populated as additional modules are implemented."
type="info"
showIcon
/>
</>
{/* === System + Docker Section (SUPER_ADMIN only) === */}
{isSuperAdmin && (
<>
{/* === Time-Series Charts (Traffic + Latency) === */}
{timeSeries && screens.md && (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} lg={12}>
<Card
title={<><BarChartOutlined style={{ marginRight: 6 }} />Request Traffic (1h)</>}
size="small"
style={{ height: '100%' }}
>
<RequestTrafficChart data={timeSeries} height={200} />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={<><ApiOutlined style={{ marginRight: 6 }} />Latency Percentiles (1h)</>}
size="small"
style={{ height: '100%' }}
>
<LatencyBandsChart data={timeSeries} height={200} />
</Card>
</Col>
</Row>
)}
{/* System Info + Service Health */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{/* Hardware card with circular gauges + mini system chart */}
<Col xs={24} lg={8}>
<Card title={<><DesktopOutlined style={{ marginRight: 6 }} />System</>} size="small" style={{ height: '100%' }}>
{systemInfo ? (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
{/* Circular gauges */}
<SystemGauges systemInfo={systemInfo} />
{/* System load mini area chart */}
{timeSeries?.cpu_usage?.timestamps?.length ? (
<MiniSystemChart timeSeries={timeSeries} />
) : null}
{/* Text details */}
<div style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 8 }}>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 11 }}>Host</Text>
<Text style={{ fontSize: 11 }}>{systemInfo.hostname}</Text>
</Flex>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 11 }}>CPU</Text>
<Text style={{ fontSize: 11 }}>{systemInfo.cpu.cores} cores &middot; Load {systemInfo.cpu.loadAvg[0]}</Text>
</Flex>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 11 }}>Memory</Text>
<Text style={{ fontSize: 11 }}>{Math.round(systemInfo.memory.usedMB / 1024 * 10) / 10}/{Math.round(systemInfo.memory.totalMB / 1024)}GB</Text>
</Flex>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 11 }}>Uptime</Text>
<Text style={{ fontSize: 11 }}>{formatUptime(systemInfo.uptime)}</Text>
</Flex>
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 11 }}>API Process</Text>
<Text style={{ fontSize: 11 }}>{systemInfo.process.rssMB}MB RSS &middot; {formatUptime(systemInfo.process.uptimeSeconds)}</Text>
</Flex>
</div>
</Space>
) : <Spin size="small" />}
</Card>
</Col>
{/* Infrastructure: service health + docker containers unified */}
<Col xs={24} lg={16}>
<Card
title={<><ContainerOutlined style={{ marginRight: 6 }} />Infrastructure</>}
size="small"
style={{ height: '100%' }}
extra={
<Flex gap={8} align="center">
{health && (
<Tag color={health.status === 'healthy' ? 'green' : 'red'} style={{ marginRight: 0 }}>
{health.status === 'healthy' ? 'Core OK' : 'Degraded'}
</Tag>
)}
{containers && (
<Text type="secondary" style={{ fontSize: 12 }}>
{containers.filter(c => c.running).length}/{containers.length} running
</Text>
)}
</Flex>
}
>
{/* Core services row */}
<Flex gap={6} wrap="wrap" style={{ marginBottom: 10 }}>
<ServiceBadge name="API" online={!!health} icon={<RocketOutlined />} />
<ServiceBadge name="Database" online={health?.checks.database === 'ok'} icon={<DatabaseOutlined />} />
<ServiceBadge name="Redis" online={health?.checks.redis === 'ok'} icon={<HddOutlined />} />
{services && Object.entries(services).map(([key, svc]) => (
<ServiceBadge
key={key}
name={SERVICE_LABELS[key] || key}
online={svc.online}
icon={SERVICE_ICONS[key] || <CloudServerOutlined />}
/>
))}
</Flex>
{/* Container tiles */}
{containers ? (
<>
<Row gutter={[6, 6]}>
{containers.map(c => {
const tile = containerTileBg(c.running, c.status);
return (
<Col xs={12} sm={8} md={6} lg={6} xl={4} key={c.name}>
<ContainerPopover resource={containerResourceMap.get(c.name)}>
<Tooltip title={`${c.name} \u2014 ${c.status}`}>
<div style={{
padding: '5px 6px',
borderRadius: 6,
background: tile.bg,
border: `1px solid ${tile.border}`,
textAlign: 'center',
cursor: containerResourceMap.has(c.name) ? 'pointer' : 'default',
transition: 'transform 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.transform = 'scale(1.03)')}
onMouseLeave={e => (e.currentTarget.style.transform = 'scale(1)')}
>
{c.running
? <CheckCircleFilled style={{ color: '#52c41a', fontSize: 11, marginRight: 3 }} />
: c.status === 'not_found'
? <MinusCircleFilled style={{ color: '#999', fontSize: 11, marginRight: 3 }} />
: <CloseCircleFilled style={{ color: '#ff4d4f', fontSize: 11, marginRight: 3 }} />
}
<Text style={{ fontSize: 11 }}>{c.label}</Text>
</div>
</Tooltip>
</ContainerPopover>
</Col>
);
})}
</Row>
{/* Container Memory Bar Chart */}
{containerResources && containerResources.length > 0 && screens.md && (
<div style={{ marginTop: 10 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>Container Memory Usage</Text>
<ContainerMemoryChart containers={containerResources} height={Math.min(containerResources.filter(c => c.memoryMB > 0).length * 22 + 36, 280)} />
</div>
)}
</>
) : <Spin size="small" />}
</Card>
</Col>
</Row>
{/* API Performance with status code donut + route bar chart */}
{apiMetrics && (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} lg={12}>
<Card
title={<><ApiOutlined style={{ marginRight: 6 }} />API Performance</>}
size="small"
style={{ height: '100%' }}
extra={
<Flex gap={12}>
<Text type="secondary" style={{ fontSize: 12 }}>
{apiMetrics.requestRate.toFixed(1)} req/s
</Text>
<Text type={apiMetrics.errorRate > 5 ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
{apiMetrics.errorRate.toFixed(1)}% errors
</Text>
</Flex>
}
>
<Flex gap={12} align="flex-start">
<Space direction="vertical" size={4} style={{ flex: 1 }}>
<Flex gap={16} wrap="wrap">
<Statistic
title={<Text style={{ fontSize: 12 }}>Avg Latency</Text>}
value={apiMetrics.avgLatencyMs}
suffix="ms"
valueStyle={{ fontSize: 18, color: apiMetrics.avgLatencyMs > 500 ? '#ff4d4f' : apiMetrics.avgLatencyMs > 200 ? '#faad14' : '#52c41a' }}
/>
<Statistic
title={<Text style={{ fontSize: 12 }}>P95 Latency</Text>}
value={apiMetrics.p95LatencyMs}
suffix="ms"
valueStyle={{ fontSize: 18, color: apiMetrics.p95LatencyMs > 1000 ? '#ff4d4f' : apiMetrics.p95LatencyMs > 500 ? '#faad14' : '#52c41a' }}
/>
</Flex>
{/* Slow routes */}
{apiMetrics.slowRoutes.length > 0 && (
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 11 }}>Slowest Routes (P95)</Text>
{apiMetrics.slowRoutes.slice(0, 3).map((r, i) => (
<Flex key={i} justify="space-between" style={{ padding: '2px 0' }}>
<Text style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>
<Tag style={{ fontSize: 9, padding: '0 3px', marginRight: 3 }}>{r.method}</Tag>
{r.route}
</Text>
<Text type="danger" style={{ fontSize: 11 }}>{r.p95Ms.toFixed(0)}ms</Text>
</Flex>
))}
</div>
)}
</Space>
{/* Status code donut */}
{statusDonutData.length > 0 && screens.sm && (
<div style={{ width: 110, flexShrink: 0 }}>
<MiniDonutChart data={statusDonutData} height={110} innerRadius={26} outerRadius={46} />
</div>
)}
</Flex>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={<><BarChartOutlined style={{ marginRight: 6 }} />Top Routes</>}
size="small"
style={{ height: '100%' }}
>
{routeBarData.length > 0 ? (
<ResponsiveContainer width="100%" height={Math.max(routeBarData.length * 24 + 20, 120)}>
<BarChart data={routeBarData} layout="vertical" margin={{ top: 0, right: 16, left: 4, bottom: 0 }}>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={120} />
<RechartsTooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} />
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{routeBarData.map((_, i) => (
<Cell key={i} fill={['#1890ff', '#13c2c2', '#722ed1', '#52c41a', '#faad14', '#eb2f96', '#fa8c16', '#2f54eb'][i % 8]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<Text type="secondary">No route data yet</Text>
)}
</Card>
</Col>
</Row>
)}
</>
)}
</div>
);
}
// --- Mini System Load Chart (CPU + Memory over 1h) ---
import { AreaChart, Area, XAxis as AreaXAxis } from 'recharts';
function MiniSystemChart({ timeSeries }: { timeSeries: TimeSeriesResult }) {
const cpu = timeSeries.cpu_usage;
const mem = timeSeries.memory_usage;
if (!cpu?.timestamps?.length) return null;
const data = cpu.timestamps.map((ts, i) => ({
t: new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
cpu: Math.round(cpu.values[i] || 0),
mem: Math.round(mem?.values[i] || 0),
}));
return (
<ResponsiveContainer width="100%" height={70}>
<AreaChart data={data} margin={{ top: 2, right: 2, left: 2, bottom: 0 }}>
<AreaXAxis dataKey="t" hide />
<RechartsTooltip contentStyle={{ fontSize: 11, borderRadius: 6 }} formatter={(v, n) => [`${v}%`, n === 'cpu' ? 'CPU' : 'Memory']} />
<Area type="monotone" dataKey="cpu" stroke="#1890ff" fill="#1890ff" fillOpacity={0.2} strokeWidth={1.5} />
<Area type="monotone" dataKey="mem" stroke="#722ed1" fill="#722ed1" fillOpacity={0.15} strokeWidth={1.5} />
</AreaChart>
</ResponsiveContainer>
);
}
// --- Stat Card Component ---
function StatCard({ title, value, subtitle, icon, color, onClick }: {
title: string;
value?: number | null;
subtitle: string;
icon: React.ReactNode;
color: string;
onClick: () => void;
}) {
return (
<Card
hoverable
onClick={onClick}
size="small"
style={{ borderTop: `3px solid ${color}`, cursor: 'pointer', height: '100%' }}
styles={{ body: { padding: '12px 16px' } }}
>
<Statistic
title={<Text style={{ fontSize: 12 }}>{title}</Text>}
value={value ?? '--'}
prefix={icon}
valueStyle={{ color, fontSize: 22 }}
/>
<Text type="secondary" style={{ fontSize: 11 }}>{subtitle}</Text>
</Card>
);
}
// --- Service Badge Component (with pulse animation) ---
const SERVICE_LABELS: Record<string, string> = {
nocodb: 'NocoDB',
n8n: 'n8n',
gitea: 'Gitea',
mailhog: 'MailHog',
miniqr: 'Mini QR',
excalidraw: 'Excalidraw',
};
const SERVICE_ICONS: Record<string, React.ReactNode> = {
nocodb: <DatabaseOutlined />,
n8n: <BranchesOutlined />,
gitea: <GlobalOutlined />,
mailhog: <MailOutlined />,
miniqr: <QrcodeOutlined />,
excalidraw: <FileTextOutlined />,
};
function ServiceBadge({ name, online, icon }: {
name: string;
online?: boolean;
icon: React.ReactNode;
}) {
return (
<Tooltip title={online ? 'Online' : online === false ? 'Offline' : 'Unknown'}>
<div style={{ textAlign: 'center', padding: '4px 0' }} className={online ? 'svc-badge-online' : ''}>
<Badge
status={online ? 'success' : online === false ? 'error' : 'default'}
text={
<span style={{ fontSize: 11 }}>
{icon} {name}
</span>
}
/>
</div>
</Tooltip>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Table,
Button,
@ -19,6 +19,7 @@ import {
HistoryOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { useOutletContext } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
@ -28,6 +29,7 @@ import type {
EmailTemplatesListParams,
EmailTemplateCategory,
PaginationMeta,
AppOutletContext,
} from '@/types/api';
import TestEmailModal from '@/components/email-templates/TestEmailModal';
import VersionHistoryDrawer from '@/components/email-templates/VersionHistoryDrawer';
@ -35,7 +37,7 @@ import EmailTemplateEditor from '@/components/email-templates/EmailTemplateEdito
dayjs.extend(relativeTime);
const { Title, Text } = Typography;
const { Text } = Typography;
const categoryOptions: { value: EmailTemplateCategory | 'ALL'; label: string }[] = [
{ value: 'ALL', label: 'All Categories' },
@ -51,6 +53,7 @@ const activeOptions = [
];
export default function EmailTemplatesPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
@ -247,6 +250,39 @@ export default function EmailTemplatesPage() {
},
];
const headerActions = useMemo(() => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<Input
placeholder="Search by name or key..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 200 }}
allowClear
size="small"
/>
<Select
value={categoryFilter}
onChange={setCategoryFilter}
options={categoryOptions}
style={{ width: 150 }}
size="small"
/>
<Select
value={activeFilter}
onChange={setActiveFilter}
options={activeOptions}
style={{ width: 120 }}
size="small"
/>
</div>
), [search, categoryFilter, activeFilter]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setPageHeader({ title: 'Email Templates', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
// If editing a template, show the editor instead of the list
if (editingTemplateId) {
return (
@ -259,37 +295,7 @@ export default function EmailTemplatesPage() {
return (
<div style={{ padding: '24px' }}>
<div style={{ marginBottom: 24 }}>
<Title level={2}>Email Templates</Title>
<Text type="secondary">
Manage email templates for campaigns, shifts, and system notifications
</Text>
</div>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder="Search by name or key..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
style={{ width: 300 }}
allowClear
/>
<Select
value={categoryFilter}
onChange={setCategoryFilter}
options={categoryOptions}
style={{ width: 180 }}
/>
<Select
value={activeFilter}
onChange={setActiveFilter}
options={activeOptions}
style={{ width: 150 }}
/>
</div>
<Table
columns={columns}
dataSource={templates}

View File

@ -71,6 +71,7 @@ import {
SUPPORT_LEVEL_COLORS,
} from '@/types/api';
import AdminMapView from '@/components/map/AdminMapView';
import AreaImportWizard from '@/components/map/AreaImportWizard';
const { Text } = Typography;
const { TextArea } = Input;
@ -116,7 +117,7 @@ export default function LocationsPage() {
const [importModalOpen, setImportModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [geocodingMissing, setGeocodingMissing] = useState(false);
const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server'>('standard');
const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server' | 'area'>('standard');
const [importFilterType, setImportFilterType] = useState<'none' | 'cut' | 'mapArea' | 'city' | 'province'>('none');
const [importCutId, setImportCutId] = useState<string | undefined>();
const [importCity, setImportCity] = useState('');
@ -1356,7 +1357,7 @@ export default function LocationsPage() {
title="Import Locations"
open={importModalOpen}
destroyOnHidden
width={620}
width={importFormat === 'area' ? 700 : 620}
onCancel={() => {
setImportModalOpen(false);
setBulkImportResult(null);
@ -1394,8 +1395,21 @@ export default function LocationsPage() {
<Radio.Button value="standard">Standard CSV</Radio.Button>
<Radio.Button value="nar">NAR Upload</Radio.Button>
<Radio.Button value="server">NAR Server</Radio.Button>
<Radio.Button value="area">Area Import</Radio.Button>
</Radio.Group>
{importFormat === 'area' && (
<AreaImportWizard
cuts={cuts}
onComplete={() => {
setImportModalOpen(false);
setImportFormat('standard');
fetchLocations();
fetchStats();
}}
/>
)}
{importFormat === 'standard' && (
<>
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>

View File

@ -1,40 +1,116 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Form, Input, Button, Alert, Typography } from 'antd';
import { MailOutlined, LockOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Card, Form, Input, Button, Alert, Typography, Segmented, Modal, App } from 'antd';
import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { ADMIN_ROLES, type UserRole } from '@/types/api';
import { isAdmin } from '@/utils/roles';
import axios from 'axios';
const { Title, Text } = Typography;
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
function getPostLoginPath(role?: UserRole): string {
if (role && ADMIN_ROLES.includes(role)) return '/app';
return '/volunteer';
}
type AuthMode = 'signin' | 'register';
export default function LoginPage() {
const navigate = useNavigate();
const { login, isAuthenticated, isLoading, error, user } = useAuthStore();
const [searchParams] = useSearchParams();
const { message: messageApi } = App.useApp();
const {
login, register, isAuthenticated, isLoading, error, errorCode,
user, registrationMessage, clearError,
} = useAuthStore();
const { settings } = useSettingsStore();
const [form] = Form.useForm();
const [loginForm] = Form.useForm();
const [registerForm] = Form.useForm();
const [mode, setMode] = useState<AuthMode>('signin');
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false);
const [forgotEmail, setForgotEmail] = useState('');
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotSent, setForgotSent] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const redirectTo = searchParams.get('redirect');
const showRegister = settings?.enablePublicRegistration !== false;
useEffect(() => {
if (isAuthenticated && user) {
navigate(getPostLoginPath(user.role), { replace: true });
if (redirectTo) {
navigate(redirectTo, { replace: true });
} else {
navigate(isAdmin(user) ? '/app' : '/volunteer', { replace: true });
}
}
}, [isAuthenticated, user, navigate]);
}, [isAuthenticated, user, navigate, redirectTo]);
const handleSubmit = async (values: { email: string; password: string }) => {
// Clear errors when switching modes
useEffect(() => {
clearError();
}, [mode]); // eslint-disable-line react-hooks/exhaustive-deps
const handleLogin = async (values: { email: string; password: string }) => {
try {
await login(values.email, values.password);
const updatedUser = useAuthStore.getState().user;
navigate(getPostLoginPath(updatedUser?.role), { replace: true });
if (redirectTo) {
navigate(redirectTo, { replace: true });
} else {
navigate(isAdmin(updatedUser) ? '/app' : '/volunteer', { replace: true });
}
} 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) {
// Don't navigate — show the verification message
return;
}
const updatedUser = useAuthStore.getState().user;
if (redirectTo) {
navigate(redirectTo, { replace: true });
} else {
navigate(isAdmin(updatedUser) ? '/app' : '/volunteer', { replace: true });
}
} catch {
// Error is set in store
}
};
const handleForgotPassword = async () => {
if (!forgotEmail) return;
setForgotLoading(true);
try {
await axios.post(`${API_URL}/api/auth/forgot-password`, { email: forgotEmail });
setForgotSent(true);
} catch {
// Always show success for security (no user enumeration)
setForgotSent(true);
} finally {
setForgotLoading(false);
}
};
const handleResendVerification = async () => {
const email = loginForm.getFieldValue('email');
if (!email) {
messageApi.warning('Please enter your email address first');
return;
}
setResendLoading(true);
try {
await axios.post(`${API_URL}/api/auth/resend-verification`, { email });
messageApi.success('If your email is registered, a new verification link has been sent.');
} catch {
messageApi.error('Failed to resend. Please try again later.');
} finally {
setResendLoading(false);
}
};
return (
<div
style={{
@ -54,46 +130,212 @@ export default function LoginPage() {
<Text type="secondary">{settings?.loginSubtitle ?? 'Admin'}</Text>
</div>
{showRegister && (
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 20 }}>
<Segmented
options={[
{ label: 'Sign In', value: 'signin' },
{ label: 'Register', value: 'register' },
]}
value={mode}
onChange={(val) => setMode(val as AuthMode)}
/>
</div>
)}
{/* Registration success — verification required */}
{registrationMessage && (
<Alert
message="Check Your Email"
description={registrationMessage}
type="success"
showIcon
icon={<CheckCircleOutlined />}
closable
onClose={() => clearError()}
style={{ marginBottom: 16 }}
/>
)}
{/* Error display with contextual actions */}
{error && (
<Alert
message={error}
type="error"
showIcon
closable
onClose={() => clearError()}
description={
errorCode === 'EMAIL_NOT_VERIFIED' ? (
<Button
type="link"
size="small"
loading={resendLoading}
onClick={handleResendVerification}
style={{ padding: 0 }}
>
Resend verification email
</Button>
) : errorCode === 'ACCOUNT_PENDING' ? (
<Text type="secondary" style={{ fontSize: 12 }}>
An admin will review your account shortly.
</Text>
) : undefined
}
style={{ marginBottom: 16 }}
/>
)}
<Form form={form} onFinish={handleSubmit} layout="vertical" size="large">
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" autoFocus />
</Form.Item>
{mode === 'signin' ? (
<Form form={loginForm} onFinish={handleLogin} layout="vertical" size="large">
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" autoFocus />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Please enter your password' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Please enter your password' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
</Form.Item>
<Form.Item>
<Form.Item style={{ marginBottom: 8 }}>
<Button
type="primary"
htmlType="submit"
loading={isLoading}
block
>
Sign In
</Button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<Button
type="link"
size="small"
onClick={() => {
setForgotEmail(loginForm.getFieldValue('email') || '');
setForgotSent(false);
setForgotPasswordOpen(true);
}}
>
Forgot password?
</Button>
</div>
</Form>
) : (
<Form form={registerForm} onFinish={handleRegister} layout="vertical" size="large">
<Form.Item
name="name"
rules={[{ required: true, message: 'Please enter your name' }]}
>
<Input prefix={<UserOutlined />} placeholder="Full Name" autoFocus />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: 'Please enter a password' },
{ min: 12, message: 'Password must be at least 12 characters' },
{
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Must contain uppercase, lowercase, and a digit',
},
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password (12+ chars)" />
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: 'Please confirm your password' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('Passwords do not match'));
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Confirm Password" />
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={isLoading}
block
>
Create Account
</Button>
</Form.Item>
</Form>
)}
</Card>
{/* Forgot Password Modal */}
<Modal
title="Reset Password"
open={forgotPasswordOpen}
onCancel={() => setForgotPasswordOpen(false)}
footer={null}
width={400}
>
{forgotSent ? (
<Alert
message="Check Your Email"
description="If an account exists with that email, a password reset link has been sent."
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
) : (
<>
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
Enter your email address and we'll send you a link to reset your password.
</Text>
<Input
prefix={<MailOutlined />}
placeholder="Email address"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
onPressEnter={handleForgotPassword}
style={{ marginBottom: 16 }}
/>
<Button
type="primary"
htmlType="submit"
loading={isLoading}
block
loading={forgotLoading}
onClick={handleForgotPassword}
disabled={!forgotEmail}
>
Sign In
Send Reset Link
</Button>
</Form.Item>
</Form>
</Card>
</>
)}
</Modal>
</div>
);
}

View File

@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { Card, Typography, Form, Input, Button, Result } from 'antd';
import { LockOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
const { Title, Text } = Typography;
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
type ResetState = 'form' | 'loading' | 'success' | 'error';
export default function ResetPasswordPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const [state, setState] = useState<ResetState>(token ? 'form' : 'error');
const [errorMessage, setErrorMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
useEffect(() => {
if (!token) {
setErrorMessage('No reset token provided. Please use the link from your email.');
}
}, [token]);
const handleSubmit = async (values: { password: string }) => {
if (!token) return;
setSubmitting(true);
try {
await axios.post(`${API_URL}/api/auth/reset-password`, {
token,
password: values.password,
});
setState('success');
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to reset password';
setErrorMessage(msg);
setState('error');
} finally {
setSubmitting(false);
}
};
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
padding: 16,
background: '#120b1a',
}}
>
<Card style={{ width: '100%', maxWidth: 420 }}>
{state === 'form' && (
<>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 4 }}>Reset Password</Title>
<Text type="secondary">Enter your new password below</Text>
</div>
<Form form={form} onFinish={handleSubmit} layout="vertical" size="large">
<Form.Item
name="password"
rules={[
{ required: true, message: 'Please enter a new password' },
{ min: 12, message: 'Password must be at least 12 characters' },
{
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Must contain uppercase, lowercase, and a digit',
},
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="New Password (12+ chars)" autoFocus />
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: 'Please confirm your password' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('Passwords do not match'));
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Confirm Password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting} block>
Reset Password
</Button>
</Form.Item>
</Form>
</>
)}
{state === 'success' && (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="Password Reset!"
subTitle="Your password has been updated. You can now log in with your new password."
extra={
<Link to="/login">
<Button type="primary" size="large">Go to Login</Button>
</Link>
}
/>
)}
{state === 'error' && (
<Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
title="Reset Failed"
subTitle={errorMessage || 'The reset link is invalid or has expired.'}
extra={
<Link to="/login">
<Button type="primary">Back to Login</Button>
</Link>
}
/>
)}
</Card>
</div>
);
}

View File

@ -360,6 +360,44 @@ export default function SettingsPage() {
</div>
),
},
{
key: 'registration',
label: 'Registration',
children: (
<div style={{ maxWidth: 600 }}>
<Alert
type="info"
message="These settings control the self-registration flow for new users."
showIcon
style={{ marginBottom: 24 }}
/>
<Form.Item
label="Enable Public Registration"
name="enablePublicRegistration"
valuePropName="checked"
extra="Allow new users to register on the login page"
>
<Switch />
</Form.Item>
<Form.Item
label="Require Email Verification"
name="enableEmailVerification"
valuePropName="checked"
extra="New registrations must verify their email before accessing the system. Requires SMTP to be configured."
>
<Switch />
</Form.Item>
<Form.Item
label="Auto-Approve Verified Users"
name="autoApproveVerifiedUsers"
valuePropName="checked"
extra="When enabled, verified users are immediately activated. When disabled, an admin must manually approve each verified user."
>
<Switch />
</Form.Item>
</div>
),
},
{
key: 'features',
label: 'Feature Toggles',

View File

@ -15,16 +15,21 @@ import {
Row,
Col,
DatePicker,
Modal,
Badge,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
CheckOutlined,
CloseOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { getUserRoles } from '@/utils/roles';
import type {
User,
UserRole,
@ -50,6 +55,8 @@ const statusColors: Record<UserStatus, string> = {
INACTIVE: 'default',
SUSPENDED: 'red',
EXPIRED: 'orange',
PENDING_VERIFICATION: 'purple',
PENDING_APPROVAL: 'gold',
};
const roleOptions: { value: UserRole; label: string }[] = [
@ -65,6 +72,8 @@ const statusOptions: { value: UserStatus; label: string }[] = [
{ value: 'INACTIVE', label: 'Inactive' },
{ value: 'SUSPENDED', label: 'Suspended' },
{ value: 'EXPIRED', label: 'Expired' },
{ value: 'PENDING_VERIFICATION', label: 'Pending Verification' },
{ value: 'PENDING_APPROVAL', label: 'Pending Approval' },
];
export default function UsersPage() {
@ -81,6 +90,9 @@ export default function UsersPage() {
const [editingUser, setEditingUser] = useState<User | null>(null);
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectingUser, setRejectingUser] = useState<User | null>(null);
const [rejectReason, setRejectReason] = useState('');
const getActiveDrawerWidth = () => {
if (createDrawerOpen) return 520;
@ -185,13 +197,41 @@ export default function UsersPage() {
}
};
const handleApprove = async (user: User) => {
try {
await api.post(`/users/${user.id}/approve`);
message.success(`${user.name || user.email} approved`);
fetchUsers();
} catch {
message.error('Failed to approve user');
}
};
const handleReject = async () => {
if (!rejectingUser) return;
try {
await api.post(`/users/${rejectingUser.id}/reject`, {
reason: rejectReason || undefined,
});
message.success(`${rejectingUser.name || rejectingUser.email} rejected`);
setRejectModalOpen(false);
setRejectingUser(null);
setRejectReason('');
fetchUsers();
} catch {
message.error('Failed to reject user');
}
};
const openEdit = (user: User) => {
setEditingUser(user);
const roles = getUserRoles(user);
editForm.setFieldsValue({
email: user.email,
name: user.name,
phone: user.phone,
role: user.role,
roles,
status: user.status,
expireDays: user.expireDays,
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
@ -199,6 +239,9 @@ export default function UsersPage() {
setEditDrawerOpen(true);
};
// Count pending approval users
const pendingCount = users.filter(u => u.status === 'PENDING_APPROVAL').length;
const columns: ColumnsType<User> = [
{
title: 'Name',
@ -212,19 +255,25 @@ export default function UsersPage() {
key: 'email',
},
{
title: 'Role',
dataIndex: 'role',
key: 'role',
render: (role: UserRole) => (
<Tag color={roleColors[role]}>{role.replace('_', ' ')}</Tag>
),
title: 'Roles',
key: 'roles',
render: (_: unknown, record: User) => {
const roles = getUserRoles(record);
return (
<Space size={2} wrap>
{roles.map((r) => (
<Tag key={r} color={roleColors[r]}>{r.replace(/_/g, ' ')}</Tag>
))}
</Space>
);
},
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: UserStatus) => (
<Tag color={statusColors[status]}>{status}</Tag>
<Tag color={statusColors[status]}>{status.replace(/_/g, ' ')}</Tag>
),
},
{
@ -247,6 +296,28 @@ export default function UsersPage() {
key: 'actions',
render: (_: unknown, record: User) => (
<Space>
{record.status === 'PENDING_APPROVAL' && (
<>
<Popconfirm
title="Approve this user?"
description="They will be able to log in."
onConfirm={() => handleApprove(record)}
>
<Button type="link" size="small" icon={<CheckOutlined />} style={{ color: '#52c41a' }} />
</Popconfirm>
<Button
type="link"
size="small"
danger
icon={<CloseOutlined />}
onClick={() => {
setRejectingUser(record);
setRejectReason('');
setRejectModalOpen(true);
}}
/>
</>
)}
<Button
type="link"
size="small"
@ -278,9 +349,14 @@ export default function UsersPage() {
>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Title level={4} style={{ margin: 0 }}>
Users
</Title>
<Space>
<Title level={4} style={{ margin: 0 }}>
Users
</Title>
{pendingCount > 0 && (
<Badge count={pendingCount} style={{ backgroundColor: '#faad14' }} />
)}
</Space>
</Col>
<Col>
<Button
@ -395,9 +471,16 @@ export default function UsersPage() {
<Form.Item name="phone" label="Phone">
<Input />
</Form.Item>
<Form.Item name="roles" label="Roles" initialValue={['USER']}>
<Select
mode="multiple"
options={roleOptions}
placeholder="Select roles"
/>
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="role" label="Role" initialValue="USER">
<Form.Item name="role" label="Primary Role" initialValue="USER">
<Select options={roleOptions} />
</Form.Item>
</Col>
@ -477,9 +560,16 @@ export default function UsersPage() {
<Form.Item name="phone" label="Phone">
<Input />
</Form.Item>
<Form.Item name="roles" label="Roles">
<Select
mode="multiple"
options={roleOptions}
placeholder="Select roles"
/>
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="role" label="Role">
<Form.Item name="role" label="Primary Role">
<Select options={roleOptions} />
</Form.Item>
</Col>
@ -505,6 +595,27 @@ export default function UsersPage() {
)}
</Form>
</Drawer>
{/* Reject Modal */}
<Modal
title={`Reject ${rejectingUser?.name || rejectingUser?.email || 'User'}?`}
open={rejectModalOpen}
onCancel={() => {
setRejectModalOpen(false);
setRejectingUser(null);
setRejectReason('');
}}
onOk={handleReject}
okText="Reject"
okButtonProps={{ danger: true }}
>
<Input.TextArea
placeholder="Optional reason for rejection"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}
/>
</Modal>
</>
);
}

View File

@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { Card, Typography, Spin, Result, Button, Form, Input, App } from 'antd';
import { MailOutlined, CheckCircleOutlined, CloseCircleOutlined, ClockCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
const { Text } = Typography;
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
type VerifyState = 'loading' | 'success-approved' | 'success-pending' | 'error' | 'expired';
export default function VerifyEmailPage() {
const [searchParams] = useSearchParams();
const { message } = App.useApp();
const token = searchParams.get('token');
const [state, setState] = useState<VerifyState>(token ? 'loading' : 'error');
const [errorMessage, setErrorMessage] = useState('');
const [resendLoading, setResendLoading] = useState(false);
const [resendSent, setResendSent] = useState(false);
useEffect(() => {
if (!token) {
setErrorMessage('No verification token provided');
setState('error');
return;
}
axios.post(`${API_URL}/api/auth/verify-email`, { token })
.then(({ data }) => {
setState(data.approved ? 'success-approved' : 'success-pending');
})
.catch((err) => {
const msg = err.response?.data?.error?.message || 'Verification failed';
setErrorMessage(msg);
setState(msg.includes('expired') ? 'expired' : 'error');
});
}, [token]);
const handleResend = async (values: { email: string }) => {
setResendLoading(true);
try {
await axios.post(`${API_URL}/api/auth/resend-verification`, { email: values.email });
setResendSent(true);
message.success('If your email is registered, a new verification link has been sent.');
} catch {
message.error('Failed to resend. Please try again later.');
} finally {
setResendLoading(false);
}
};
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
padding: 16,
background: '#120b1a',
}}
>
<Card style={{ width: '100%', maxWidth: 480 }}>
{state === 'loading' && (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
<Text style={{ display: 'block', marginTop: 16 }}>Verifying your email...</Text>
</div>
)}
{state === 'success-approved' && (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="Email Verified!"
subTitle="Your account is active. You can now log in."
extra={
<Link to="/login">
<Button type="primary" size="large">Go to Login</Button>
</Link>
}
/>
)}
{state === 'success-pending' && (
<Result
icon={<ClockCircleOutlined style={{ color: '#faad14' }} />}
title="Email Verified!"
subTitle="Your account is now pending admin approval. You'll receive an email when your account has been approved."
extra={
<Link to="/login">
<Button type="default">Back to Login</Button>
</Link>
}
/>
)}
{(state === 'error' || state === 'expired') && (
<Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
title={state === 'expired' ? 'Link Expired' : 'Verification Failed'}
subTitle={errorMessage || 'The verification link is invalid or has expired.'}
extra={
<>
{!resendSent ? (
<div style={{ maxWidth: 320, margin: '0 auto' }}>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Enter your email to receive a new verification link:
</Text>
<Form onFinish={handleResend} layout="inline" style={{ justifyContent: 'center' }}>
<Form.Item
name="email"
rules={[{ required: true, type: 'email', message: 'Enter email' }]}
style={{ flex: 1 }}
>
<Input prefix={<MailOutlined />} placeholder="Email" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={resendLoading}>Resend</Button>
</Form.Item>
</Form>
</div>
) : (
<Text type="success">Verification email sent! Check your inbox.</Text>
)}
<div style={{ marginTop: 16 }}>
<Link to="/login">
<Button type="link">Back to Login</Button>
</Link>
</div>
</>
}
/>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,419 @@
import { useState, useEffect, useCallback } from 'react';
import {
Card,
Table,
Button,
Space,
Tag,
Input,
Select,
Drawer,
Typography,
message,
Statistic,
Row,
Col,
Modal,
} from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
SearchOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type {
Campaign,
CampaignModerationStatus,
CampaignModerationStats,
CampaignsListResponse,
GovernmentLevel,
} from '@/types/api';
import {
CAMPAIGN_MODERATION_STATUS_COLORS,
CAMPAIGN_MODERATION_STATUS_LABELS,
GOVERNMENT_LEVEL_LABELS,
GOVERNMENT_LEVEL_COLORS,
} from '@/types/api';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
export default function CampaignModerationPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<CampaignModerationStatus | undefined>(undefined);
const [stats, setStats] = useState<CampaignModerationStats | null>(null);
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [actionModalOpen, setActionModalOpen] = useState(false);
const [actionType, setActionType] = useState<'reject' | 'request_changes' | null>(null);
const [actionReason, setActionReason] = useState('');
const [actionNotes, setActionNotes] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const fetchQueue = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: pageSize };
if (search) params.search = search;
if (statusFilter) params.moderationStatus = statusFilter;
const { data } = await api.get<CampaignsListResponse>('/campaigns/moderation/queue', { params });
setCampaigns(data.campaigns);
setTotal(data.pagination.total);
} catch {
message.error('Failed to load moderation queue');
} finally {
setLoading(false);
}
}, [page, pageSize, search, statusFilter]);
const fetchStats = useCallback(async () => {
try {
const { data } = await api.get<CampaignModerationStats>('/campaigns/moderation/stats');
setStats(data);
} catch {
// non-critical
}
}, []);
useEffect(() => {
fetchQueue();
fetchStats();
}, [fetchQueue, fetchStats]);
const handleModerate = async (campaignId: string, action: 'approve' | 'reject' | 'request_changes', reason?: string, notes?: string) => {
setActionLoading(true);
try {
await api.patch(`/campaigns/moderation/${campaignId}`, { action, reason, notes });
message.success(
action === 'approve' ? 'Campaign approved and activated' :
action === 'reject' ? 'Campaign rejected' : 'Changes requested'
);
setDrawerOpen(false);
setActionModalOpen(false);
setSelectedCampaign(null);
setActionReason('');
setActionNotes('');
fetchQueue();
fetchStats();
} catch {
message.error('Failed to moderate campaign');
} finally {
setActionLoading(false);
}
};
const openActionModal = (type: 'reject' | 'request_changes') => {
setActionType(type);
setActionReason('');
setActionModalOpen(true);
};
const columns: ColumnsType<Campaign> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Button type="link" style={{ padding: 0, textAlign: 'left', whiteSpace: 'normal' }} onClick={() => { setSelectedCampaign(record); setDrawerOpen(true); }}>
{title}
</Button>
),
},
{
title: 'Creator',
key: 'creator',
render: (_, record) => (
<div>
<Text style={{ display: 'block' }}>{record.createdByUserName || 'Unknown'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{record.createdByUserEmail}</Text>
</div>
),
},
{
title: 'Status',
dataIndex: 'moderationStatus',
key: 'moderationStatus',
render: (status: CampaignModerationStatus) => (
<Tag color={CAMPAIGN_MODERATION_STATUS_COLORS[status]}>
{CAMPAIGN_MODERATION_STATUS_LABELS[status]}
</Tag>
),
},
{
title: 'Levels',
dataIndex: 'targetGovernmentLevels',
key: 'levels',
render: (levels: GovernmentLevel[]) => (
<Space size={4} wrap>
{levels.map(l => <Tag key={l} color={GOVERNMENT_LEVEL_COLORS[l]} style={{ fontSize: 11 }}>{GOVERNMENT_LEVEL_LABELS[l]}</Tag>)}
</Space>
),
},
{
title: 'Submitted',
dataIndex: 'createdAt',
key: 'createdAt',
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
render: (_, record) => (
<Space size={4}>
{record.moderationStatus === 'PENDING_REVIEW' && (
<>
<Button
type="primary"
size="small"
icon={<CheckCircleOutlined />}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
onClick={() => handleModerate(record.id, 'approve')}
>
Approve
</Button>
<Button
size="small"
danger
icon={<CloseCircleOutlined />}
onClick={() => { setSelectedCampaign(record); openActionModal('reject'); }}
>
Reject
</Button>
<Button
size="small"
icon={<ExclamationCircleOutlined />}
onClick={() => { setSelectedCampaign(record); openActionModal('request_changes'); }}
>
Request Changes
</Button>
</>
)}
{record.moderationStatus !== 'PENDING_REVIEW' && (
<Button
size="small"
onClick={() => { setSelectedCampaign(record); setDrawerOpen(true); }}
>
View
</Button>
)}
</Space>
),
},
];
return (
<div>
{/* Stats Row */}
{stats && (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Total" value={stats.total} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Pending Review" value={stats.pending} valueStyle={{ color: '#fa8c16' }} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Approved" value={stats.approved} valueStyle={{ color: '#52c41a' }} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Rejected" value={stats.rejected} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
</Row>
)}
{/* Filters */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Input
placeholder="Search by title, name, or email"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
onPressEnter={fetchQueue}
style={{ width: 280 }}
allowClear
/>
<Select
placeholder="Moderation Status"
value={statusFilter}
onChange={(v) => { setStatusFilter(v); setPage(1); }}
allowClear
style={{ width: 180 }}
options={[
{ label: 'Pending Review', value: 'PENDING_REVIEW' },
{ label: 'Approved', value: 'APPROVED' },
{ label: 'Rejected', value: 'REJECTED' },
{ label: 'Changes Requested', value: 'CHANGES_REQUESTED' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={() => { fetchQueue(); fetchStats(); }}>
Refresh
</Button>
</Space>
</Card>
{/* Table */}
<Table
columns={columns}
dataSource={campaigns}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize,
onChange: setPage,
showSizeChanger: false,
showTotal: (t) => `${t} campaigns`,
}}
scroll={{ x: 800 }}
/>
{/* Detail Drawer */}
<Drawer
title="Campaign Details"
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setSelectedCampaign(null); }}
width={600}
>
{selectedCampaign && (
<div>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ fontSize: 18 }}>{selectedCampaign.title}</Text>
{selectedCampaign.moderationStatus && (
<Tag color={CAMPAIGN_MODERATION_STATUS_COLORS[selectedCampaign.moderationStatus]} style={{ marginLeft: 8 }}>
{CAMPAIGN_MODERATION_STATUS_LABELS[selectedCampaign.moderationStatus]}
</Tag>
)}
</div>
{selectedCampaign.description && (
<div style={{ marginBottom: 16 }}>
<Text type="secondary">Description</Text>
<Paragraph>{selectedCampaign.description}</Paragraph>
</div>
)}
<div style={{ marginBottom: 16 }}>
<Text type="secondary">Government Levels</Text>
<div style={{ marginTop: 4 }}>
{selectedCampaign.targetGovernmentLevels.map(l => (
<Tag key={l} color={GOVERNMENT_LEVEL_COLORS[l]}>{GOVERNMENT_LEVEL_LABELS[l]}</Tag>
))}
</div>
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">Creator</Text>
<div>{selectedCampaign.createdByUserName || 'Unknown'} ({selectedCampaign.createdByUserEmail})</div>
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">Submitted</Text>
<div>{dayjs(selectedCampaign.createdAt).format('MMM D, YYYY h:mm A')}</div>
</div>
<Card size="small" title="Email Template" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>
<Text strong>Subject: </Text>
<Text>{selectedCampaign.emailSubject}</Text>
</div>
<div style={{ whiteSpace: 'pre-wrap', background: '#f5f5f5', padding: 12, borderRadius: 6, maxHeight: 300, overflow: 'auto' }}>
{selectedCampaign.emailBody}
</div>
</Card>
{selectedCampaign.callToAction && (
<div style={{ marginBottom: 16 }}>
<Text type="secondary">Call to Action</Text>
<Paragraph>{selectedCampaign.callToAction}</Paragraph>
</div>
)}
{selectedCampaign.rejectionReason && (
<Card size="small" title="Rejection/Change Reason" style={{ marginBottom: 16, borderColor: '#ff4d4f' }}>
<Text>{selectedCampaign.rejectionReason}</Text>
</Card>
)}
{selectedCampaign.moderationNotes && (
<div style={{ marginBottom: 16 }}>
<Text type="secondary">Moderation Notes</Text>
<Paragraph>{selectedCampaign.moderationNotes}</Paragraph>
</div>
)}
{selectedCampaign.moderationStatus === 'PENDING_REVIEW' && (
<Space style={{ marginTop: 16 }}>
<Button
type="primary"
icon={<CheckCircleOutlined />}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
onClick={() => handleModerate(selectedCampaign.id, 'approve')}
loading={actionLoading}
>
Approve
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
onClick={() => openActionModal('reject')}
>
Reject
</Button>
<Button
icon={<ExclamationCircleOutlined />}
onClick={() => openActionModal('request_changes')}
>
Request Changes
</Button>
</Space>
)}
</div>
)}
</Drawer>
{/* Action Modal (for rejection reason / change request) */}
<Modal
title={actionType === 'reject' ? 'Reject Campaign' : 'Request Changes'}
open={actionModalOpen}
onCancel={() => { setActionModalOpen(false); setActionType(null); }}
onOk={() => {
if (selectedCampaign && actionType) {
handleModerate(selectedCampaign.id, actionType, actionReason, actionNotes);
}
}}
okText={actionType === 'reject' ? 'Reject' : 'Send Request'}
okButtonProps={{ danger: actionType === 'reject', loading: actionLoading }}
>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ display: 'block', marginBottom: 4 }}>Reason</Text>
<TextArea
rows={3}
value={actionReason}
onChange={(e) => setActionReason(e.target.value)}
placeholder={actionType === 'reject' ? 'Why is this campaign being rejected?' : 'What changes are needed?'}
/>
</div>
<div>
<Text strong style={{ display: 'block', marginBottom: 4 }}>Internal Notes (optional)</Text>
<TextArea
rows={2}
value={actionNotes}
onChange={(e) => setActionNotes(e.target.value)}
placeholder="Notes for other admins"
/>
</div>
</Modal>
</div>
);
}

View File

@ -0,0 +1,582 @@
import { useState, useEffect, useCallback } from 'react';
import {
Card,
Table,
Button,
Space,
Tag,
Input,
Select,
DatePicker,
Drawer,
Typography,
Popconfirm,
message,
Statistic,
Row,
Col,
Tabs,
Form,
Descriptions,
} from 'antd';
import {
CheckCircleOutlined,
EyeInvisibleOutlined,
EyeOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined,
WarningOutlined,
PlusOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { mediaApi } from '@/lib/media-api';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
interface CommentRecord {
id: number;
mediaId: number;
videoTitle: string;
content: string;
createdAt: string;
safetyStatus: string | null;
safetyCategories: any;
safetyReasoning: string | null;
isHidden: boolean | null;
hiddenAt: string | null;
hiddenReason: string | null;
moderationNotes: string | null;
user: { id: string; name: string; email: string } | null;
moderation: {
id: number;
status: string;
moderatedAt: string | null;
reason: string | null;
moderator: { id: string; name: string | null } | null;
} | null;
}
interface CommentStats {
total: number;
pending: number;
flagged: number;
hidden: number;
safe: number;
}
interface WordFilter {
id: number;
word: string;
level: string;
createdAt: string;
creator: { id: string; name: string | null } | null;
}
export default function CommentModerationPage() {
// Comments state
const [comments, setComments] = useState<CommentRecord[]>([]);
const [stats, setStats] = useState<CommentStats>({ total: 0, pending: 0, flagged: 0, hidden: 0, safe: 0 });
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [pageSize] = useState(20);
// Filters
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [searchText, setSearchText] = useState('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
// Detail drawer
const [selectedComment, setSelectedComment] = useState<CommentRecord | null>(null);
const [notesInput, setNotesInput] = useState('');
const [savingNotes, setSavingNotes] = useState(false);
// Word filters
const [wordFilters, setWordFilters] = useState<WordFilter[]>([]);
const [wordFiltersLoading, setWordFiltersLoading] = useState(false);
const [newWord, setNewWord] = useState('');
const [newWordLevel, setNewWordLevel] = useState<string>('medium');
// Fetch stats
const fetchStats = useCallback(async () => {
try {
const { data } = await mediaApi.get('/media/admin/comments/stats');
setStats(data);
} catch {
// silent
}
}, []);
// Fetch comments
const fetchComments = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, limit: pageSize };
if (statusFilter) params.status = statusFilter;
if (searchText) params.search = searchText;
if (dateRange) {
params.dateFrom = dateRange[0].startOf('day').toISOString();
params.dateTo = dateRange[1].endOf('day').toISOString();
}
const { data } = await mediaApi.get('/media/admin/comments', { params });
setComments(data.comments);
setTotal(data.total);
} catch {
message.error('Failed to load comments');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, searchText, dateRange]);
// Fetch word filters
const fetchWordFilters = useCallback(async () => {
setWordFiltersLoading(true);
try {
const { data } = await mediaApi.get('/media/admin/word-filters');
setWordFilters(data.words);
} catch {
message.error('Failed to load word filters');
} finally {
setWordFiltersLoading(false);
}
}, []);
useEffect(() => {
fetchComments();
fetchStats();
}, [fetchComments, fetchStats]);
// Actions
const handleApprove = async (id: number) => {
try {
await mediaApi.patch(`/media/admin/comments/${id}/approve`);
message.success('Comment approved');
fetchComments();
fetchStats();
} catch {
message.error('Failed to approve comment');
}
};
const handleHide = async (id: number, reason: string = 'manual') => {
try {
await mediaApi.patch(`/media/admin/comments/${id}/hide`, { reason });
message.success('Comment hidden');
fetchComments();
fetchStats();
} catch {
message.error('Failed to hide comment');
}
};
const handleUnhide = async (id: number) => {
try {
await mediaApi.patch(`/media/admin/comments/${id}/unhide`);
message.success('Comment unhidden');
fetchComments();
fetchStats();
} catch {
message.error('Failed to unhide comment');
}
};
const handleDelete = async (id: number) => {
try {
await mediaApi.delete(`/media/admin/comments/${id}`);
message.success('Comment deleted');
fetchComments();
fetchStats();
if (selectedComment?.id === id) setSelectedComment(null);
} catch {
message.error('Failed to delete comment');
}
};
const handleSaveNotes = async () => {
if (!selectedComment) return;
setSavingNotes(true);
try {
await mediaApi.put(`/media/admin/comments/${selectedComment.id}/notes`, { notes: notesInput });
message.success('Notes saved');
setSelectedComment({ ...selectedComment, moderationNotes: notesInput });
fetchComments();
} catch {
message.error('Failed to save notes');
} finally {
setSavingNotes(false);
}
};
// Word filter actions
const handleAddWord = async () => {
if (!newWord.trim()) return;
try {
await mediaApi.post('/media/admin/word-filters', { word: newWord.trim(), level: newWordLevel });
message.success('Word added to filter');
setNewWord('');
fetchWordFilters();
} catch (err: any) {
message.error(err.response?.data?.message || 'Failed to add word');
}
};
const handleDeleteWord = async (id: number) => {
try {
await mediaApi.delete(`/media/admin/word-filters/${id}`);
message.success('Word removed from filter');
fetchWordFilters();
} catch {
message.error('Failed to remove word');
}
};
// Status tag renderer
const renderStatusTag = (record: CommentRecord) => {
if (record.isHidden) {
return <Tag color="error" icon={<EyeInvisibleOutlined />}>Hidden</Tag>;
}
switch (record.safetyStatus) {
case 'safe':
return <Tag color="success" icon={<CheckCircleOutlined />}>Safe</Tag>;
case 'flagged':
return <Tag color="warning" icon={<WarningOutlined />}>Flagged</Tag>;
case 'pending':
default:
return <Tag color="default">Pending</Tag>;
}
};
// Table columns
const columns: ColumnsType<CommentRecord> = [
{
title: 'Content',
dataIndex: 'content',
width: 300,
ellipsis: true,
render: (text: string, record) => (
<Button type="link" onClick={() => { setSelectedComment(record); setNotesInput(record.moderationNotes || ''); }} style={{ padding: 0, textAlign: 'left', whiteSpace: 'normal', height: 'auto' }}>
<Text ellipsis style={{ maxWidth: 280 }}>{text}</Text>
</Button>
),
},
{
title: 'Video',
dataIndex: 'videoTitle',
width: 150,
ellipsis: true,
},
{
title: 'User',
dataIndex: 'user',
width: 120,
render: (user: CommentRecord['user']) => user?.name || <Text type="secondary">Anonymous</Text>,
},
{
title: 'Status',
width: 110,
render: (_: any, record) => renderStatusTag(record),
},
{
title: 'Date',
dataIndex: 'createdAt',
width: 140,
render: (date: string) => dayjs(date).format('MMM D, HH:mm'),
},
{
title: 'Actions',
width: 180,
render: (_: any, record) => (
<Space size={4}>
{record.safetyStatus !== 'safe' && !record.isHidden && (
<Button size="small" type="primary" icon={<CheckCircleOutlined />} onClick={() => handleApprove(record.id)}>
Approve
</Button>
)}
{!record.isHidden ? (
<Button size="small" danger icon={<EyeInvisibleOutlined />} onClick={() => handleHide(record.id)}>
Hide
</Button>
) : (
<Button size="small" icon={<EyeOutlined />} onClick={() => handleUnhide(record.id)}>
Unhide
</Button>
)}
<Popconfirm title="Permanently delete this comment?" onConfirm={() => handleDelete(record.id)} okText="Delete" okType="danger">
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
// Word filter columns
const wordFilterColumns: ColumnsType<WordFilter> = [
{ title: 'Word', dataIndex: 'word', width: 200 },
{
title: 'Level',
dataIndex: 'level',
width: 120,
render: (level: string) => {
const colors: Record<string, string> = { high: 'error', medium: 'warning', low: 'blue', custom: 'default' };
return <Tag color={colors[level] || 'default'}>{level.toUpperCase()}</Tag>;
},
},
{
title: 'Added By',
width: 120,
render: (_: any, record) => record.creator?.name || <Text type="secondary">System</Text>,
},
{
title: 'Date',
dataIndex: 'createdAt',
width: 140,
render: (date: string) => dayjs(date).format('MMM D, YYYY'),
},
{
title: '',
width: 60,
render: (_: any, record) => (
<Popconfirm title="Remove this word?" onConfirm={() => handleDeleteWord(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div>
<Tabs
defaultActiveKey="comments"
items={[
{
key: 'comments',
label: 'Comments',
children: (
<>
{/* Stats Cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Total" value={stats.total} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Pending" value={stats.pending} valueStyle={{ color: '#bfbfbf' }} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Flagged" value={stats.flagged} valueStyle={{ color: '#faad14' }} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="Hidden" value={stats.hidden} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
</Row>
{/* Filter Bar */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Select
placeholder="Status"
allowClear
style={{ width: 130 }}
value={statusFilter}
onChange={(val) => { setStatusFilter(val); setPage(1); }}
options={[
{ label: 'Pending', value: 'pending' },
{ label: 'Flagged', value: 'flagged' },
{ label: 'Safe', value: 'safe' },
{ label: 'Hidden', value: 'hidden' },
]}
/>
<Input
placeholder="Search content..."
prefix={<SearchOutlined />}
style={{ width: 200 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onPressEnter={() => { setPage(1); fetchComments(); }}
allowClear
/>
<RangePicker
value={dateRange as any}
onChange={(dates) => { setDateRange(dates as any); setPage(1); }}
/>
<Button icon={<ReloadOutlined />} onClick={() => { fetchComments(); fetchStats(); }}>
Refresh
</Button>
</Space>
</Card>
{/* Comments Table */}
<Table
rowKey="id"
columns={columns}
dataSource={comments}
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p) => setPage(p),
showSizeChanger: false,
showTotal: (t) => `${t} comments`,
}}
size="small"
scroll={{ x: 1000 }}
/>
</>
),
},
{
key: 'word-filter',
label: 'Word Filter',
children: (
<>
{/* Add Word Form */}
<Card size="small" style={{ marginBottom: 16 }}>
<Form layout="inline">
<Form.Item>
<Input
placeholder="Enter word or phrase..."
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onPressEnter={handleAddWord}
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item>
<Select
value={newWordLevel}
onChange={setNewWordLevel}
style={{ width: 130 }}
options={[
{ label: 'High (Block)', value: 'high' },
{ label: 'Medium (Hide)', value: 'medium' },
{ label: 'Low (Flag)', value: 'low' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddWord}>
Add Word
</Button>
</Form.Item>
</Form>
<div style={{ marginTop: 8 }}>
<Text type="secondary">
<strong>High</strong>: blocks submission entirely &middot;{' '}
<strong>Medium</strong>: saved but auto-hidden for review &middot;{' '}
<strong>Low</strong>: visible but flagged for review
</Text>
</div>
</Card>
<Table
rowKey="id"
columns={wordFilterColumns}
dataSource={wordFilters}
loading={wordFiltersLoading}
pagination={false}
size="small"
/>
</>
),
destroyInactiveTabPane: false,
},
]}
onChange={(key) => {
if (key === 'word-filter') fetchWordFilters();
}}
/>
{/* Detail Drawer */}
<Drawer
title="Comment Details"
open={!!selectedComment}
onClose={() => setSelectedComment(null)}
width={480}
>
{selectedComment && (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="ID">{selectedComment.id}</Descriptions.Item>
<Descriptions.Item label="Video">{selectedComment.videoTitle} (#{selectedComment.mediaId})</Descriptions.Item>
<Descriptions.Item label="User">{selectedComment.user?.name || 'Anonymous'}</Descriptions.Item>
<Descriptions.Item label="Date">{dayjs(selectedComment.createdAt).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
<Descriptions.Item label="Status">{renderStatusTag(selectedComment)}</Descriptions.Item>
{selectedComment.isHidden && (
<>
<Descriptions.Item label="Hidden At">{selectedComment.hiddenAt ? dayjs(selectedComment.hiddenAt).format('YYYY-MM-DD HH:mm') : '-'}</Descriptions.Item>
<Descriptions.Item label="Hidden Reason"><Tag>{selectedComment.hiddenReason || 'manual'}</Tag></Descriptions.Item>
</>
)}
</Descriptions>
<div>
<Text strong>Content</Text>
<Paragraph style={{ whiteSpace: 'pre-wrap', padding: 12, borderRadius: 6, background: 'rgba(255,255,255,0.04)', marginTop: 8 }}>
{selectedComment.content}
</Paragraph>
</div>
{selectedComment.safetyReasoning && (
<div>
<Text strong>Safety Reasoning</Text>
<Paragraph type="warning" style={{ marginTop: 4 }}>{selectedComment.safetyReasoning}</Paragraph>
</div>
)}
{selectedComment.moderation && (
<div>
<Text strong>Moderation History</Text>
<Descriptions column={1} size="small" style={{ marginTop: 8 }}>
<Descriptions.Item label="Decision"><Tag>{selectedComment.moderation.status}</Tag></Descriptions.Item>
<Descriptions.Item label="By">{selectedComment.moderation.moderator?.name || '-'}</Descriptions.Item>
<Descriptions.Item label="At">{selectedComment.moderation.moderatedAt ? dayjs(selectedComment.moderation.moderatedAt).format('YYYY-MM-DD HH:mm') : '-'}</Descriptions.Item>
{selectedComment.moderation.reason && (
<Descriptions.Item label="Reason">{selectedComment.moderation.reason}</Descriptions.Item>
)}
</Descriptions>
</div>
)}
<div>
<Text strong>Moderation Notes</Text>
<Input.TextArea
value={notesInput}
onChange={(e) => setNotesInput(e.target.value)}
rows={3}
placeholder="Add notes about this comment..."
style={{ marginTop: 8 }}
/>
<Button type="primary" size="small" onClick={handleSaveNotes} loading={savingNotes} style={{ marginTop: 8 }}>
Save Notes
</Button>
</div>
<Space>
{selectedComment.safetyStatus !== 'safe' && !selectedComment.isHidden && (
<Button type="primary" icon={<CheckCircleOutlined />} onClick={() => { handleApprove(selectedComment.id); setSelectedComment(null); }}>
Approve
</Button>
)}
{!selectedComment.isHidden ? (
<Button danger icon={<EyeInvisibleOutlined />} onClick={() => { handleHide(selectedComment.id); setSelectedComment(null); }}>
Hide
</Button>
) : (
<Button icon={<EyeOutlined />} onClick={() => { handleUnhide(selectedComment.id); setSelectedComment(null); }}>
Unhide
</Button>
)}
<Popconfirm title="Permanently delete?" onConfirm={() => handleDelete(selectedComment.id)}>
<Button danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
</Space>
)}
</Drawer>
</div>
);
}

View File

@ -8,6 +8,9 @@ import {
BarsOutlined,
UploadOutlined,
CalendarOutlined,
CloudDownloadOutlined,
ThunderboltOutlined,
OrderedListOutlined,
} from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { mediaApi } from '@/lib/media-api';
@ -25,6 +28,9 @@ import QuickAnalyticsModal from '@/components/media/QuickAnalyticsModal';
import SchedulePublishModal from '@/components/media/SchedulePublishModal';
import ScheduleCalendarDrawer from '@/components/media/ScheduleCalendarDrawer';
import EditVideoModal from '@/components/media/EditVideoModal';
import FetchVideosDrawer from '@/components/media/FetchVideosDrawer';
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
import BulkAddToPlaylistModal from '@/components/media/BulkAddToPlaylistModal';
export default function LibraryPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -48,6 +54,11 @@ export default function LibraryPage() {
const [scheduleVideo, setScheduleVideo] = useState<Video | null>(null);
const [calendarModalOpen, setCalendarModalOpen] = useState(false);
const [editVideo, setEditVideo] = useState<Video | null>(null);
const [fetchDrawerOpen, setFetchDrawerOpen] = useState(false);
const [shortsFilter, setShortsFilter] = useState<boolean | undefined>();
const [scanningShorts, setScanningShorts] = useState(false);
const [playlistVideoId, setPlaylistVideoId] = useState<number | null>(null);
const [bulkPlaylistOpen, setBulkPlaylistOpen] = useState(false);
useEffect(() => {
setPageHeader({
@ -62,7 +73,7 @@ export default function LibraryPage() {
useEffect(() => {
fetchVideos();
}, [debouncedSearch, orientation, selectedProducers, pagination.page, pagination.limit]);
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, pagination.page, pagination.limit]);
const fetchProducers = async () => {
try {
@ -80,6 +91,7 @@ export default function LibraryPage() {
search: debouncedSearch || undefined,
orientation,
producers: selectedProducers.length > 0 ? selectedProducers : undefined,
isShort: shortsFilter,
offset: (pagination.page - 1) * pagination.limit,
limit: pagination.limit,
};
@ -91,7 +103,22 @@ export default function LibraryPage() {
} finally {
setLoading(false);
}
}, [debouncedSearch, orientation, selectedProducers, pagination.page, pagination.limit]);
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, pagination.page, pagination.limit]);
const handleScanShorts = async () => {
setScanningShorts(true);
try {
const { data } = await mediaApi.post<{ classified: number; declassified: number; totalShorts: number }>(
'/shorts/scan'
);
message.success(`Classified ${data.classified} shorts, declassified ${data.declassified}. Total shorts: ${data.totalShorts}`);
fetchVideos();
} catch (error: any) {
message.error(error.response?.data?.message || 'Failed to scan shorts');
} finally {
setScanningShorts(false);
}
};
const handleSelect = (id: number) => {
setSelectedVideoIds((prev) =>
@ -226,7 +253,20 @@ export default function LibraryPage() {
style={{ width: '100%' }}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Col xs={12} sm={6} md={3}>
<Select
placeholder="Shorts"
options={[
{ value: 'true', label: 'Shorts Only' },
{ value: 'false', label: 'Non-Shorts' },
]}
value={shortsFilter === undefined ? undefined : String(shortsFilter)}
onChange={(v) => setShortsFilter(v === undefined ? undefined : v === 'true')}
allowClear
style={{ width: '100%' }}
/>
</Col>
<Col xs={24} sm={12} md={9}>
<Space wrap>
<Button
type="primary"
@ -235,6 +275,19 @@ export default function LibraryPage() {
>
Upload Videos
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={() => setFetchDrawerOpen(true)}
>
Fetch
</Button>
<Button
icon={<ThunderboltOutlined />}
onClick={handleScanShorts}
loading={scanningShorts}
>
Scan Shorts
</Button>
<Button
icon={<CalendarOutlined />}
onClick={() => setCalendarModalOpen(true)}
@ -282,6 +335,7 @@ export default function LibraryPage() {
onAnalytics={handleAnalytics}
onSchedule={handleSchedule}
onDelete={handleDeleteSingle}
onAddToPlaylist={(v) => setPlaylistVideoId(v.id)}
onRefresh={fetchVideos}
onTogglePublish={handleTogglePublish}
/>
@ -315,6 +369,12 @@ export default function LibraryPage() {
onClick: () => setPublishModalOpen(true),
type: 'primary',
},
{
key: 'add-to-playlist',
label: 'Add to Playlist',
icon: <OrderedListOutlined />,
onClick: () => setBulkPlaylistOpen(true),
},
{
key: 'delete',
label: 'Delete',
@ -381,6 +441,30 @@ export default function LibraryPage() {
onClose={() => setEditVideo(null)}
onSuccess={handleEditSuccess}
/>
<FetchVideosDrawer
open={fetchDrawerOpen}
onClose={() => setFetchDrawerOpen(false)}
onSuccess={() => { fetchVideos(); fetchProducers(); }}
/>
{playlistVideoId && (
<AddToPlaylistModal
videoId={playlistVideoId}
open={!!playlistVideoId}
onClose={() => setPlaylistVideoId(null)}
/>
)}
<BulkAddToPlaylistModal
videoIds={selectedVideoIds}
open={bulkPlaylistOpen}
onClose={() => setBulkPlaylistOpen(false)}
onSuccess={() => {
setBulkPlaylistOpen(false);
setSelectedVideoIds([]);
}}
/>
</div>
);
}

View File

@ -0,0 +1,629 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography,
Table,
Button,
Space,
Tag,
Input,
Tabs,
Popconfirm,
message,
} from 'antd';
import type { TableProps } from 'antd';
import {
StarOutlined,
StarFilled,
DeleteOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
SearchOutlined,
LinkOutlined,
PlusOutlined,
EditOutlined,
CopyOutlined,
GlobalOutlined,
LockOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import CreatePlaylistModal from '@/components/media/CreatePlaylistModal';
import EditPlaylistModal from '@/components/media/EditPlaylistModal';
const { Title, Text } = Typography;
interface PlaylistRow {
id: number;
name: string;
description: string | null;
isPublic: boolean;
videoCount: number;
totalDurationSeconds: number;
viewCount: number;
createdAt: string;
creator: { id: string; name: string | null; email: string };
isFeatured: boolean;
featuredPosition: number | null;
featuredAt: string | null;
}
interface FeaturedRow {
id: number;
playlistId: number;
position: number;
featuredAt: string | null;
featuredBy: { id: string; name: string | null; email: string } | null;
playlist: {
id: number;
name: string;
isPublic: boolean;
videoCount: number;
totalDurationSeconds: number;
viewCount: number;
creator: { id: string; name: string | null; email: string };
};
}
function formatDuration(totalSeconds: number): string {
if (!totalSeconds) return '0:00';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
// Map Ant Design sorter field keys to backend sortBy values
const sorterFieldMap: Record<string, string> = {
name: 'name',
videoCount: 'videoCount',
duration: 'totalDurationSeconds',
viewCount: 'viewCount',
createdAt: 'createdAt',
};
export default function PlaylistManagementPage() {
// All Playlists tab state
const [playlists, setPlaylists] = useState<PlaylistRow[]>([]);
const [total, setTotal] = useState(0);
const [loadingAll, setLoadingAll] = useState(false);
const [search, setSearch] = useState('');
const [pagination, setPagination] = useState({ current: 1, pageSize: 25 });
const [sortBy, setSortBy] = useState('createdAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Bulk selection state
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [bulkLoading, setBulkLoading] = useState(false);
// Featured tab state
const [featured, setFeatured] = useState<FeaturedRow[]>([]);
const [loadingFeatured, setLoadingFeatured] = useState(false);
// Modal state
const [createOpen, setCreateOpen] = useState(false);
const [editPlaylistId, setEditPlaylistId] = useState<number | null>(null);
const fetchAll = useCallback(async () => {
try {
setLoadingAll(true);
const { data } = await mediaApi.get('/media/playlists', {
params: {
limit: pagination.pageSize,
offset: (pagination.current - 1) * pagination.pageSize,
search: search || undefined,
sortBy,
sortOrder,
},
});
setPlaylists(data.data || []);
setTotal(data.total || 0);
} catch {
message.error('Failed to load playlists');
} finally {
setLoadingAll(false);
}
}, [pagination, search, sortBy, sortOrder]);
const fetchFeatured = useCallback(async () => {
try {
setLoadingFeatured(true);
const { data } = await mediaApi.get('/media/playlists/featured');
setFeatured(data.data || []);
} catch {
message.error('Failed to load featured playlists');
} finally {
setLoadingFeatured(false);
}
}, []);
useEffect(() => {
fetchAll();
}, [fetchAll]);
useEffect(() => {
fetchFeatured();
}, [fetchFeatured]);
const handleFeature = async (playlistId: number) => {
try {
await mediaApi.post(`/media/playlists/${playlistId}/feature`);
message.success('Playlist featured');
fetchAll();
fetchFeatured();
} catch (error: any) {
if (error.response?.status === 409) {
message.warning('Already featured');
} else {
message.error('Failed to feature playlist');
}
}
};
const handleUnfeature = async (playlistId: number) => {
try {
await mediaApi.delete(`/media/playlists/${playlistId}/feature`);
message.success('Playlist unfeatured');
fetchAll();
fetchFeatured();
} catch {
message.error('Failed to unfeature');
}
};
const handleDelete = async (playlistId: number) => {
try {
await mediaApi.delete(`/media/playlists/${playlistId}`);
message.success('Playlist deleted');
fetchAll();
fetchFeatured();
} catch {
message.error('Failed to delete playlist');
}
};
const handleToggleVisibility = async (record: PlaylistRow) => {
try {
await mediaApi.put(`/media/playlists/${record.id}`, {
isPublic: !record.isPublic,
});
message.success(`Playlist set to ${record.isPublic ? 'Private' : 'Public'}`);
fetchAll();
} catch {
message.error('Failed to update visibility');
}
};
const handleDuplicate = async (playlistId: number) => {
try {
const { data } = await mediaApi.post(`/media/playlists/${playlistId}/duplicate`);
message.success(`Created "${data.name}"`);
fetchAll();
} catch {
message.error('Failed to duplicate playlist');
}
};
const handleReorder = async (index: number, direction: 'up' | 'down') => {
const newFeatured = [...featured];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newFeatured.length) return;
const temp = newFeatured[index]!;
newFeatured[index] = newFeatured[targetIndex]!;
newFeatured[targetIndex] = temp;
const reordered = newFeatured.map((f, i) => ({ ...f, position: i }));
setFeatured(reordered);
try {
await mediaApi.put('/media/playlists/featured/reorder', {
items: reordered.map((f) => ({
playlistId: f.playlistId,
position: f.position,
})),
});
} catch {
message.error('Failed to reorder');
fetchFeatured();
}
};
// Table onChange handler for sorting
const handleTableChange: TableProps<PlaylistRow>['onChange'] = (_pag, _filters, sorter) => {
if (!sorter || Array.isArray(sorter)) return;
const field = sorter.columnKey as string;
const mappedField = sorterFieldMap[field];
if (mappedField && sorter.order) {
setSortBy(mappedField);
setSortOrder(sorter.order === 'ascend' ? 'asc' : 'desc');
} else {
setSortBy('createdAt');
setSortOrder('desc');
}
setPagination((p) => ({ ...p, current: 1 }));
};
// Bulk operations
const handleBulkAction = async (action: 'public' | 'private' | 'feature' | 'delete') => {
if (selectedRowKeys.length === 0) return;
setBulkLoading(true);
let successCount = 0;
try {
for (const id of selectedRowKeys) {
try {
switch (action) {
case 'public':
await mediaApi.put(`/media/playlists/${id}`, { isPublic: true });
break;
case 'private':
await mediaApi.put(`/media/playlists/${id}`, { isPublic: false });
break;
case 'feature':
await mediaApi.post(`/media/playlists/${id}/feature`);
break;
case 'delete':
await mediaApi.delete(`/media/playlists/${id}`);
break;
}
successCount++;
} catch {
// Continue with other items
}
}
const labels = { public: 'made public', private: 'made private', feature: 'featured', delete: 'deleted' };
message.success(`${successCount} playlist(s) ${labels[action]}`);
setSelectedRowKeys([]);
fetchAll();
fetchFeatured();
} finally {
setBulkLoading(false);
}
};
// All Playlists table columns
const allColumns = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
sorter: true,
render: (name: string, record: PlaylistRow) => (
<Space>
<Text strong>{name}</Text>
{record.isFeatured && (
<StarFilled style={{ color: '#faad14', fontSize: 12 }} />
)}
</Space>
),
},
{
title: 'Creator',
key: 'creator',
render: (_: any, record: PlaylistRow) => (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.creator.name || record.creator.email}
</Text>
),
},
{
title: 'Videos',
dataIndex: 'videoCount',
key: 'videoCount',
width: 80,
align: 'center' as const,
sorter: true,
},
{
title: 'Duration',
dataIndex: 'totalDurationSeconds',
key: 'duration',
width: 100,
sorter: true,
render: (seconds: number) => formatDuration(seconds),
},
{
title: 'Views',
dataIndex: 'viewCount',
key: 'viewCount',
width: 80,
align: 'center' as const,
sorter: true,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 110,
sorter: true,
render: (date: string) => dayjs(date).format('MMM D, YYYY'),
},
{
title: 'Visibility',
dataIndex: 'isPublic',
key: 'isPublic',
width: 90,
render: (isPublic: boolean, record: PlaylistRow) => (
<Tag
color={isPublic ? 'green' : 'default'}
icon={isPublic ? <GlobalOutlined /> : <LockOutlined />}
style={{ cursor: 'pointer' }}
onClick={() => handleToggleVisibility(record)}
>
{isPublic ? 'Public' : 'Private'}
</Tag>
),
},
{
title: 'Actions',
key: 'actions',
width: 210,
render: (_: any, record: PlaylistRow) => (
<Space size={4}>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => setEditPlaylistId(record.id)}
title="Edit"
/>
<Button
size="small"
type="text"
icon={<CopyOutlined />}
onClick={() => handleDuplicate(record.id)}
title="Duplicate"
/>
<Button
size="small"
type="text"
icon={<LinkOutlined />}
onClick={() => {
window.open(`/gallery/curated/${record.id}`, '_blank');
}}
title="View"
/>
{record.isFeatured ? (
<Button
size="small"
type="text"
icon={<StarFilled style={{ color: '#faad14' }} />}
onClick={() => handleUnfeature(record.id)}
title="Unfeature"
/>
) : (
<Button
size="small"
type="text"
icon={<StarOutlined />}
onClick={() => handleFeature(record.id)}
title="Feature"
/>
)}
<Popconfirm
title="Delete this playlist?"
onConfirm={() => handleDelete(record.id)}
okText="Delete"
okType="danger"
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
// Featured table columns
const featuredColumns = [
{
title: '#',
dataIndex: 'position',
key: 'position',
width: 50,
render: (pos: number) => pos + 1,
},
{
title: 'Playlist',
key: 'playlist',
render: (_: any, record: FeaturedRow) => (
<div>
<Text strong>{record.playlist.name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
by {record.playlist.creator.name || record.playlist.creator.email}
{' \u2022 '}
{record.playlist.videoCount} videos
{' \u2022 '}
{record.playlist.viewCount} views
</Text>
</div>
),
},
{
title: 'Featured By',
key: 'featuredBy',
render: (_: any, record: FeaturedRow) => (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.featuredBy?.name || record.featuredBy?.email || '\u2014'}
</Text>
),
},
{
title: 'Actions',
key: 'actions',
width: 140,
render: (_: any, record: FeaturedRow, index: number) => (
<Space size={4}>
<Button
size="small"
type="text"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => handleReorder(index, 'up')}
/>
<Button
size="small"
type="text"
icon={<ArrowDownOutlined />}
disabled={index === featured.length - 1}
onClick={() => handleReorder(index, 'down')}
/>
<Button
size="small"
type="text"
danger
icon={<StarFilled />}
onClick={() => handleUnfeature(record.playlistId)}
title="Unfeature"
/>
</Space>
),
},
];
const handleRefreshAll = () => {
fetchAll();
fetchFeatured();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>Playlist Management</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
>
Create Playlist
</Button>
</div>
<Tabs
items={[
{
key: 'all',
label: `All Playlists (${total})`,
children: (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Input
placeholder="Search playlists..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPagination((p) => ({ ...p, current: 1 }));
}}
allowClear
style={{ width: 300 }}
/>
{/* Bulk actions bar */}
{selectedRowKeys.length > 0 && (
<Space size={8}>
<Text type="secondary">{selectedRowKeys.length} selected</Text>
<Button
size="small"
icon={<GlobalOutlined />}
onClick={() => handleBulkAction('public')}
loading={bulkLoading}
>
Make Public
</Button>
<Button
size="small"
icon={<LockOutlined />}
onClick={() => handleBulkAction('private')}
loading={bulkLoading}
>
Make Private
</Button>
<Button
size="small"
icon={<StarOutlined />}
onClick={() => handleBulkAction('feature')}
loading={bulkLoading}
>
Feature
</Button>
<Popconfirm
title={`Delete ${selectedRowKeys.length} playlist(s)?`}
onConfirm={() => handleBulkAction('delete')}
okText="Delete"
okType="danger"
>
<Button
size="small"
danger
icon={<DeleteOutlined />}
loading={bulkLoading}
>
Delete
</Button>
</Popconfirm>
<Button size="small" onClick={() => setSelectedRowKeys([])}>
Clear
</Button>
</Space>
)}
</div>
<Table
dataSource={playlists}
columns={allColumns}
rowKey="id"
loading={loadingAll}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
onChange={handleTableChange}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total,
onChange: (page, pageSize) =>
setPagination({ current: page, pageSize }),
showSizeChanger: true,
showTotal: (t) => `${t} playlists`,
}}
size="middle"
/>
</>
),
},
{
key: 'featured',
label: `Featured (${featured.length})`,
children: (
<Table
dataSource={featured}
columns={featuredColumns}
rowKey="id"
loading={loadingFeatured}
pagination={false}
size="middle"
locale={{ emptyText: 'No featured playlists. Feature playlists from the "All Playlists" tab.' }}
/>
),
},
]}
/>
<CreatePlaylistModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleRefreshAll}
/>
<EditPlaylistModal
playlistId={editPlaylistId}
open={!!editPlaylistId}
onClose={() => setEditPlaylistId(null)}
onUpdated={handleRefreshAll}
/>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useParams, Link, useSearchParams } from 'react-router-dom';
import {
Typography,
Input,
@ -12,6 +12,7 @@ import {
Spin,
Result,
Grid,
Tooltip,
theme,
} from 'antd';
import {
@ -23,8 +24,11 @@ import {
CopyOutlined,
CheckCircleOutlined,
ArrowRightOutlined,
RocketOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import type {
Campaign,
Representative,
@ -41,9 +45,11 @@ type Step = 'info' | 'reps' | 'send';
export default function CampaignPage() {
const { slug } = useParams<{ slug: string }>();
const [searchParams] = useSearchParams();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const { isAuthenticated } = useAuthStore();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [loading, setLoading] = useState(true);
@ -52,8 +58,8 @@ export default function CampaignPage() {
// Step tracking
const [currentStep, setCurrentStep] = useState<Step>('info');
// Step 1: User info
const [postalCode, setPostalCode] = useState('');
// Step 1: User info (pre-fill from query param)
const [postalCode, setPostalCode] = useState(searchParams.get('postalCode') || '');
const [userName, setUserName] = useState('');
const [userEmail, setUserEmail] = useState('');
@ -449,25 +455,29 @@ export default function CampaignPage() {
{rep.email && !isSent && (
<>
{campaign.allowSmtpEmail && (
<Button
type="primary"
size="small"
icon={<SendOutlined />}
loading={sendingTo === rep.email}
onClick={() => handleSendSmtp(rep)}
style={{ background: '#005a9c' }}
>
Send
</Button>
<Tooltip title="Send the campaign email directly through our system">
<Button
type="primary"
size="small"
icon={<SendOutlined />}
loading={sendingTo === rep.email}
onClick={() => handleSendSmtp(rep)}
style={{ background: '#005a9c' }}
>
Send via System
</Button>
</Tooltip>
)}
{campaign.allowMailtoLink && (
<Button
size="small"
icon={<MailOutlined />}
onClick={() => handleMailto(rep)}
>
Email App
</Button>
<Tooltip title="Opens your default email app with the message pre-filled">
<Button
size="small"
icon={<MailOutlined />}
onClick={() => handleMailto(rep)}
>
Open Your Email
</Button>
</Tooltip>
)}
</>
)}
@ -556,6 +566,48 @@ export default function CampaignPage() {
</Card>
)}
{/* Post-Send CTA */}
{sentTo.size > 0 && (
<Card
style={{
marginBottom: 24,
background: 'linear-gradient(135deg, #1b2838 0%, #0d2137 100%)',
border: '1px solid rgba(82, 196, 26, 0.3)',
borderRadius: 12,
textAlign: 'center',
}}
>
<CheckCircleOutlined style={{ fontSize: 32, color: '#52c41a', marginBottom: 8 }} />
<Title level={4} style={{ color: '#fff', margin: '0 0 4px' }}>
You've made your voice heard!
</Title>
<Text style={{ color: 'rgba(255,255,255,0.65)', display: 'block', marginBottom: 20 }}>
Want to amplify your impact? Share this campaign or start your own.
</Text>
<Space wrap size={12} style={{ justifyContent: 'center' }}>
{!isAuthenticated && (
<Link to={`/login?redirect=/campaigns/create`}>
<Button icon={<UserOutlined />} style={{ borderColor: token.colorPrimary, color: token.colorPrimary }}>
Create an Account
</Button>
</Link>
)}
<Link to="/campaigns/create">
<Button type="primary" icon={<RocketOutlined />}>
Start Your Own Campaign
</Button>
</Link>
<Button
icon={<ShareAltOutlined />}
onClick={handleCopyLink}
style={{ borderColor: 'rgba(255,255,255,0.25)', color: 'rgba(255,255,255,0.85)' }}
>
Share This Campaign
</Button>
</Space>
</Card>
)}
{/* Response Wall CTA */}
{campaign.showResponseWall && (
<div

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import {
Typography,
Card,
@ -15,8 +15,10 @@ import {
Avatar,
Space,
Divider,
Drawer,
Grid,
Tooltip,
Popover,
message,
theme,
} from 'antd';
@ -30,9 +32,15 @@ import {
GlobalOutlined,
ArrowRightOutlined,
CopyOutlined,
SendOutlined,
EditOutlined,
EnvironmentOutlined,
BankOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
import { useAuthStore } from '@/stores/auth.store';
import AuthModal from '@/components/AuthModal';
import type { Campaign, GovernmentLevel, Representative, RepresentativeLookupResponse } from '@/types/api';
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
import { groupRepsByLevel, GOVERNMENT_LEVEL_DISPLAY_COLORS } from '@/utils/representatives';
@ -47,6 +55,14 @@ export default function CampaignsListPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { settings: siteSettings } = useSettingsStore();
const { isAuthenticated } = useAuthStore();
const navigate = useNavigate();
const [authModalOpen, setAuthModalOpen] = useState(false);
// Derive colors from theme tokens (set by PublicLayout from settings)
const colorPrimary = token.colorPrimary;
const colorBgContainer = token.colorBgContainer;
const headerGradient = siteSettings?.publicHeaderGradient ?? `linear-gradient(135deg, ${colorPrimary} 0%, ${token.colorPrimaryBgHover} 100%)`;
// Rep lookup state
const [postalCode, setPostalCode] = useState('');
@ -54,6 +70,9 @@ export default function CampaignsListPage() {
const [representatives, setRepresentatives] = useState<Representative[] | null>(null);
const [lookupLocation, setLookupLocation] = useState<{ city: string | null; province: string | null } | null>(null);
// Campaign selector drawer (mobile)
const [campaignDrawerOpen, setCampaignDrawerOpen] = useState(false);
useEffect(() => {
fetchCampaigns();
}, []);
@ -91,6 +110,19 @@ export default function CampaignsListPage() {
}
};
const navigateToCampaign = (slug: string) => {
const pc = postalCode.replace(/\s/g, '').toUpperCase();
window.location.href = `/campaign/${slug}?postalCode=${pc}`;
};
const navigateToCreate = () => {
if (isAuthenticated) {
navigate('/campaigns/create');
} else {
setAuthModalOpen(true);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
@ -123,6 +155,69 @@ export default function CampaignsListPage() {
? [lookupLocation.city, lookupLocation.province].filter(Boolean).join(', ')
: '';
// Campaign selector content (shared between Popover desktop + Drawer mobile)
const campaignSelectorContent = (
<div>
{campaigns.map((c) => (
<Card
key={c.id}
hoverable
size="small"
style={{
marginBottom: 8,
cursor: 'pointer',
borderRadius: 8,
}}
styles={{ body: { padding: '12px 14px' } }}
onClick={() => {
setCampaignDrawerOpen(false);
navigateToCampaign(c.slug);
}}
>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 2 }}>
<SendOutlined style={{ marginRight: 6, fontSize: 12, opacity: 0.6 }} />
{c.title}
</div>
{c.description && (
<div style={{
fontSize: 13,
opacity: 0.6,
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
marginLeft: 18,
}}>
{c.description}
</div>
)}
</Card>
))}
{campaigns.length > 0 && <Divider style={{ margin: '12px 0' }} />}
<Card
hoverable
size="small"
style={{
cursor: 'pointer',
borderRadius: 8,
borderStyle: 'dashed',
}}
styles={{ body: { padding: '14px', textAlign: 'center' } }}
onClick={() => {
setCampaignDrawerOpen(false);
navigateToCreate();
}}
>
<EditOutlined style={{ marginRight: 6, fontSize: 14 }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>Write Your Own Campaign</span>
<div style={{ fontSize: 12, opacity: 0.55, marginTop: 4 }}>
Create a custom message for this issue
</div>
</Card>
</div>
);
return (
<div>
{/* Hero Banner */}
@ -131,7 +226,7 @@ export default function CampaignsListPage() {
textAlign: 'center',
padding: isMobile ? '36px 16px 32px' : '48px 24px 40px',
marginBottom: 32,
background: siteSettings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 50%, #0099ff 100%)',
background: headerGradient,
borderRadius: 12,
position: 'relative',
overflow: 'hidden',
@ -140,7 +235,7 @@ export default function CampaignsListPage() {
<Title level={2} style={{ color: '#fff', margin: 0, fontSize: isMobile ? 26 : 32, textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
{siteSettings?.organizationName ?? 'Changemaker Lite'}
</Title>
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', fontSize: isMobile ? 14 : 16, margin: '12px 0 0' }}>
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', fontSize: isMobile ? 15 : 16, margin: '12px 0 0' }}>
Connect with your elected representatives across all levels of government
</Paragraph>
</div>
@ -152,24 +247,24 @@ export default function CampaignsListPage() {
</Title>
<Card
style={{
background: 'rgba(27, 40, 56, 0.8)',
background: colorBgContainer,
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
maxWidth: 600,
margin: '0 auto',
}}
>
<Text style={{ color: 'rgba(255,255,255,0.7)', display: 'block', textAlign: 'center', marginBottom: 12 }}>
<Text style={{ color: 'rgba(255,255,255,0.7)', display: 'block', textAlign: 'center', marginBottom: 12, fontSize: isMobile ? 15 : 14 }}>
Enter your postal code:
</Text>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ display: 'flex', gap: 12, flexDirection: !screens.sm ? 'column' : 'row' }}>
<Input
placeholder="e.g. T5K 2M5"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
onPressEnter={handleLookup}
size="large"
style={{ textTransform: 'uppercase', flex: 1 }}
style={{ textTransform: 'uppercase', flex: screens.sm ? 1 : undefined }}
disabled={lookupLoading}
prefix={lookupLoading ? <Spin size="small" /> : undefined}
/>
@ -179,7 +274,7 @@ export default function CampaignsListPage() {
icon={<SearchOutlined />}
onClick={handleLookup}
loading={lookupLoading}
style={{ background: '#005a9c' }}
block={!screens.sm}
>
Search
</Button>
@ -190,9 +285,21 @@ export default function CampaignsListPage() {
{repsByLevel && (
<div style={{ marginTop: 24 }}>
{locationText && (
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', textAlign: 'center', marginBottom: 16 }}>
Showing representatives for {locationText}
</Text>
<Card
size="small"
style={{
background: `linear-gradient(135deg, ${token.colorPrimaryBg} 0%, ${colorBgContainer} 100%)`,
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 10,
marginBottom: 20,
textAlign: 'center',
}}
>
<EnvironmentOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
<Text style={{ color: 'rgba(255,255,255,0.75)', fontSize: isMobile ? 15 : 14 }}>
Showing representatives for <Text strong style={{ color: '#fff' }}>{locationText}</Text>
</Text>
</Card>
)}
{representatives!.length === 0 ? (
<Result
@ -202,96 +309,22 @@ export default function CampaignsListPage() {
/>
) : (
Object.entries(repsByLevel).map(([level, reps]) => (
<div key={level} style={{ marginBottom: 24 }}>
<Title level={4} style={{ color: GOVERNMENT_LEVEL_DISPLAY_COLORS[level] || '#94a3b8', marginBottom: 12 }}>
<div key={level} style={{ marginBottom: 28 }}>
<Title level={4} style={{ color: GOVERNMENT_LEVEL_DISPLAY_COLORS[level] || '#94a3b8', marginBottom: 14, fontSize: isMobile ? 18 : 20 }}>
{GOVERNMENT_LEVEL_LABELS[level as GovernmentLevel] || level} Representatives
</Title>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{reps.map((rep, idx) => (
<Card
<RepCard
key={rep.id || idx}
style={{
background: '#1b2838',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
}}
>
<div style={{ display: 'flex', gap: 16, flexDirection: isMobile ? 'column' : 'row' }}>
<Avatar src={rep.photoUrl} icon={!rep.photoUrl ? <UserOutlined /> : undefined} size={isMobile ? 64 : 80} />
<div style={{ flex: 1 }}>
<Text strong style={{ color: token.colorPrimary, fontSize: 17 }}>
{rep.name || 'Unknown'}
</Text>
<div style={{ marginTop: 4 }}>
{rep.electedOffice && (
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Office:</Text> {rep.electedOffice}</Text></div>
)}
{rep.districtName && (
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>District:</Text> {rep.districtName}</Text></div>
)}
{rep.partyName && (
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Party:</Text> {rep.partyName}</Text></div>
)}
{rep.email && (
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Email:</Text> {rep.email}</Text></div>
)}
{rep.offices?.map((office, oi) => (
office.tel && (
<div key={oi}><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Phone:</Text> {office.tel}{office.type ? ` (${office.type})` : ''}</Text></div>
)
))}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 12 }}>
{rep.email && (
<Button
type="primary"
size="small"
icon={<MailOutlined />}
onClick={() => window.open(`mailto:${rep.email}`, '_blank')}
style={{ background: '#005a9c' }}
>
Send Email
</Button>
)}
{rep.offices?.some(o => o.tel) && (
<Button
size="small"
icon={<PhoneOutlined />}
style={{ background: '#16a34a', border: 'none', color: '#fff' }}
onClick={() => {
const phone = rep.offices?.find(o => o.tel)?.tel;
if (phone) window.open(`tel:${phone.replace(/\s/g, '')}`, '_blank');
}}
>
Call
</Button>
)}
{rep.url && (
<Button
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(rep.url!, '_blank')}
>
View Profile
</Button>
)}
</div>
{rep.offices && rep.offices.length > 0 && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
{rep.offices.map((office, oi) => (
office.postal && (
<Tag key={oi} color="processing" style={{ fontSize: 11 }}>
{office.type || 'Office'}: {office.postal}
</Tag>
)
))}
</div>
)}
</div>
</div>
</Card>
rep={rep}
isMobile={isMobile}
token={token}
colorBgContainer={colorBgContainer}
campaigns={campaigns}
onOpenDrawer={() => setCampaignDrawerOpen(true)}
campaignSelectorContent={campaignSelectorContent}
/>
))}
</Space>
</div>
@ -301,6 +334,20 @@ export default function CampaignsListPage() {
)}
</div>
{/* Campaign Selection Drawer (Mobile) */}
<Drawer
title={campaigns.length > 0 ? 'Choose a Campaign' : 'Take Action'}
placement="bottom"
open={campaignDrawerOpen}
onClose={() => setCampaignDrawerOpen(false)}
height="auto"
styles={{
body: { padding: 16, maxHeight: '70vh', overflowY: 'auto' },
}}
>
{campaignSelectorContent}
</Drawer>
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Featured Campaign */}
@ -310,7 +357,7 @@ export default function CampaignsListPage() {
<Card
hoverable
style={{
background: 'linear-gradient(135deg, #1b2838 0%, #1a3a5c 100%)',
background: `linear-gradient(135deg, ${colorBgContainer} 0%, ${token.colorPrimaryBg} 100%)`,
border: '1px solid rgba(255,215,0,0.3)',
borderRadius: 12,
overflow: 'hidden',
@ -322,7 +369,7 @@ export default function CampaignsListPage() {
</div>
<Title level={3} style={{ color: '#fff', margin: '4px 0 8px' }}>{campaign.title}</Title>
{campaign.description && (
<Paragraph style={{ color: 'rgba(255,255,255,0.7)', margin: '0 0 12px' }} ellipsis={{ rows: 2 }}>
<Paragraph style={{ color: 'rgba(255,255,255,0.7)', margin: '0 0 12px', fontSize: isMobile ? 15 : 14 }} ellipsis={{ rows: 2 }}>
{campaign.description}
</Paragraph>
)}
@ -347,7 +394,7 @@ export default function CampaignsListPage() {
</div>
</Card>
</Link>
<ShareButtons campaign={campaign} />
<ShareButtons campaign={campaign} isMobile={isMobile} />
</div>
))}
@ -355,7 +402,7 @@ export default function CampaignsListPage() {
<Title level={3} style={{ color: 'rgba(255,255,255,0.85)', textAlign: 'center', marginBottom: 8 }}>
Active Campaigns
</Title>
<Paragraph style={{ color: 'rgba(255,255,255,0.55)', textAlign: 'center', marginBottom: 24 }}>
<Paragraph style={{ color: 'rgba(255,255,255,0.55)', textAlign: 'center', marginBottom: 24, fontSize: isMobile ? 15 : 14 }}>
Join ongoing campaigns to make your voice heard on important issues
</Paragraph>
@ -377,7 +424,7 @@ export default function CampaignsListPage() {
<Card
style={{
flex: 1,
background: '#1b2838',
background: colorBgContainer,
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
overflow: 'hidden',
@ -407,7 +454,7 @@ export default function CampaignsListPage() {
style={{
height: 140,
margin: '-20px -20px 16px -20px',
background: 'linear-gradient(135deg, #1a3a5c 0%, #1b2838 100%)',
background: `linear-gradient(135deg, ${token.colorPrimaryBg} 0%, ${colorBgContainer} 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -420,7 +467,7 @@ export default function CampaignsListPage() {
)}
{campaign.description && (
<Paragraph
style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13, margin: '0 0 12px' }}
style={{ color: 'rgba(255,255,255,0.55)', fontSize: isMobile ? 14 : 13, margin: '0 0 12px', lineHeight: 1.5 }}
ellipsis={{ rows: 2 }}
>
{campaign.description}
@ -435,13 +482,13 @@ export default function CampaignsListPage() {
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
{campaign.showEmailCount && (
<span style={{ color: token.colorPrimary, fontSize: 13 }}>
<span style={{ color: token.colorPrimary, fontSize: isMobile ? 14 : 13 }}>
<MailOutlined style={{ marginRight: 4 }} />
{campaign._count.emails} emails sent
</span>
)}
{campaign.showResponseWall && (
<span style={{ color: token.colorSuccess, fontSize: 13 }}>
<span style={{ color: token.colorSuccess, fontSize: isMobile ? 14 : 13 }}>
<MessageOutlined style={{ marginRight: 4 }} />
{campaign._count.responses} responses
</span>
@ -449,7 +496,7 @@ export default function CampaignsListPage() {
</div>
</Link>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', padding: '8px 20px' }}>
<ShareButtons campaign={campaign} compact />
<ShareButtons campaign={campaign} compact isMobile={isMobile} />
<Link to={`/campaign/${campaign.slug}`} style={{ display: 'block', marginTop: 4 }}>
<Button type="link" style={{ padding: 0, color: token.colorPrimary, fontSize: 13 }}>
Learn More & Participate <ArrowRightOutlined />
@ -462,16 +509,263 @@ export default function CampaignsListPage() {
))}
</Row>
)}
<AuthModal
open={authModalOpen}
onCancel={() => setAuthModalOpen(false)}
onSuccess={() => {
setAuthModalOpen(false);
navigate('/campaigns/create');
}}
title="Sign in to Create a Campaign"
subtitle="Sign in or create an account to submit your own campaign"
/>
</div>
);
}
// --- Representative Card Component ---
function RepCard({
rep,
isMobile,
token: tkn,
colorBgContainer,
campaigns,
onOpenDrawer,
campaignSelectorContent,
}: {
rep: Representative;
isMobile: boolean;
token: ReturnType<typeof theme.useToken>['token'];
colorBgContainer: string;
campaigns: Campaign[];
onOpenDrawer: () => void;
campaignSelectorContent: React.ReactNode;
}) {
const firstPhone = rep.offices?.find(o => o.tel);
return (
<Card
style={{
background: colorBgContainer,
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 12,
}}
styles={{ body: { padding: isMobile ? 16 : 20 } }}
>
{/* Header: Avatar + Name + Title */}
<div style={{
display: 'flex',
gap: isMobile ? 12 : 16,
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'center' : 'flex-start',
}}>
<Avatar
src={rep.photoUrl}
icon={!rep.photoUrl ? <UserOutlined /> : undefined}
size={isMobile ? 80 : 72}
style={{ flexShrink: 0, border: '2px solid rgba(255,255,255,0.1)' }}
/>
<div style={{ flex: 1, width: '100%' }}>
{/* Name */}
<Text strong style={{
color: tkn.colorPrimary,
fontSize: isMobile ? 20 : 18,
display: 'block',
textAlign: isMobile ? 'center' : 'left',
lineHeight: 1.3,
}}>
{rep.name || 'Unknown'}
</Text>
{/* Role subtitle */}
{rep.electedOffice && (
<Text style={{
color: 'rgba(255,255,255,0.6)',
fontSize: isMobile ? 15 : 14,
display: 'block',
textAlign: isMobile ? 'center' : 'left',
marginTop: 2,
}}>
{rep.electedOffice}
</Text>
)}
{/* Info grid */}
<div style={{
marginTop: 12,
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr',
gap: isMobile ? '6px 0' : '4px 12px',
fontSize: isMobile ? 15 : 14,
lineHeight: 1.6,
}}>
{rep.districtName && (
<>
<Text style={{ color: 'rgba(255,255,255,0.45)', whiteSpace: 'nowrap' }}>
<EnvironmentOutlined style={{ marginRight: 6 }} />District
</Text>
<Text style={{ color: 'rgba(255,255,255,0.85)' }}>{rep.districtName}</Text>
</>
)}
{rep.partyName && (
<>
<Text style={{ color: 'rgba(255,255,255,0.45)', whiteSpace: 'nowrap' }}>
<BankOutlined style={{ marginRight: 6 }} />Party
</Text>
<Text style={{ color: 'rgba(255,255,255,0.85)' }}>{rep.partyName}</Text>
</>
)}
{rep.email && (
<>
<Text style={{ color: 'rgba(255,255,255,0.45)', whiteSpace: 'nowrap' }}>
<MailOutlined style={{ marginRight: 6 }} />Email
</Text>
<Text style={{ color: 'rgba(255,255,255,0.85)', wordBreak: 'break-all' }}>{rep.email}</Text>
</>
)}
{firstPhone && (
<>
<Text style={{ color: 'rgba(255,255,255,0.45)', whiteSpace: 'nowrap' }}>
<PhoneOutlined style={{ marginRight: 6 }} />Phone
</Text>
<Text style={{ color: 'rgba(255,255,255,0.85)' }}>
{firstPhone.tel}{firstPhone.type ? ` (${firstPhone.type})` : ''}
</Text>
</>
)}
{rep.offices?.filter(o => o.tel && o !== firstPhone).map((office, oi) => (
<div key={oi} style={{ gridColumn: isMobile ? '1' : '1 / -1', paddingLeft: isMobile ? 0 : 24 }}>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: isMobile ? 14 : 13 }}>
<PhoneOutlined style={{ marginRight: 6, opacity: 0.5 }} />
{office.tel}{office.type ? ` (${office.type})` : ''}
</Text>
</div>
))}
</div>
{/* Office addresses */}
{rep.offices && rep.offices.some(o => o.postal) && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 10 }}>
{rep.offices.filter(o => o.postal).slice(0, 2).map((office, oi) => (
<Tag key={oi} color="processing" style={{ fontSize: isMobile ? 12 : 11, lineHeight: '20px' }}>
{office.type || 'Office'}: {office.postal}
</Tag>
))}
{rep.offices.filter(o => o.postal).length > 2 && (
<Tag style={{ fontSize: 11, lineHeight: '20px' }}>
+{rep.offices.filter(o => o.postal).length - 2} more
</Tag>
)}
</div>
)}
{/* Action Buttons */}
<div style={{
display: 'flex',
gap: isMobile ? 10 : 8,
flexWrap: 'wrap',
marginTop: 16,
flexDirection: isMobile ? 'column' : 'row',
}}>
{rep.email && (
isMobile ? (
<Button
type="primary"
size="large"
icon={<SendOutlined />}
block
onClick={onOpenDrawer}
>
Send via Campaign
</Button>
) : (
<Popover
trigger="click"
placement="bottomLeft"
title={campaigns.length > 0 ? 'Choose a Campaign' : 'Take Action'}
content={<div style={{ maxWidth: 340, maxHeight: 400, overflowY: 'auto' }}>{campaignSelectorContent}</div>}
>
<Button type="primary" icon={<SendOutlined />}>
Send via Campaign
</Button>
</Popover>
)
)}
{rep.email && (
<Tooltip title="Open your default email app to compose a message">
<Button
size={isMobile ? 'large' : 'middle'}
icon={<MailOutlined />}
onClick={() => window.open(`mailto:${rep.email}`, '_blank')}
block={isMobile}
>
Email Directly
</Button>
</Tooltip>
)}
<div style={{ display: 'flex', gap: isMobile ? 10 : 8, width: isMobile ? '100%' : 'auto' }}>
{rep.offices?.some(o => o.tel) && (
<Button
size={isMobile ? 'large' : 'middle'}
icon={<PhoneOutlined />}
style={{ background: '#16a34a', border: 'none', color: '#fff', flex: isMobile ? 1 : undefined }}
onClick={() => {
const phone = rep.offices?.find(o => o.tel)?.tel;
if (phone) window.open(`tel:${phone.replace(/\s/g, '')}`, '_blank');
}}
>
Call
</Button>
)}
{rep.url && (
<Button
size={isMobile ? 'large' : 'middle'}
icon={<GlobalOutlined />}
onClick={() => window.open(rep.url!, '_blank')}
style={{ flex: isMobile ? 1 : undefined }}
>
Profile
</Button>
)}
</div>
</div>
</div>
</div>
</Card>
);
}
// --- Share Buttons Component ---
function ShareButtons({ campaign, compact }: { campaign: Campaign; compact?: boolean }) {
function ShareButtons({ campaign, compact, isMobile }: { campaign: Campaign; compact?: boolean; isMobile?: boolean }) {
const url = `${window.location.origin}/campaign/${campaign.slug}`;
const text = `${campaign.title} - Take action now!`;
// On mobile, use native share API if available
if (isMobile && typeof navigator !== 'undefined' && navigator.share) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: compact ? 0 : 8 }}>
<Button
size="small"
icon={<CopyOutlined />}
onClick={async (e) => {
e.stopPropagation();
e.preventDefault();
try {
await navigator.share({ title: campaign.title, url });
} catch {
// User cancelled
}
}}
>
Share
</Button>
</div>
);
}
const shareLinks = [
{
label: 'Share on X',

View File

@ -0,0 +1,266 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Typography,
Card,
Form,
Input,
Button,
Checkbox,
Steps,
Result,
message,
Grid,
theme,
} from 'antd';
import {
EditOutlined,
MailOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { GovernmentLevel, CreateUserCampaignPayload } from '@/types/api';
import { GOVERNMENT_LEVEL_LABELS } from '@/types/api';
const { Title, Text, Paragraph } = Typography;
const GOV_LEVEL_OPTIONS: { value: GovernmentLevel; label: string }[] = [
{ value: 'FEDERAL', label: GOVERNMENT_LEVEL_LABELS.FEDERAL },
{ value: 'PROVINCIAL', label: GOVERNMENT_LEVEL_LABELS.PROVINCIAL },
{ value: 'MUNICIPAL', label: GOVERNMENT_LEVEL_LABELS.MUNICIPAL },
{ value: 'SCHOOL_BOARD', label: GOVERNMENT_LEVEL_LABELS.SCHOOL_BOARD },
];
export default function CreateCampaignPage() {
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [form] = Form.useForm();
const [step, setStep] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const handleNext = async () => {
try {
if (step === 0) {
await form.validateFields(['title', 'description', 'targetGovernmentLevels']);
} else if (step === 1) {
await form.validateFields(['emailSubject', 'emailBody']);
}
setStep(step + 1);
} catch {
// validation error
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const payload: CreateUserCampaignPayload = {
title: values.title,
description: values.description || undefined,
emailSubject: values.emailSubject,
emailBody: values.emailBody,
callToAction: values.callToAction || undefined,
targetGovernmentLevels: values.targetGovernmentLevels,
};
await api.post('/campaigns/user/submit', payload);
setSubmitted(true);
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to submit campaign';
message.error(msg);
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<Result
status="success"
title="Campaign Submitted for Review!"
subTitle="An admin will review your campaign. You'll be able to see its status on your My Campaigns page."
extra={[
<Link key="mine" to="/campaigns/mine">
<Button type="primary">View My Campaigns</Button>
</Link>,
<Link key="home" to="/campaigns">
<Button>Back to Campaigns</Button>
</Link>,
]}
/>
);
}
const formValues = form.getFieldsValue(true);
return (
<div>
<Title level={3} style={{ color: '#fff', textAlign: 'center', marginBottom: 4 }}>
Create a Campaign
</Title>
<Paragraph style={{ color: 'rgba(255,255,255,0.55)', textAlign: 'center', marginBottom: 24 }}>
Submit your campaign for review. Once approved, others can use it to contact their representatives.
</Paragraph>
<Steps
current={step}
size={isMobile ? 'small' : 'default'}
style={{ marginBottom: 24 }}
items={[
{ title: 'Campaign Info', icon: <EditOutlined /> },
{ title: 'Email Template', icon: <MailOutlined /> },
{ title: 'Review & Submit', icon: <CheckCircleOutlined /> },
]}
/>
<Form form={form} layout="vertical" size="large">
{step === 0 && (
<Card style={{ background: token.colorBgContainer, border: '1px solid rgba(255,255,255,0.08)' }}>
<Form.Item
name="title"
label={<Text style={{ color: 'rgba(255,255,255,0.85)' }}>Campaign Title</Text>}
rules={[
{ required: true, message: 'Title is required' },
{ min: 3, message: 'At least 3 characters' },
{ max: 200, message: 'Maximum 200 characters' },
]}
>
<Input placeholder="e.g. Protect Our Green Spaces" />
</Form.Item>
<Form.Item
name="description"
label={<Text style={{ color: 'rgba(255,255,255,0.85)' }}>Description (optional)</Text>}
rules={[{ max: 2000, message: 'Maximum 2000 characters' }]}
>
<Input.TextArea rows={3} placeholder="Briefly describe the purpose of this campaign" />
</Form.Item>
<Form.Item
name="targetGovernmentLevels"
label={<Text style={{ color: 'rgba(255,255,255,0.85)' }}>Target Government Levels</Text>}
rules={[{ required: true, message: 'Select at least one government level' }]}
>
<Checkbox.Group options={GOV_LEVEL_OPTIONS} />
</Form.Item>
<Button type="primary" onClick={handleNext}>
Next: Email Template
</Button>
</Card>
)}
{step === 1 && (
<Card style={{ background: token.colorBgContainer, border: '1px solid rgba(255,255,255,0.08)' }}>
<Form.Item
name="emailSubject"
label={<Text style={{ color: 'rgba(255,255,255,0.85)' }}>Email Subject Line</Text>}
rules={[
{ required: true, message: 'Subject is required' },
{ min: 3, message: 'At least 3 characters' },
{ max: 200, message: 'Maximum 200 characters' },
]}
>
<Input placeholder="e.g. Urgent: Protect Our Local Parks" />
</Form.Item>
<Form.Item
name="emailBody"
label={<Text style={{ color: 'rgba(255,255,255,0.85)' }}>Email Body</Text>}
rules={[
{ required: true, message: 'Email body is required' },
{ min: 10, message: 'At least 10 characters' },
{ max: 5000, message: 'Maximum 5000 characters' },
]}
>
<Input.TextArea
rows={10}
placeholder="Write the email that will be sent to representatives..."
showCount
maxLength={5000}
/>
</Form.Item>
<Form.Item
name="callToAction"
label={<Text style={{ color: 'rgba(255,255,255,0.85)' }}>Call to Action (optional)</Text>}
rules={[{ max: 500, message: 'Maximum 500 characters' }]}
>
<Input placeholder="e.g. Stand up for our community's future!" />
</Form.Item>
<div style={{ display: 'flex', gap: 12 }}>
<Button onClick={() => setStep(0)}>Back</Button>
<Button type="primary" onClick={handleNext}>
Next: Review
</Button>
</div>
</Card>
)}
{step === 2 && (
<Card style={{ background: token.colorBgContainer, border: '1px solid rgba(255,255,255,0.08)' }}>
<Title level={4} style={{ color: '#fff', marginBottom: 16 }}>Review Your Campaign</Title>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Title: </Text>
<Text style={{ color: 'rgba(255,255,255,0.7)' }}>{formValues.title}</Text>
</div>
{formValues.description && (
<div style={{ marginBottom: 16 }}>
<Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Description: </Text>
<Text style={{ color: 'rgba(255,255,255,0.7)' }}>{formValues.description}</Text>
</div>
)}
<div style={{ marginBottom: 16 }}>
<Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Government Levels: </Text>
<Text style={{ color: 'rgba(255,255,255,0.7)' }}>
{(formValues.targetGovernmentLevels || []).map((l: GovernmentLevel) => GOVERNMENT_LEVEL_LABELS[l]).join(', ')}
</Text>
</div>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Email Subject: </Text>
<Text style={{ color: 'rgba(255,255,255,0.7)' }}>{formValues.emailSubject}</Text>
</div>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ color: 'rgba(255,255,255,0.85)', display: 'block', marginBottom: 4 }}>Email Body:</Text>
<div style={{
padding: 12,
background: 'rgba(0,0,0,0.2)',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.06)',
whiteSpace: 'pre-wrap',
color: 'rgba(255,255,255,0.7)',
maxHeight: 200,
overflow: 'auto',
}}>
{formValues.emailBody}
</div>
</div>
{formValues.callToAction && (
<div style={{ marginBottom: 16 }}>
<Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Call to Action: </Text>
<Text style={{ color: 'rgba(255,255,255,0.7)' }}>{formValues.callToAction}</Text>
</div>
)}
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
<Button onClick={() => setStep(1)}>Back</Button>
<Button type="primary" loading={submitting} onClick={handleSubmit}>
Submit for Review
</Button>
</div>
</Card>
)}
</Form>
</div>
);
}

View File

@ -1,20 +1,14 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import {
Row,
Col,
Input,
Select,
Spin,
Empty,
Pagination,
theme,
Grid,
} from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import PublicVideoCard from '@/components/media/PublicVideoCard';
import ExpandedVideoCard from '@/components/media/ExpandedVideoCard';
import FeaturedPlaylistCarousel from '@/components/media/FeaturedPlaylistCarousel';
import { mediaPublicApi } from '@/lib/media-public-api';
import { useDebounce } from '@/hooks/useDebounce';
import { useParams, useSearchParams } from 'react-router-dom';
import { ExpandedVideoProvider, useExpandedVideo } from '@/contexts/ExpandedVideoContext';
@ -35,7 +29,7 @@ interface Video {
createdAt: string;
}
interface Pagination {
interface PaginationInfo {
total: number;
limit: number;
offset: number;
@ -43,30 +37,26 @@ interface Pagination {
}
function MediaGalleryContent() {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { category: urlCategory } = useParams<{ category?: string }>();
const [searchParams] = useSearchParams();
const { state: expandedState, expandVideo } = useExpandedVideo();
// Read search/sort from URL params (set by MediaBottomNav)
const search = searchParams.get('search') || '';
const sort = (searchParams.get('sort') as 'recent' | 'popular' | 'most_viewed') || 'recent';
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<Pagination>({
const [pagination, setPagination] = useState<PaginationInfo>({
total: 0,
limit: 24,
offset: 0,
hasMore: false,
});
// Filters
const [searchInput, setSearchInput] = useState('');
const [sort, setSort] = useState<'recent' | 'popular' | 'most_viewed'>('recent');
const [currentPage, setCurrentPage] = useState(1);
// Debounce search input
const debouncedSearch = useDebounce(searchInput, 300);
// Fetch videos
const fetchVideos = async () => {
try {
@ -83,8 +73,8 @@ function MediaGalleryContent() {
params.category = urlCategory;
}
if (debouncedSearch) {
params.search = debouncedSearch;
if (search) {
params.search = search;
}
const response = await mediaPublicApi.get('/public', { params });
@ -98,32 +88,30 @@ function MediaGalleryContent() {
}
};
// Fetch on filter changes
// Fetch on filter changes (search/sort come from URL params via MediaBottomNav)
useEffect(() => {
setCurrentPage(1);
}, [search, sort]);
useEffect(() => {
fetchVideos();
}, [urlCategory, debouncedSearch, sort, currentPage]);
}, [urlCategory, search, sort, currentPage]);
// Handle URL ?expanded=123 parameter on mount
// Handle URL ?expanded=123 parameter on initial load only
// (e.g., shared link or page refresh — not triggered by collapse)
const hasRestoredRef = useRef(false);
useEffect(() => {
if (hasRestoredRef.current) return;
const expandedId = searchParams.get('expanded');
if (expandedId && videos.length > 0 && !expandedState.videoId) {
if (expandedId && videos.length > 0) {
const videoId = parseInt(expandedId, 10);
const video = videos.find(v => v.id === videoId);
if (video) {
hasRestoredRef.current = true;
expandVideo(videoId, video);
}
}
}, [searchParams, videos, expandVideo, expandedState.videoId]);
const handleSortChange = (value: 'recent' | 'popular' | 'most_viewed') => {
setSort(value);
setCurrentPage(1); // Reset to first page
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchInput(e.target.value);
setCurrentPage(1); // Reset to first page
};
}, [videos]); // Only re-check when videos load
const handlePageChange = (page: number) => {
setCurrentPage(page);
@ -132,32 +120,8 @@ function MediaGalleryContent() {
return (
<div>
{/* Filters Bar */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={16} md={18}>
<Input
placeholder="Search videos by title..."
prefix={<SearchOutlined style={{ color: token.colorTextTertiary }} />}
value={searchInput}
onChange={handleSearchChange}
size="large"
allowClear
/>
</Col>
<Col xs={24} sm={8} md={6}>
<Select
value={sort}
onChange={handleSortChange}
size="large"
style={{ width: '100%' }}
options={[
{ value: 'recent', label: 'Most Recent' },
{ value: 'popular', label: 'Most Popular' },
{ value: 'most_viewed', label: 'Most Viewed' },
]}
/>
</Col>
</Row>
{/* Featured Playlists Carousel — only on main gallery page */}
{!urlCategory && !search && <FeaturedPlaylistCarousel />}
{/* Loading State */}
{loading && (
@ -170,8 +134,8 @@ function MediaGalleryContent() {
{!loading && videos.length === 0 && (
<Empty
description={
debouncedSearch
? `No videos found for "${debouncedSearch}"`
search
? `No videos found for "${search}"`
: 'No videos available'
}
style={{ padding: 60 }}

View File

@ -17,12 +17,15 @@ import {
LikeFilled,
EyeOutlined,
ArrowLeftOutlined,
PlusOutlined,
} from '@ant-design/icons';
import VideoPlayer from '@/components/media/VideoPlayer';
import ReactionButtons from '@/components/media/ReactionButtons';
import CommentSection from '@/components/media/CommentSection';
import PublicVideoCard from '@/components/media/PublicVideoCard';
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
const { Title, Text } = Typography;
@ -52,7 +55,9 @@ export default function MediaViewerPage() {
const [loading, setLoading] = useState(true);
const [upvoted, setUpvoted] = useState(false);
const [upvoting, setUpvoting] = useState(false);
const [addToPlaylistOpen, setAddToPlaylistOpen] = useState(false);
const currentTime = 0; // TODO: Track video playback time
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const videoId = parseInt(id || '0', 10);
@ -66,8 +71,7 @@ export default function MediaViewerPage() {
// Check if locked and user not authenticated
if (response.data.video.isLocked) {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
if (!useAuthStore.getState().isAuthenticated) {
Modal.confirm({
title: 'Login Required',
content: 'This video is locked. Please log in to watch.',
@ -255,6 +259,16 @@ export default function MediaViewerPage() {
{formatCount(video.upvoteCount)}
</Button>
{isAuthenticated && (
<Button
icon={<PlusOutlined />}
size="large"
onClick={() => setAddToPlaylistOpen(true)}
>
Playlist
</Button>
)}
<Tag color="purple" style={{ fontSize: 14, padding: '4px 12px' }}>
{video.category}
</Tag>
@ -299,6 +313,13 @@ export default function MediaViewerPage() {
<div>
<CommentSection videoId={videoId} />
</div>
{/* Add to Playlist Modal */}
<AddToPlaylistModal
videoId={videoId}
open={addToPlaylistOpen}
onClose={() => setAddToPlaylistOpen(false)}
/>
</div>
);
}

View File

@ -0,0 +1,152 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
Typography,
Card,
Tag,
Button,
Empty,
Spin,
Alert,
Space,
Grid,
theme,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { Campaign } from '@/types/api';
import {
CAMPAIGN_MODERATION_STATUS_COLORS,
CAMPAIGN_MODERATION_STATUS_LABELS,
GOVERNMENT_LEVEL_LABELS,
GOVERNMENT_LEVEL_COLORS,
} from '@/types/api';
import type { GovernmentLevel } from '@/types/api';
const { Title, Text, Paragraph } = Typography;
export default function MyCampaignsPage() {
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCampaigns();
}, []);
const fetchCampaigns = async () => {
setLoading(true);
try {
const { data } = await api.get<Campaign[]>('/campaigns/user/my-campaigns');
setCampaigns(data);
} catch {
// handled below
} finally {
setLoading(false);
}
};
if (loading) {
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>;
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
<Title level={3} style={{ color: '#fff', margin: 0 }}>My Campaigns</Title>
<Link to="/campaigns/create">
<Button type="primary" icon={<PlusOutlined />}>Create Campaign</Button>
</Link>
</div>
{campaigns.length === 0 ? (
<Empty
description={<Text style={{ color: 'rgba(255,255,255,0.45)' }}>You haven't created any campaigns yet</Text>}
style={{ padding: 60 }}
>
<Link to="/campaigns/create">
<Button type="primary" icon={<PlusOutlined />}>Create Your First Campaign</Button>
</Link>
</Empty>
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{campaigns.map((campaign) => {
const canEdit = campaign.moderationStatus === 'CHANGES_REQUESTED' || campaign.moderationStatus === 'PENDING_REVIEW';
const isActive = campaign.status === 'ACTIVE' && campaign.moderationStatus === 'APPROVED';
return (
<Card
key={campaign.id}
style={{
background: token.colorBgContainer,
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
}}
>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, justifyContent: 'space-between' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<Text strong style={{ color: '#fff', fontSize: 16 }}>{campaign.title}</Text>
{campaign.moderationStatus && (
<Tag color={CAMPAIGN_MODERATION_STATUS_COLORS[campaign.moderationStatus]}>
{CAMPAIGN_MODERATION_STATUS_LABELS[campaign.moderationStatus]}
</Tag>
)}
</div>
{campaign.description && (
<Paragraph style={{ color: 'rgba(255,255,255,0.55)', margin: '0 0 8px' }} ellipsis={{ rows: 2 }}>
{campaign.description}
</Paragraph>
)}
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 8 }}>
{campaign.targetGovernmentLevels.map((l: GovernmentLevel) => (
<Tag key={l} color={GOVERNMENT_LEVEL_COLORS[l]} style={{ fontSize: 11 }}>
{GOVERNMENT_LEVEL_LABELS[l]}
</Tag>
))}
</div>
<Text style={{ color: 'rgba(255,255,255,0.35)', fontSize: 12 }}>
Created {new Date(campaign.createdAt).toLocaleDateString()}
</Text>
{(campaign.moderationStatus === 'REJECTED' || campaign.moderationStatus === 'CHANGES_REQUESTED') && campaign.rejectionReason && (
<Alert
type={campaign.moderationStatus === 'REJECTED' ? 'error' : 'warning'}
message={campaign.moderationStatus === 'REJECTED' ? 'Rejection Reason' : 'Changes Requested'}
description={campaign.rejectionReason}
style={{ marginTop: 12 }}
showIcon
/>
)}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-start', flexDirection: isMobile ? 'row' : 'column' }}>
{canEdit && (
<Link to={`/campaigns/create?edit=${campaign.id}`}>
<Button icon={<EditOutlined />} block={isMobile}>Edit</Button>
</Link>
)}
{isActive && (
<Link to={`/campaign/${campaign.slug}`}>
<Button type="primary" icon={<EyeOutlined />} block={isMobile}>View</Button>
</Link>
)}
</div>
</div>
</Card>
);
})}
</Space>
)}
</div>
);
}

View File

@ -0,0 +1,394 @@
import { useState, useEffect, useCallback } from 'react';
import { Typography, Card, Input, Button, Switch, Spin, Grid, theme, App, Divider } from 'antd';
import {
UserOutlined,
LockOutlined,
EyeOutlined,
SaveOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
interface PrivacySettings {
showOnlineStatus: boolean | null;
showCurrentlyWatching: boolean | null;
showInFriendActivity: boolean | null;
anonymizePublicComments: boolean | null;
hidePublicReactions: boolean | null;
hidePublicFinishes: boolean | null;
allowFriendRequests: boolean | null;
closeFriendsOnlyWatching: boolean | null;
}
interface ProfileData {
name: string | null;
email: string;
}
const privacyToggles: {
key: keyof PrivacySettings;
label: string;
description: string;
}[] = [
{
key: 'showOnlineStatus',
label: 'Show Online Status',
description: 'Let others see when you are online',
},
{
key: 'showCurrentlyWatching',
label: 'Show Currently Watching',
description: 'Display what video you are watching to friends',
},
{
key: 'showInFriendActivity',
label: 'Show in Friend Activity',
description: 'Appear in the friend activity feed',
},
{
key: 'anonymizePublicComments',
label: 'Anonymize Public Comments',
description: 'Hide your name on public comments',
},
{
key: 'hidePublicReactions',
label: 'Hide Public Reactions',
description: 'Do not show your reactions publicly',
},
{
key: 'hidePublicFinishes',
label: 'Hide Public Finishes',
description: 'Hide your video completion status from others',
},
{
key: 'allowFriendRequests',
label: 'Allow Friend Requests',
description: 'Let other users send you friend requests',
},
{
key: 'closeFriendsOnlyWatching',
label: 'Close Friends Only Watching',
description: 'Only close friends can see what you watch',
},
];
export default function MySettingsPage() {
const { token } = theme.useToken();
const { message } = App.useApp();
const screens = useBreakpoint();
const isMobile = !screens.md;
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<ProfileData>({ name: null, email: '' });
const [editName, setEditName] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [privacy, setPrivacy] = useState<PrivacySettings | null>(null);
// Password fields
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [changingPassword, setChangingPassword] = useState(false);
const fetchSettings = useCallback(async () => {
try {
const { data } = await mediaApi.get('/media/me/settings');
setProfile(data.profile);
setEditName(data.profile.name || '');
setPrivacy(data.privacy);
} catch {
// Silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const handleSaveProfile = async () => {
setSavingProfile(true);
try {
const { data } = await mediaApi.put('/media/me/profile', {
name: editName.trim() || null,
});
setProfile(data.profile);
message.success('Profile updated');
} catch {
message.error('Failed to update profile');
} finally {
setSavingProfile(false);
}
};
const handleTogglePrivacy = async (key: keyof PrivacySettings, checked: boolean) => {
// Optimistic update
setPrivacy((prev) => (prev ? { ...prev, [key]: checked } : prev));
try {
await mediaApi.put('/media/me/settings', { [key]: checked });
} catch {
// Revert on failure
setPrivacy((prev) => (prev ? { ...prev, [key]: !checked } : prev));
message.error('Failed to update setting');
}
};
const handleChangePassword = async () => {
if (!currentPassword || !newPassword) {
message.warning('Please fill in all password fields');
return;
}
if (newPassword !== confirmPassword) {
message.warning('New passwords do not match');
return;
}
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{12,}$/.test(newPassword)) {
message.warning('Password must be 12+ characters with uppercase, lowercase, and a digit');
return;
}
setChangingPassword(true);
try {
await mediaApi.put('/media/me/password', {
currentPassword,
newPassword,
});
message.success('Password changed successfully');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: any) {
const msg = err?.response?.data?.message || 'Failed to change password';
message.error(msg);
} finally {
setChangingPassword(false);
}
};
// Password strength indicator
const getPasswordStrength = (pw: string): { label: string; color: string; pct: number } => {
if (!pw) return { label: '', color: '', pct: 0 };
let score = 0;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[a-z]/.test(pw)) score++;
if (/\d/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
if (score <= 2) return { label: 'Weak', color: '#ef4444', pct: 25 };
if (score <= 4) return { label: 'Fair', color: '#f59e0b', pct: 50 };
if (score <= 5) return { label: 'Good', color: '#22c55e', pct: 75 };
return { label: 'Strong', color: '#10b981', pct: 100 };
};
const strength = getPasswordStrength(newPassword);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 100 }}>
<Spin size="large" />
</div>
);
}
const cardStyle = {
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
marginBottom: 24,
};
return (
<div style={{ maxWidth: 640, margin: '0 auto', padding: isMobile ? '16px 8px' : '24px 16px' }}>
<Title level={3} style={{ margin: '0 0 24px', color: token.colorText }}>
Settings
</Title>
{/* Profile Section */}
<Card
size="small"
title={
<Text strong style={{ color: token.colorText }}>
<UserOutlined style={{ marginRight: 8 }} />
Profile
</Text>
}
style={cardStyle}
styles={{ body: { padding: 16 } }}
>
<div style={{ marginBottom: 16 }}>
<Text style={{ display: 'block', marginBottom: 6, fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>
Name
</Text>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Your display name"
maxLength={100}
style={{ maxWidth: 400 }}
/>
</div>
<div style={{ marginBottom: 16 }}>
<Text style={{ display: 'block', marginBottom: 6, fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>
Email
</Text>
<Text style={{ color: 'rgba(255,255,255,0.65)' }}>{profile.email}</Text>
</div>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveProfile}
loading={savingProfile}
disabled={editName.trim() === (profile.name || '')}
size="small"
>
Save Profile
</Button>
</Card>
{/* Password Section */}
<Card
size="small"
title={
<Text strong style={{ color: token.colorText }}>
<LockOutlined style={{ marginRight: 8 }} />
Change Password
</Text>
}
style={cardStyle}
styles={{ body: { padding: 16 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 400 }}>
<div>
<Text style={{ display: 'block', marginBottom: 6, fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>
Current Password
</Text>
<Input.Password
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
/>
</div>
<div>
<Text style={{ display: 'block', marginBottom: 6, fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>
New Password
</Text>
<Input.Password
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
/>
{newPassword && (
<div style={{ marginTop: 6 }}>
<div
style={{
height: 4,
borderRadius: 2,
background: 'rgba(255,255,255,0.1)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${strength.pct}%`,
height: '100%',
background: strength.color,
borderRadius: 2,
transition: 'width 0.3s ease',
}}
/>
</div>
<Text style={{ fontSize: 11, color: strength.color }}>{strength.label}</Text>
</div>
)}
</div>
<div>
<Text style={{ display: 'block', marginBottom: 6, fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>
Confirm New Password
</Text>
<Input.Password
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
status={confirmPassword && confirmPassword !== newPassword ? 'error' : undefined}
/>
</div>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)' }}>
12+ characters, uppercase, lowercase, digit required
</Text>
<Button
type="primary"
icon={<LockOutlined />}
onClick={handleChangePassword}
loading={changingPassword}
disabled={!currentPassword || !newPassword || !confirmPassword}
size="small"
style={{ alignSelf: 'flex-start' }}
>
Change Password
</Button>
</div>
</Card>
{/* Privacy Section */}
<Card
size="small"
title={
<Text strong style={{ color: token.colorText }}>
<EyeOutlined style={{ marginRight: 8 }} />
Privacy
</Text>
}
style={cardStyle}
styles={{ body: { padding: '8px 16px' } }}
>
{privacy &&
privacyToggles.map((toggle, i) => (
<div key={toggle.key}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 0',
}}
>
<div style={{ flex: 1, minWidth: 0, marginRight: 16 }}>
<Text
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
color: token.colorText,
}}
>
{toggle.label}
</Text>
<Text
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.4)',
}}
>
{toggle.description}
</Text>
</div>
<Switch
checked={privacy[toggle.key] ?? false}
onChange={(checked) => handleTogglePrivacy(toggle.key, checked)}
size="small"
/>
</div>
{i < privacyToggles.length - 1 && (
<Divider style={{ margin: 0, borderColor: 'rgba(255,255,255,0.06)' }} />
)}
</div>
))}
</Card>
</div>
);
}

View File

@ -0,0 +1,471 @@
import { useState, useEffect, useCallback } from 'react';
import { Typography, Card, Row, Col, Spin, Button, Grid, theme, App } from 'antd';
import {
ClockCircleOutlined,
PlayCircleOutlined,
MessageOutlined,
HeartOutlined,
TrophyOutlined,
FireOutlined,
ReloadOutlined,
HistoryOutlined,
MoonOutlined,
SunOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
interface UserStatsData {
totalWatchTimeSeconds: number | null;
totalVideosWatched: number | null;
totalCommentsMade: number | null;
totalUpvotesGiven: number | null;
totalFinishes: number | null;
currentDayStreak: number | null;
longestDayStreak: number | null;
longestSingleSession: number | null;
nightOwlCount: number | null;
earlyBirdCount: number | null;
}
interface DailyActivity {
activityDate: string;
watchTimeSeconds: number | null;
videosWatched: number | null;
}
interface WatchHistoryItem {
id: number;
watchTimeSeconds: number;
completed: boolean;
createdAt: string;
video: {
id: number;
title: string | null;
filename: string;
thumbnailPath: string | null;
durationSeconds: number | null;
};
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export default function MyStatsPage() {
const { token } = theme.useToken();
const { message } = App.useApp();
const screens = useBreakpoint();
const isMobile = !screens.md;
const [loading, setLoading] = useState(true);
const [recalculating, setRecalculating] = useState(false);
const [stats, setStats] = useState<UserStatsData | null>(null);
const [dailyActivity, setDailyActivity] = useState<DailyActivity[]>([]);
const [achievementCount, setAchievementCount] = useState(0);
const [history, setHistory] = useState<WatchHistoryItem[]>([]);
const [historyTotal, setHistoryTotal] = useState(0);
const [historyLoading, setHistoryLoading] = useState(false);
const fetchStats = useCallback(async () => {
try {
const { data } = await mediaApi.get('/media/me/stats');
setStats(data.stats);
setDailyActivity(data.dailyActivity || []);
setAchievementCount(data.achievementCount || 0);
} catch {
// Stats may not exist yet
}
}, []);
const fetchHistory = useCallback(async (offset = 0) => {
try {
setHistoryLoading(true);
const { data } = await mediaApi.get('/media/me/watch-history', {
params: { limit: '10', offset: String(offset) },
});
if (offset === 0) {
setHistory(data.views);
} else {
setHistory((prev) => [...prev, ...data.views]);
}
setHistoryTotal(data.total);
} catch {
// Silent
} finally {
setHistoryLoading(false);
}
}, []);
useEffect(() => {
Promise.all([fetchStats(), fetchHistory()]).finally(() => setLoading(false));
}, [fetchStats, fetchHistory]);
const handleRecalculate = async () => {
setRecalculating(true);
try {
const { data } = await mediaApi.post('/media/me/stats/recalculate');
setStats(data.stats);
message.success('Stats recalculated');
} catch {
message.error('Failed to recalculate stats');
} finally {
setRecalculating(false);
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 100 }}>
<Spin size="large" />
</div>
);
}
const s = stats || ({} as Partial<UserStatsData>);
const maxDailyWatch = Math.max(
...dailyActivity.map((d) => d.watchTimeSeconds || 0),
1
);
const statCards = [
{
icon: <ClockCircleOutlined />,
label: 'Watch Time',
value: formatDuration(s.totalWatchTimeSeconds || 0),
},
{
icon: <PlayCircleOutlined />,
label: 'Videos Watched',
value: String(s.totalVideosWatched || 0),
},
{
icon: <MessageOutlined />,
label: 'Comments',
value: String(s.totalCommentsMade || 0),
},
{
icon: <HeartOutlined />,
label: 'Reactions',
value: String(s.totalUpvotesGiven || 0),
},
{
icon: <TrophyOutlined />,
label: 'Completed',
value: String(s.totalFinishes || 0),
},
{
icon: <FireOutlined />,
label: 'Streak',
value: `${s.currentDayStreak || 0}d / ${s.longestDayStreak || 0}d`,
},
];
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: isMobile ? '16px 8px' : '24px 16px' }}>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
flexWrap: 'wrap',
gap: 12,
}}
>
<Title level={3} style={{ margin: 0, color: token.colorText }}>
My Stats
</Title>
<Button
icon={<ReloadOutlined />}
onClick={handleRecalculate}
loading={recalculating}
size="small"
>
Recalculate
</Button>
</div>
{/* Stat Cards */}
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
{statCards.map((card) => (
<Col xs={12} sm={8} key={card.label}>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
textAlign: 'center',
}}
styles={{ body: { padding: '16px 12px' } }}
>
<div style={{ fontSize: 24, color: token.colorPrimary, marginBottom: 4 }}>
{card.icon}
</div>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: token.colorText,
lineHeight: 1.2,
}}
>
{card.value}
</div>
<Text
type="secondary"
style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)' }}
>
{card.label}
</Text>
</Card>
</Col>
))}
</Row>
{/* Activity Chart (CSS bar chart) */}
<Card
size="small"
title={
<Text strong style={{ color: token.colorText }}>
<HistoryOutlined style={{ marginRight: 8 }} />
Last 30 Days Activity
</Text>
}
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
marginBottom: 24,
}}
styles={{ body: { padding: '12px 16px' } }}
>
{dailyActivity.length === 0 ? (
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.4)' }}>
No activity recorded yet. Start watching to see your chart!
</Text>
) : (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 100 }}>
{dailyActivity.map((day) => {
const pct = ((day.watchTimeSeconds || 0) / maxDailyWatch) * 100;
return (
<div
key={day.activityDate}
title={`${day.activityDate}: ${formatDuration(day.watchTimeSeconds || 0)}`}
style={{
flex: 1,
minWidth: 4,
maxWidth: 20,
height: `${Math.max(pct, 4)}%`,
background: token.colorPrimary,
borderRadius: '3px 3px 0 0',
opacity: pct > 0 ? 0.5 + (pct / 200) : 0.15,
transition: 'height 0.3s ease',
}}
/>
);
})}
</div>
)}
</Card>
{/* Habits Section */}
<Card
size="small"
title={
<Text strong style={{ color: token.colorText }}>
<ThunderboltOutlined style={{ marginRight: 8 }} />
Habits
</Text>
}
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
marginBottom: 24,
}}
styles={{ body: { padding: '16px' } }}
>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<div style={{ textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 16, marginBottom: 8 }}>
<div>
<MoonOutlined
style={{
fontSize: 20,
color:
(s.nightOwlCount || 0) >= (s.earlyBirdCount || 0)
? token.colorPrimary
: 'rgba(255,255,255,0.3)',
}}
/>
<div style={{ fontSize: 18, fontWeight: 700, color: token.colorText }}>
{s.nightOwlCount || 0}
</div>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>Night Owl</Text>
</div>
<div>
<SunOutlined
style={{
fontSize: 20,
color:
(s.earlyBirdCount || 0) > (s.nightOwlCount || 0)
? '#f59e0b'
: 'rgba(255,255,255,0.3)',
}}
/>
<div style={{ fontSize: 18, fontWeight: 700, color: token.colorText }}>
{s.earlyBirdCount || 0}
</div>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>Early Bird</Text>
</div>
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ textAlign: 'center' }}>
<ClockCircleOutlined style={{ fontSize: 20, color: token.colorPrimary }} />
<div style={{ fontSize: 18, fontWeight: 700, color: token.colorText }}>
{formatDuration(s.longestSingleSession || 0)}
</div>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>
Longest Session
</Text>
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{ textAlign: 'center' }}>
<TrophyOutlined style={{ fontSize: 20, color: '#f59e0b' }} />
<div style={{ fontSize: 18, fontWeight: 700, color: token.colorText }}>
{achievementCount}
</div>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>Achievements</Text>
</div>
</Col>
</Row>
</Card>
{/* Watch History */}
<Card
size="small"
title={
<Text strong style={{ color: token.colorText }}>
<PlayCircleOutlined style={{ marginRight: 8 }} />
Watch History ({historyTotal})
</Text>
}
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
}}
styles={{ body: { padding: 0 } }}
>
{history.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center' }}>
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.4)' }}>
No watch history yet
</Text>
</div>
) : (
<>
{history.map((item) => (
<div
key={item.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}
>
{/* Thumbnail placeholder */}
<div
style={{
width: 56,
height: 36,
borderRadius: 6,
background: 'rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<PlayCircleOutlined
style={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }}
/>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<Text
ellipsis
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
color: token.colorText,
}}
>
{item.video.title || item.video.filename}
</Text>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>
{formatDuration(item.watchTimeSeconds)}
{item.video.durationSeconds
? ` / ${formatDuration(item.video.durationSeconds)}`
: ''}
{item.completed && (
<TrophyOutlined
style={{ marginLeft: 6, color: '#f59e0b', fontSize: 11 }}
/>
)}
</Text>
</div>
{/* Date */}
<Text
style={{
fontSize: 11,
color: 'rgba(255,255,255,0.35)',
flexShrink: 0,
}}
>
{formatDate(item.createdAt)}
</Text>
</div>
))}
{history.length < historyTotal && (
<div style={{ padding: 12, textAlign: 'center' }}>
<Button
type="link"
onClick={() => fetchHistory(history.length)}
loading={historyLoading}
>
Load more
</Button>
</div>
)}
</>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,355 @@
import { useState, useEffect, useCallback } from 'react';
import { Typography, Spin, Empty, Button, Grid, Input, Popconfirm, message } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ShareAltOutlined, LockOutlined, GlobalOutlined, SearchOutlined } from '@ant-design/icons';
import FeaturedPlaylistCarousel from '@/components/media/FeaturedPlaylistCarousel';
import PlaylistCard from '@/components/media/PlaylistCard';
import CreatePlaylistModal from '@/components/media/CreatePlaylistModal';
import EditPlaylistModal from '@/components/media/EditPlaylistModal';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
import { useAuthStore } from '@/stores/auth.store';
import { useDebounce } from '@/hooks/useDebounce';
import type { PlaylistSummary } from '@/types/media';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
export default function PlaylistBrowsePage() {
const screens = useBreakpoint();
const isMobile = !screens.md;
const user = useAuthStore((s) => s.user);
const [popularPlaylists, setPopularPlaylists] = useState<PlaylistSummary[]>([]);
const [myPlaylists, setMyPlaylists] = useState<PlaylistSummary[]>([]);
const [loadingPopular, setLoadingPopular] = useState(true);
const [loadingMy, setLoadingMy] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editPlaylistId, setEditPlaylistId] = useState<number | null>(null);
// Search state
const [searchInput, setSearchInput] = useState('');
const debouncedSearch = useDebounce(searchInput, 300);
const [searchResults, setSearchResults] = useState<PlaylistSummary[]>([]);
const [searchTotal, setSearchTotal] = useState(0);
const [searchLoading, setSearchLoading] = useState(false);
const [searchPage, setSearchPage] = useState(1);
const searchPageSize = 24;
const isSearching = debouncedSearch.trim().length > 0;
const fetchPopular = useCallback(async () => {
try {
setLoadingPopular(true);
const { data } = await mediaPublicApi.get('/playlists/popular', {
params: { limit: 12 },
});
setPopularPlaylists(data.data || []);
} catch {
// Silent
} finally {
setLoadingPopular(false);
}
}, []);
const fetchMyPlaylists = useCallback(async () => {
if (!user) return;
try {
setLoadingMy(true);
const { data } = await mediaApi.get('/playlists/my');
setMyPlaylists(data.data || []);
} catch {
// Silent
} finally {
setLoadingMy(false);
}
}, [user]);
// Search effect
useEffect(() => {
if (!isSearching) {
setSearchResults([]);
setSearchTotal(0);
return;
}
const doSearch = async () => {
try {
setSearchLoading(true);
const { data } = await mediaPublicApi.get('/playlists/popular', {
params: {
search: debouncedSearch,
limit: searchPageSize,
offset: (searchPage - 1) * searchPageSize,
},
});
setSearchResults(data.data || []);
setSearchTotal(data.total || 0);
} catch {
// Silent
} finally {
setSearchLoading(false);
}
};
doSearch();
}, [debouncedSearch, searchPage, isSearching]);
// Reset search page when query changes
useEffect(() => {
setSearchPage(1);
}, [debouncedSearch]);
useEffect(() => {
fetchPopular();
}, [fetchPopular]);
useEffect(() => {
fetchMyPlaylists();
}, [fetchMyPlaylists]);
const handleDelete = async (playlistId: number) => {
try {
await mediaApi.delete(`/playlists/${playlistId}`);
message.success('Playlist deleted');
fetchMyPlaylists();
fetchPopular();
} catch {
message.error('Failed to delete playlist');
}
};
const handleShare = async (playlist: PlaylistSummary) => {
try {
let shareToken = playlist.shareToken;
if (!shareToken) {
const { data } = await mediaApi.post(`/playlists/${playlist.id}/share`);
shareToken = data.shareToken;
}
const url = `${window.location.origin}/gallery/curated/share/${shareToken}`;
await navigator.clipboard.writeText(url);
message.success('Share link copied to clipboard');
} catch {
message.error('Failed to generate share link');
}
};
const gridStyle = {
display: 'grid',
gridTemplateColumns: isMobile
? '1fr'
: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 16,
};
const totalSearchPages = Math.ceil(searchTotal / searchPageSize);
return (
<div>
{/* Search bar */}
<div style={{ marginBottom: 24 }}>
<Input
placeholder="Search playlists..."
prefix={<SearchOutlined />}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
allowClear
size="large"
style={{ maxWidth: 500 }}
/>
</div>
{isSearching ? (
/* Search results mode */
<div style={{ marginBottom: 32 }}>
<Title level={5} style={{ marginBottom: 16 }}>
{searchLoading
? 'Searching...'
: `${searchTotal} result${searchTotal !== 1 ? 's' : ''} for "${debouncedSearch}"`}
</Title>
{searchLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : searchResults.length === 0 ? (
<Empty description="No playlists found" />
) : (
<>
<div style={gridStyle}>
{searchResults.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</div>
{/* Pagination */}
{totalSearchPages > 1 && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button
disabled={searchPage <= 1}
onClick={() => setSearchPage((p) => p - 1)}
style={{ marginRight: 8 }}
>
Previous
</Button>
<Text type="secondary">
Page {searchPage} of {totalSearchPages}
</Text>
<Button
disabled={searchPage >= totalSearchPages}
onClick={() => setSearchPage((p) => p + 1)}
style={{ marginLeft: 8 }}
>
Next
</Button>
</div>
)}
</>
)}
</div>
) : (
/* Normal browse mode */
<>
{/* Featured Playlists */}
<FeaturedPlaylistCarousel />
{/* Popular Playlists */}
<div style={{ marginBottom: 32 }}>
<Title level={5} style={{ marginBottom: 16 }}>
Popular Playlists
</Title>
{loadingPopular ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : popularPlaylists.length === 0 ? (
<Empty description="No public playlists yet" />
) : (
<div style={gridStyle}>
{popularPlaylists.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</div>
)}
</div>
{/* My Playlists (authenticated only) */}
{user && (
<div style={{ marginBottom: 32 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Title level={5} style={{ margin: 0 }}>
My Playlists
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
>
Create Playlist
</Button>
</div>
{loadingMy ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : myPlaylists.length === 0 ? (
<Empty description="You haven't created any playlists yet">
<Button type="primary" onClick={() => setCreateOpen(true)}>
Create Your First Playlist
</Button>
</Empty>
) : (
<div style={gridStyle}>
{myPlaylists.map((p) => (
<div key={p.id} style={{ position: 'relative' }}>
<PlaylistCard playlist={p} />
{/* Action overlay */}
<div
style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}
>
<Button
size="small"
type="primary"
ghost
icon={p.isPublic ? <GlobalOutlined /> : <LockOutlined />}
style={{ fontSize: 11, padding: '0 6px', height: 24 }}
title={p.isPublic ? 'Public' : 'Private'}
/>
<Button
size="small"
icon={<ShareAltOutlined />}
onClick={(e) => {
e.stopPropagation();
handleShare(p);
}}
style={{ padding: '0 6px', height: 24 }}
/>
<Button
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
setEditPlaylistId(p.id);
}}
style={{ padding: '0 6px', height: 24 }}
/>
<Popconfirm
title="Delete this playlist?"
onConfirm={() => handleDelete(p.id)}
okText="Delete"
okType="danger"
>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
style={{ padding: '0 6px', height: 24 }}
/>
</Popconfirm>
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
{/* Modals */}
<CreatePlaylistModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => {
fetchMyPlaylists();
fetchPopular();
}}
/>
<EditPlaylistModal
playlistId={editPlaylistId}
open={!!editPlaylistId}
onClose={() => setEditPlaylistId(null)}
onUpdated={() => {
fetchMyPlaylists();
fetchPopular();
}}
/>
</div>
);
}

View File

@ -0,0 +1,381 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
useParams,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import {
Typography,
Button,
Space,
Spin,
Divider,
Grid,
message,
theme,
} from 'antd';
import {
ArrowLeftOutlined,
ShareAltOutlined,
EyeOutlined,
} from '@ant-design/icons';
import VideoPlayer, { VideoPlayerRef } from '@/components/media/VideoPlayer';
import PlaylistSidebarPanel from '@/components/media/PlaylistSidebarPanel';
import ReactionButtons from '@/components/media/ReactionButtons';
import CommentSection from '@/components/media/CommentSection';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
import type { PlaylistDetail } from '@/types/media';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
export default function PlaylistViewerPage() {
const { playlistId, token: shareToken } = useParams<{
playlistId?: string;
token?: string;
}>();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { token: themeToken } = theme.useToken();
const playerRef = useRef<VideoPlayerRef>(null);
const [playlist, setPlaylist] = useState<PlaylistDetail | null>(null);
const [loading, setLoading] = useState(true);
const [currentVideoId, setCurrentVideoId] = useState<number | null>(null);
// Determine current video from URL or default to first
const videoParam = searchParams.get('v');
// Fetch playlist
useEffect(() => {
const fetchPlaylist = async () => {
try {
setLoading(true);
let data: PlaylistDetail;
if (shareToken) {
const res = await mediaPublicApi.get(`/playlists/share/${shareToken}`);
data = res.data;
} else if (playlistId) {
const res = await mediaPublicApi.get(`/playlists/${playlistId}`);
data = res.data;
} else {
navigate('/gallery/curated');
return;
}
setPlaylist(data);
// Set initial video
const requestedVideoId = videoParam ? parseInt(videoParam) : null;
if (
requestedVideoId &&
data.videos?.some((v: any) => v.mediaId === requestedVideoId)
) {
setCurrentVideoId(requestedVideoId);
} else if (data.videos?.length > 0) {
setCurrentVideoId(data.videos[0]!.mediaId);
}
} catch (error: any) {
if (error.response?.status === 404) {
message.error('Playlist not found');
navigate('/gallery/curated');
} else {
message.error('Failed to load playlist');
}
} finally {
setLoading(false);
}
};
fetchPlaylist();
}, [playlistId, shareToken]);
// Record playlist view on mount
useEffect(() => {
if (!playlist) return;
const recordView = async () => {
try {
await mediaPublicApi.post(`/playlists/${playlist.id}/view`);
} catch {
// Silent fail
}
};
recordView();
}, [playlist?.id]);
// Listen for video ended event to auto-advance
useEffect(() => {
if (!playerRef.current || !playlist || !currentVideoId) return;
const videoElement = playerRef.current.getVideoElement();
if (!videoElement) return;
const handleEnded = () => {
const currentIndex = playlist.videos.findIndex(
(v) => v.mediaId === currentVideoId
);
if (currentIndex >= 0 && currentIndex < playlist.videos.length - 1) {
const nextVideo = playlist.videos[currentIndex + 1]!;
handleVideoSelect(nextVideo.mediaId);
}
};
videoElement.addEventListener('ended', handleEnded);
return () => videoElement.removeEventListener('ended', handleEnded);
}, [currentVideoId, playlist?.videos]);
const handleVideoSelect = useCallback(
(mediaId: number) => {
setCurrentVideoId(mediaId);
setSearchParams({ v: mediaId.toString() }, { replace: true });
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams]
);
const handleShare = async () => {
if (!playlist) return;
try {
let token = playlist.shareToken;
if (!token && playlist.isOwner) {
const { data } = await mediaApi.post(`/playlists/${playlist.id}/share`);
token = data.shareToken;
setPlaylist((prev) => (prev ? { ...prev, shareToken: token } : prev));
}
const url = token
? `${window.location.origin}/gallery/curated/share/${token}`
: `${window.location.origin}/gallery/curated/${playlist.id}`;
await navigator.clipboard.writeText(url);
message.success('Link copied to clipboard');
} catch {
message.error('Failed to copy link');
}
};
// Get current video info
const currentVideo = playlist?.videos.find(
(v) => v.mediaId === currentVideoId
);
const currentTitle = currentVideo
? currentVideo.video.title ||
currentVideo.video.filename.replace(/\.[^/.]+$/, '')
: '';
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
if (!playlist) return null;
return (
<div
style={{
minHeight: '100vh',
background: themeToken.colorBgLayout,
color: themeToken.colorText,
}}
>
{/* Top bar */}
<div
style={{
padding: isMobile ? '8px 12px' : '12px 24px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<Button
icon={<ArrowLeftOutlined />}
type="text"
onClick={() => navigate('/gallery/curated')}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong ellipsis style={{ display: 'block', fontSize: 15 }}>
{playlist.name}
</Text>
{playlist.description && (
<Text
type="secondary"
ellipsis
style={{ display: 'block', fontSize: 12, lineHeight: '1.3' }}
>
{playlist.description}
</Text>
)}
</div>
<Button icon={<ShareAltOutlined />} type="text" onClick={handleShare} />
</div>
{/* Main content */}
<div
style={{
display: isMobile ? 'block' : 'flex',
height: isMobile ? 'auto' : 'calc(100vh - 49px)',
}}
>
{/* Video area */}
<div
style={{
flex: 1,
overflow: isMobile ? 'visible' : 'auto',
padding: isMobile ? 12 : 24,
}}
>
{/* Player */}
{currentVideoId && (
<div style={{ marginBottom: 16 }}>
<VideoPlayer
key={currentVideoId}
ref={playerRef}
videoId={currentVideoId}
autoplay
width="100%"
/>
</div>
)}
{/* Video title + meta */}
<Title level={4} style={{ marginBottom: 8 }}>
{currentTitle}
</Title>
<Space size={16} wrap style={{ marginBottom: 16 }}>
{currentVideo && (
<Text type="secondary">
<EyeOutlined style={{ marginRight: 4 }} />
{currentVideo.video.viewCount} views
</Text>
)}
<Text type="secondary">
Playing {(playlist.videos.findIndex((v) => v.mediaId === currentVideoId) ?? 0) + 1} of{' '}
{playlist.videos.length}
</Text>
</Space>
{/* Mobile: Horizontal video strip */}
{isMobile && playlist.videos.length > 1 && (
<div
style={{
display: 'flex',
gap: 8,
overflowX: 'auto',
marginBottom: 16,
paddingBottom: 8,
scrollbarWidth: 'none',
}}
>
{playlist.videos.map((item) => {
const title =
item.video.title ||
item.video.filename.replace(/\.[^/.]+$/, '');
const isCurrent = item.mediaId === currentVideoId;
return (
<div
key={item.id}
onClick={() => handleVideoSelect(item.mediaId)}
style={{
minWidth: 140,
maxWidth: 140,
cursor: 'pointer',
opacity: isCurrent ? 1 : 0.7,
borderBottom: isCurrent
? `2px solid ${themeToken.colorPrimary}`
: '2px solid transparent',
paddingBottom: 4,
flexShrink: 0,
}}
>
<div
style={{
width: '100%',
paddingTop: '56.25%',
position: 'relative',
borderRadius: 4,
overflow: 'hidden',
background: '#000',
marginBottom: 4,
}}
>
{item.video.thumbnailUrl && (
<img
src={`/media${item.video.thumbnailUrl}`}
alt=""
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
</div>
<Text
ellipsis
style={{
fontSize: 11,
fontWeight: isCurrent ? 600 : 400,
display: 'block',
}}
>
{title}
</Text>
</div>
);
})}
</div>
)}
{/* Reactions */}
{currentVideoId && (
<div style={{ marginBottom: 16 }}>
<ReactionButtons videoId={currentVideoId} currentTime={0} />
</div>
)}
<Divider />
{/* Comments */}
{currentVideoId && <CommentSection videoId={currentVideoId} />}
</div>
{/* Desktop: Playlist sidebar */}
{!isMobile && (
<div
style={{
width: 360,
flexShrink: 0,
height: '100%',
overflow: 'hidden',
}}
>
<PlaylistSidebarPanel
playlistName={playlist.name}
description={playlist.description}
videos={playlist.videos}
currentVideoId={currentVideoId}
onVideoSelect={handleVideoSelect}
/>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -10,16 +10,20 @@ interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
errorCode: string | null;
registrationMessage: string | null;
}
interface AuthActions {
login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<{ requiresVerification?: boolean }>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
fetchMe: () => Promise<void>;
hydrate: () => Promise<void>;
setTokens: (accessToken: string, refreshToken: string) => void;
clearAuth: () => void;
clearError: () => void;
}
export const useAuthStore = create<AuthState & AuthActions>()(
@ -31,9 +35,11 @@ export const useAuthStore = create<AuthState & AuthActions>()(
isAuthenticated: false,
isLoading: true,
error: null,
errorCode: null,
registrationMessage: null,
login: async (email: string, password: string) => {
set({ error: null, isLoading: true });
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
try {
const { data } = await api.post<AuthResponse>('/auth/login', {
email,
@ -41,16 +47,51 @@ export const useAuthStore = create<AuthState & AuthActions>()(
});
set({
user: data.user,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true,
isLoading: false,
});
} catch (err: unknown) {
const message =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Login failed';
set({ error: message, isLoading: false });
const resp = (err as { response?: { data?: { error?: { message?: string; code?: string } } } })?.response?.data?.error;
const message = resp?.message || 'Login failed';
const code = resp?.code || null;
set({ error: message, errorCode: code, isLoading: false });
throw err;
}
},
register: async (name: string, email: string, password: string) => {
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
try {
const { data } = await api.post<AuthResponse>('/auth/register', {
name,
email,
password,
});
// If verification is required, don't set tokens — user needs to verify email first
if (data.requiresVerification) {
set({
isLoading: false,
registrationMessage: data.message || 'Please check your email to verify your account',
});
return { requiresVerification: true };
}
set({
user: data.user,
accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true,
isLoading: false,
});
return {};
} catch (err: unknown) {
const resp = (err as { response?: { data?: { error?: { message?: string; code?: string } } } })?.response?.data?.error;
const message = resp?.message || 'Registration failed';
const code = resp?.code || null;
set({ error: message, errorCode: code, isLoading: false });
throw err;
}
},
@ -79,8 +120,8 @@ export const useAuthStore = create<AuthState & AuthActions>()(
});
set({
user: data.user,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true,
});
} catch {
@ -127,8 +168,14 @@ export const useAuthStore = create<AuthState & AuthActions>()(
isAuthenticated: false,
isLoading: false,
error: null,
errorCode: null,
registrationMessage: null,
});
},
clearError: () => {
set({ error: null, errorCode: null, registrationMessage: null });
},
}),
{
name: 'cml-auth',

View File

@ -15,9 +15,9 @@ export interface AppOutletContext {
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'USER' | 'TEMP';
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED';
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
export type CreatedVia = 'ADMIN' | 'PUBLIC_SHIFT_SIGNUP' | 'STANDARD';
export type CreatedVia = 'ADMIN' | 'PUBLIC_SHIFT_SIGNUP' | 'STANDARD' | 'SELF_REGISTRATION';
export interface User {
id: string;
@ -25,6 +25,7 @@ export interface User {
name: string | null;
phone: string | null;
role: UserRole;
roles: UserRole[];
status: UserStatus;
permissions: Record<string, unknown> | null;
createdVia: CreatedVia;
@ -38,8 +39,10 @@ export interface User {
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
accessToken?: string;
refreshToken?: string;
requiresVerification?: boolean;
message?: string;
}
export interface PaginationMeta {
@ -68,6 +71,7 @@ export interface CreateUserPayload {
name?: string;
phone?: string;
role?: UserRole;
roles?: UserRole[];
status?: UserStatus;
expiresAt?: string;
expireDays?: number;
@ -79,6 +83,7 @@ export interface UpdateUserPayload {
name?: string;
phone?: string;
role?: UserRole;
roles?: UserRole[];
status?: UserStatus;
expiresAt?: string | null;
expireDays?: number;
@ -98,6 +103,8 @@ export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_A
export type CampaignStatus = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'ARCHIVED';
export type CampaignModerationStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED';
export type GovernmentLevel = 'FEDERAL' | 'PROVINCIAL' | 'MUNICIPAL' | 'SCHOOL_BOARD';
export interface Campaign {
@ -123,6 +130,12 @@ export interface Campaign {
createdByUserId: string | null;
createdByUserEmail: string | null;
createdByUserName: string | null;
isUserGenerated: boolean;
moderationStatus: CampaignModerationStatus | null;
reviewedByUserId: string | null;
reviewedAt: string | null;
rejectionReason: string | null;
moderationNotes: string | null;
createdAt: string;
updatedAt: string;
_count: {
@ -183,6 +196,45 @@ export interface CampaignsListParams {
status?: CampaignStatus;
}
// --- User-Generated Campaigns ---
export interface CreateUserCampaignPayload {
title: string;
description?: string;
emailSubject: string;
emailBody: string;
callToAction?: string;
targetGovernmentLevels: GovernmentLevel[];
}
export interface ModerateCampaignPayload {
action: 'approve' | 'reject' | 'request_changes';
reason?: string;
notes?: string;
}
export interface CampaignModerationStats {
total: number;
pending: number;
approved: number;
rejected: number;
changesRequested: number;
}
export const CAMPAIGN_MODERATION_STATUS_COLORS: Record<CampaignModerationStatus, string> = {
PENDING_REVIEW: 'orange',
APPROVED: 'green',
REJECTED: 'red',
CHANGES_REQUESTED: 'gold',
};
export const CAMPAIGN_MODERATION_STATUS_LABELS: Record<CampaignModerationStatus, string> = {
PENDING_REVIEW: 'Pending Review',
APPROVED: 'Approved',
REJECTED: 'Rejected',
CHANGES_REQUESTED: 'Changes Requested',
};
// --- Representatives ---
export interface RepresentativeOffice {
@ -1010,6 +1062,9 @@ export interface SiteSettings {
smtpActiveProvider?: 'mailhog' | 'production';
emailTestMode?: boolean;
testEmailRecipient?: string;
enablePublicRegistration: boolean;
enableEmailVerification: boolean;
autoApproveVerifiedUsers: boolean;
enableInfluence: boolean;
enableMap: boolean;
enableNewsletter: boolean;
@ -1224,6 +1279,45 @@ export interface NarImportProgress {
result?: NarServerImportResult;
}
// --- Area Import ---
export type AreaImportSourceStatus = 'pending' | 'running' | 'complete' | 'failed' | 'skipped';
export interface AreaImportSourceProgress {
status: AreaImportSourceStatus;
candidatesFound: number;
message?: string;
error?: string;
}
export interface AreaImportProgress {
status: 'initializing' | 'running' | 'creating-records' | 'complete' | 'failed';
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number } | null;
areaSqKm: number;
sources: {
osm: AreaImportSourceProgress;
nar: AreaImportSourceProgress;
reverseGeocode: AreaImportSourceProgress;
};
locationsCreated: number;
addressesCreated: number;
skippedDuplicate: number;
totalCandidates: number;
error?: string;
}
export interface AreaImportPreviewResult {
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
areaSqKm: number;
existingLocations: number;
estimates: {
osm: number;
nar: number;
reverseGeocode: number;
};
narProvincesDetected: string[];
}
// --- SMTP Test Results ---
export interface SmtpTestResult {
@ -1425,3 +1519,162 @@ export const EMAIL_TEMPLATE_CATEGORY_COLORS: Record<EmailTemplateCategory, strin
MAP: 'green',
SYSTEM: 'purple',
};
// --- Dashboard ---
export interface DashboardSummary {
users: {
total: number;
byRole: Record<string, number>;
active: number;
suspended: number;
};
campaigns: {
total: number;
active: number;
draft: number;
paused: number;
archived: number;
};
locations: {
total: number;
geocoded: number;
addresses: number;
};
emails: {
total: number;
sent: number;
failed: number;
queued: number;
};
shifts: {
total: number;
open: number;
upcoming: number;
};
canvass: {
totalSessions: number;
totalVisits: number;
activeSessions: number;
};
responses: {
total: number;
pending: number;
approved: number;
};
videos: {
total: number;
published: number;
};
pages: {
total: number;
published: number;
};
emailTemplates: {
total: number;
};
cuts: {
total: number;
};
representatives: {
totalCached: number;
};
campaignModeration: {
pendingReview: number;
};
}
export interface SystemInfo {
hostname: string;
platform: string;
arch: string;
nodeVersion: string;
uptime: number;
cpu: {
model: string;
cores: number;
loadAvg: number[];
};
memory: {
totalMB: number;
usedMB: number;
freeMB: number;
usagePercent: number;
};
disk: {
totalGB: number;
usedGB: number;
freeGB: number;
usagePercent: number;
} | null;
process: {
heapUsedMB: number;
heapTotalMB: number;
rssMB: number;
uptimeSeconds: number;
};
}
export interface ContainerInfo {
name: string;
label: string;
running: boolean;
status: string;
}
export interface WeatherData {
latitude: number;
longitude: number;
temperature: number;
apparentTemperature: number;
humidity: number;
windSpeed: number;
windDirection: number;
weatherCode: number;
weatherDescription: string;
isDay: boolean;
precipitation: number;
cloudCover: number;
time: string;
}
export interface ApiMetrics {
requestRate: number;
errorRate: number;
avgLatencyMs: number;
p95LatencyMs: number;
topRoutes: { method: string; route: string; count: number }[];
slowRoutes: { method: string; route: string; p95Ms: number }[];
statusBreakdown: { status: string; count: number }[];
}
// --- Dashboard Time-Series ---
export interface TimeSeriesPoint {
timestamps: number[];
values: number[];
}
export type TimeSeriesResult = Record<string, TimeSeriesPoint>;
export type TimeSeriesMetricKey =
| 'request_rate_2xx' | 'request_rate_4xx' | 'request_rate_5xx'
| 'latency_p50' | 'latency_p95' | 'latency_p99'
| 'email_sent_rate' | 'email_failed_rate' | 'email_queue_size'
| 'cpu_usage' | 'memory_usage' | 'active_sessions' | 'login_rate';
// --- Dashboard Container Resources ---
export interface ContainerResource {
name: string;
label: string;
cpuPercent: number;
memoryMB: number;
memoryLimitMB: number;
networkRxKBps: number;
networkTxKBps: number;
}
export interface ContainerResourcesResponse {
containers: ContainerResource[];
}

View File

@ -31,6 +31,7 @@ export interface Video {
scheduledUnpublishAt?: string | null;
isPublished?: boolean;
publishedAt?: string | null;
isShort?: boolean;
}
// Video Analytics interfaces
@ -91,6 +92,7 @@ export interface VideosListParams {
subdirectories?: string;
offset?: number;
limit?: number;
isShort?: boolean;
}
// Public media interfaces
@ -145,3 +147,74 @@ export interface DirectoryType {
count: number;
subdirectories?: string[];
}
// Playlist interfaces
export interface PlaylistCreator {
id: string;
name: string | null;
email: string;
}
export interface PlaylistSummary {
id: number;
name: string;
description: string | null;
isPublic: boolean;
shareToken?: string | null;
videoCount: number;
totalDurationSeconds: number;
viewCount: number;
thumbnailUrl: string | null;
createdAt: string;
updatedAt: string | null;
creator: PlaylistCreator;
isFeatured: boolean;
featuredPosition: number | null;
isOwner: boolean;
}
export interface PlaylistVideoItem {
id: number;
mediaId: number;
position: number;
addedAt: string;
video: {
id: number;
title: string | null;
filename: string;
durationSeconds: number | null;
quality: string | null;
orientation: string | null;
thumbnailUrl: string | null;
viewCount: number;
isLocked: boolean;
createdAt: string;
};
}
export interface PlaylistDetail extends PlaylistSummary {
videos: PlaylistVideoItem[];
}
export interface PlaylistsListResponse {
data: PlaylistSummary[];
total: number;
limit: number;
offset: number;
}
export interface CreatePlaylistBody {
name: string;
description?: string;
isPublic?: boolean;
}
export interface UpdatePlaylistBody {
name?: string;
description?: string;
isPublic?: boolean;
}
export interface ReorderPlaylistVideosBody {
items: Array<{ mediaId: number; position: number }>;
}

6
admin/src/utils/color.ts Normal file
View File

@ -0,0 +1,6 @@
export function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

23
admin/src/utils/roles.ts Normal file
View File

@ -0,0 +1,23 @@
import type { UserRole } from '@/types/api';
import { ADMIN_ROLES } from '@/types/api';
/** Check if the user has any of the specified roles */
export function hasAnyRole(user: { roles?: UserRole[]; role?: UserRole } | null, roles: UserRole[]): boolean {
if (!user) return false;
const userRoles = getUserRoles(user);
return userRoles.some(r => roles.includes(r));
}
/** Check if user has any admin role */
export function isAdmin(user: { roles?: UserRole[]; role?: UserRole } | null): boolean {
return hasAnyRole(user, ADMIN_ROLES);
}
/** Safely extract roles array from a user (handles old single-role and new multi-role) */
export function getUserRoles(user: { roles?: UserRole[]; role?: UserRole }): UserRole[] {
if (Array.isArray(user.roles) && user.roles.length > 0) {
return user.roles;
}
if (user.role) return [user.role];
return ['USER'];
}

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
FROM node:20-alpine AS base
WORKDIR /app
# Install ffmpeg for video metadata extraction
RUN apk add --no-cache ffmpeg
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Install dependencies
COPY package*.json ./
@ -35,8 +35,8 @@ RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
# Install ffmpeg for video metadata extraction
RUN apk add --no-cache ffmpeg
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Copy built files and node_modules
COPY --from=build /app/dist ./dist

View File

@ -24,12 +24,15 @@ enum UserStatus {
INACTIVE
SUSPENDED
EXPIRED
PENDING_VERIFICATION
PENDING_APPROVAL
}
enum UserCreatedVia {
ADMIN
PUBLIC_SHIFT_SIGNUP
STANDARD
SELF_REGISTRATION
}
model User {
@ -39,6 +42,7 @@ model User {
name String?
phone String?
role UserRole @default(USER)
roles Json @default("[]") // Array of UserRole strings for multi-role support
status UserStatus @default(ACTIVE)
permissions Json? // Per-app granular permissions
createdVia UserCreatedVia @default(STANDARD)
@ -51,6 +55,7 @@ model User {
refreshTokens RefreshToken[]
campaignsCreated Campaign[] @relation("CampaignCreator")
campaignsReviewed Campaign[] @relation("CampaignReviewer")
campaignEmails CampaignEmail[] @relation("CampaignEmailSender")
responses RepresentativeResponse[] @relation("ResponseSubmitter")
responseUpvotes ResponseUpvote[]
@ -154,6 +159,13 @@ enum CampaignStatus {
ARCHIVED
}
enum CampaignModerationStatus {
PENDING_REVIEW
APPROVED
REJECTED
CHANGES_REQUESTED
}
enum GovernmentLevel {
FEDERAL
PROVINCIAL
@ -192,6 +204,15 @@ model Campaign {
createdByUserEmail String?
createdByUserName String?
// User-generated campaign moderation
isUserGenerated Boolean @default(false)
moderationStatus CampaignModerationStatus?
reviewedByUserId String?
reviewedByUser User? @relation("CampaignReviewer", fields: [reviewedByUserId], references: [id], onDelete: SetNull)
reviewedAt DateTime?
rejectionReason String? @db.Text
moderationNotes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -200,6 +221,8 @@ model Campaign {
customRecipients CustomRecipient[]
calls Call[]
@@index([moderationStatus])
@@index([isUserGenerated])
@@map("campaigns")
}
@ -803,6 +826,11 @@ model SiteSettings {
emailTestMode Boolean @default(true)
testEmailRecipient String @default("")
// Registration settings
enablePublicRegistration Boolean @default(true)
enableEmailVerification Boolean @default(true)
autoApproveVerifiedUsers Boolean @default(true)
// Feature toggles
enableInfluence Boolean @default(true)
enableMap Boolean @default(true)
@ -1350,6 +1378,7 @@ model Video {
isPublished Boolean @default(false) @map("is_published")
publishedAt DateTime? @map("published_at")
category String? // videos|curated|compilations|playback|highlights
isShort Boolean @default(false) @map("is_short")
// Moderation system
isLocked Boolean @default(false) @map("is_locked")
@ -1417,6 +1446,7 @@ model Video {
@@index([directoryType, isValid, orientation], map: "idx_videos_directory_valid_orientation")
@@index([isPublished, isLocked], map: "idx_videos_published_locked")
@@index([category, isPublished], map: "idx_videos_category_published")
@@index([isShort, isPublished, isLocked], map: "idx_videos_short_published")
@@index([uploaderId], map: "idx_videos_uploader")
@@map("videos")
}
@ -1938,7 +1968,7 @@ model PlaylistVideo {
addedAt DateTime @default(now()) @map("added_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id])
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
media Video @relation(fields: [mediaId], references: [id])
@@index([playlistId], map: "idx_playlist_videos_playlist")
@ -1955,7 +1985,7 @@ model FeaturedPlaylist {
featuredAt DateTime? @map("featured_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id])
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
featurer User? @relation("FeaturedPlaylistFeaturer", fields: [featuredBy], references: [id])
@@index([position], map: "idx_featured_playlists_position")
@ -1969,7 +1999,7 @@ model PlaylistView {
createdAt DateTime @default(now()) @map("created_at")
// Relations
playlist Playlist @relation(fields: [playlistId], references: [id])
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
session Session @relation(fields: [sessionId], references: [id])
@@index([playlistId], map: "idx_playlist_views_playlist")

View File

@ -41,6 +41,7 @@ async function main() {
password: hashedPassword,
name: 'Admin',
role: UserRole.SUPER_ADMIN,
roles: JSON.parse(JSON.stringify([UserRole.SUPER_ADMIN])),
emailVerified: true,
},
});

View File

@ -114,6 +114,11 @@ const envSchema = z.object({
// NAR (National Address Register)
NAR_DATA_DIR: z.string().default('/data'),
// Overpass / Area Import
OVERPASS_API_URL: z.string().default('https://overpass-api.de/api/interpreter'),
OVERPASS_MIN_DELAY_MS: z.coerce.number().default(30000),
AREA_IMPORT_MAX_GRID_POINTS: z.coerce.number().default(500),
// Media Management
ENABLE_MEDIA_FEATURES: z.string().default('false'),
MEDIA_API_PORT: z.coerce.number().default(4100),

View File

@ -8,11 +8,23 @@ import { videoStreamingRoutes } from './modules/media/routes/video-streaming.rou
import { reactionsRoutes } from './modules/media/routes/reactions.routes';
import { publicRoutes } from './modules/media/routes/public.routes';
import { chatStreamRoutes } from './modules/media/routes/chat-stream.routes';
import { commentsRoutes } from './modules/media/routes/comments.routes';
import { uploadRoutes } from './modules/media/routes/upload.routes';
import { videoActionsRoutes } from './modules/media/routes/video-actions.routes';
import { videoScheduleRoutes } from './modules/media/routes/video-schedule.routes';
import { videoTrackingRoutes } from './modules/media/routes/video-tracking.routes';
import { commentAdminRoutes } from './modules/media/routes/comment-admin.routes';
import { chatNotificationsRoutes } from './modules/media/routes/chat-notifications.routes';
import { chatThreadsRoutes } from './modules/media/routes/chat-threads.routes';
import { userProfileRoutes } from './modules/media/routes/user-profile.routes';
import { shortsRoutes } from './modules/media/routes/shorts.routes';
import { upvoteRoutes } from './modules/media/routes/upvote.routes';
import { videoScheduleQueueService } from './services/video-schedule-queue.service';
import { videoFetchQueueService } from './services/video-fetch-queue.service';
import { fetchRoutes } from './modules/media/routes/fetch.routes';
import { playlistsPublicRoutes } from './modules/media/routes/playlists-public.routes';
import { playlistsUserRoutes } from './modules/media/routes/playlists-user.routes';
import { playlistsAdminRoutes } from './modules/media/routes/playlists-admin.routes';
// Add BigInt serialization support for Prisma BigInt fields
// This converts BigInt values to strings when JSON.stringify() is called
@ -32,6 +44,7 @@ const fastify = Fastify({
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');
await videoScheduleQueueService.close();
await videoFetchQueueService.close();
fastify.close(() => {
logger.info('Media API server closed');
process.exit(0);
@ -99,9 +112,18 @@ const start = async () => {
await fastify.register(videoTrackingRoutes, { prefix: '/api/track' });
await fastify.register(reactionsRoutes, { prefix: '/api/reactions' });
await fastify.register(publicRoutes, { prefix: '/api' });
await fastify.register(chatStreamRoutes, { prefix: '/api/media' });
// TODO: Add more routes
// await fastify.register(jobsRoutes, { prefix: '/api/jobs' });
await fastify.register(commentsRoutes, { prefix: '/api' });
await fastify.register(chatStreamRoutes, { prefix: '/api' });
await fastify.register(commentAdminRoutes, { prefix: '/api/media' });
await fastify.register(chatNotificationsRoutes, { prefix: '/api/media' });
await fastify.register(chatThreadsRoutes, { prefix: '/api/media' });
await fastify.register(userProfileRoutes, { prefix: '/api/media' });
await fastify.register(fetchRoutes, { prefix: '/api/videos' });
await fastify.register(shortsRoutes, { prefix: '/api' });
await fastify.register(upvoteRoutes, { prefix: '/api' });
await fastify.register(playlistsPublicRoutes, { prefix: '/api/playlists' });
await fastify.register(playlistsUserRoutes, { prefix: '/api/playlists' });
await fastify.register(playlistsAdminRoutes, { prefix: '/api/media' });
const port = env.MEDIA_API_PORT;
const host = '0.0.0.0';
@ -113,6 +135,10 @@ const start = async () => {
videoScheduleQueueService.startWorker();
logger.info('Video schedule queue worker initialized');
// Start video fetch queue worker
videoFetchQueueService.startWorker();
logger.info('Video fetch queue worker initialized');
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
logger.warn('Media features are disabled (ENABLE_MEDIA_FEATURES=false)');
}

View File

@ -8,6 +8,7 @@ interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles?: UserRole[];
}
export function authenticate(req: Request, _res: Response, next: NextFunction) {
@ -20,7 +21,12 @@ export function authenticate(req: Request, _res: Response, next: NextFunction) {
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
req.user = { id: payload.id, email: payload.email, role: payload.role };
req.user = {
id: payload.id,
email: payload.email,
role: payload.role,
roles: payload.roles || [payload.role], // Backwards compat: old JWTs without roles
};
next();
} catch {
throw new AppError(401, 'Invalid or expired token', 'INVALID_TOKEN');
@ -38,7 +44,12 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction) {
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
req.user = { id: payload.id, email: payload.email, role: payload.role };
req.user = {
id: payload.id,
email: payload.email,
role: payload.role,
roles: payload.roles || [payload.role],
};
} catch {
// Token invalid — continue without user
}

View File

@ -8,7 +8,11 @@ export function requireRole(...roles: UserRole[]) {
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
if (!roles.includes(req.user.role)) {
// Check multi-role array (falls back to single role via auth middleware)
const userRoles = req.user.roles || [req.user.role];
const hasRole = userRoles.some(r => roles.includes(r));
if (!hasRole) {
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
}
@ -21,7 +25,9 @@ export function requireNonTemp(req: Request, _res: Response, next: NextFunction)
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
if (req.user.role === UserRole.TEMP) {
const userRoles = req.user.roles || [req.user.role];
// User is "temp only" if their only role is TEMP
if (userRoles.length === 1 && userRoles[0] === UserRole.TEMP) {
throw new AppError(403, 'Temporary accounts cannot access this resource', 'TEMP_FORBIDDEN');
}

View File

@ -0,0 +1,43 @@
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
/** 3 requests per hour for resending verification emails */
export function createVerificationRateLimit() {
return rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:verify-resend:',
}),
message: {
error: {
message: 'Too many verification email requests, please try again later',
code: 'VERIFICATION_RATE_LIMIT_EXCEEDED',
},
},
});
}
/** 3 requests per hour for password reset emails */
export function createResetRateLimit() {
return rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:password-reset:',
}),
message: {
error: {
message: 'Too many password reset requests, please try again later',
code: 'RESET_RATE_LIMIT_EXCEEDED',
},
},
});
}

View File

@ -1,9 +1,20 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { UserRole, UserStatus } from '@prisma/client';
import { authService } from './auth.service';
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { authRateLimit } from '../../middleware/rate-limit';
import { prisma } from '../../config/database';
import { verificationTokenService } from '../../services/verification-token.service';
import { passwordResetTokenService } from '../../services/password-reset-token.service';
import { emailService } from '../../services/email.service';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { createVerificationRateLimit, createResetRateLimit } from './auth.rate-limits';
const router = Router();
@ -37,6 +48,172 @@ router.post(
}
);
// POST /api/auth/verify-email
const verifyEmailSchema = z.object({ token: z.string().min(1) });
router.post(
'/verify-email',
authRateLimit,
validate(verifyEmailSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token } = req.body;
const result = await verificationTokenService.verifyToken(token);
if (!result.valid || !result.userId) {
res.status(400).json({
error: { message: result.error || 'Invalid token', code: 'INVALID_TOKEN' },
});
return;
}
const settings = await siteSettingsService.get();
const autoApprove = settings.autoApproveVerifiedUsers;
const newStatus = autoApprove ? UserStatus.ACTIVE : UserStatus.PENDING_APPROVAL;
await prisma.user.update({
where: { id: result.userId },
data: { emailVerified: true, status: newStatus },
});
// If not auto-approved, notify admins
if (!autoApprove) {
const user = await prisma.user.findUnique({ where: { id: result.userId } });
if (user) {
const admins = await prisma.user.findMany({
where: { role: UserRole.SUPER_ADMIN, status: UserStatus.ACTIVE },
select: { email: true },
});
if (admins.length > 0) {
await emailService.sendPendingApprovalNotification({
adminEmails: admins.map(a => a.email),
newUserEmail: user.email,
newUserName: user.name || '',
}).catch(err => logger.error('Failed to send approval notification:', err));
}
}
}
res.json({
verified: true,
approved: autoApprove,
message: autoApprove
? 'Email verified. You can now log in.'
: 'Email verified. Your account is pending admin approval.',
});
} catch (err) {
next(err);
}
}
);
// POST /api/auth/resend-verification
const resendVerificationSchema = z.object({ email: z.string().email() });
router.post(
'/resend-verification',
createVerificationRateLimit(),
validate(resendVerificationSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
// Always return success to prevent user enumeration
res.json({ message: 'If your email is registered and pending verification, a new verification link has been sent.' });
// Send asynchronously (don't block response)
const user = await prisma.user.findUnique({ where: { email: req.body.email } });
if (user && user.status === UserStatus.PENDING_VERIFICATION) {
const token = await verificationTokenService.createToken(user.id);
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
const verificationUrl = `${adminUrl}/verify-email?token=${token}`;
await emailService.sendVerificationEmail({
recipientEmail: user.email,
recipientName: user.name || 'there',
verificationUrl,
}).catch(err => logger.error('Failed to resend verification email:', err));
}
} catch (err) {
next(err);
}
}
);
// POST /api/auth/forgot-password
const forgotPasswordSchema = z.object({ email: z.string().email() });
router.post(
'/forgot-password',
createResetRateLimit(),
validate(forgotPasswordSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
// Always return success to prevent user enumeration
res.json({ message: 'If your email is registered, a password reset link has been sent.' });
// Send asynchronously
const user = await prisma.user.findUnique({ where: { email: req.body.email } });
if (user && user.status === UserStatus.ACTIVE) {
const token = await passwordResetTokenService.createToken(user.id);
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
const resetUrl = `${adminUrl}/reset-password?token=${token}`;
await emailService.sendPasswordResetEmail({
recipientEmail: user.email,
recipientName: user.name || 'there',
resetUrl,
}).catch(err => logger.error('Failed to send password reset email:', err));
}
} catch (err) {
next(err);
}
}
);
// POST /api/auth/reset-password
const resetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one digit'),
});
router.post(
'/reset-password',
authRateLimit,
validate(resetPasswordSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, password } = req.body;
const result = await passwordResetTokenService.validateToken(token);
if (!result.valid || !result.userId) {
res.status(400).json({
error: { message: result.error || 'Invalid token', code: 'INVALID_TOKEN' },
});
return;
}
const hashedPassword = await bcrypt.hash(password, 12);
// Update password, mark token used, invalidate all refresh tokens
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: result.userId },
data: { password: hashedPassword },
});
await tx.refreshToken.deleteMany({ where: { userId: result.userId } });
});
await passwordResetTokenService.markTokenUsed(token);
res.json({ message: 'Password has been reset. You can now log in with your new password.' });
} catch (err) {
next(err);
}
}
);
// POST /api/auth/refresh
router.post(
'/refresh',
@ -73,7 +250,6 @@ router.get(
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { prisma } = await import('../../config/database');
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
@ -82,6 +258,7 @@ router.get(
name: true,
phone: true,
role: true,
roles: true,
status: true,
permissions: true,
createdVia: true,

View File

@ -5,12 +5,17 @@ import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler';
import { recordLoginAttempt } from '../../utils/metrics';
import { siteSettingsService } from '../settings/settings.service';
import { verificationTokenService } from '../../services/verification-token.service';
import { emailService } from '../../services/email.service';
import { getPrimaryRole } from '../../utils/roles';
import type { RegisterInput } from './auth.schemas';
interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles: UserRole[];
}
interface TokenPair {
@ -18,6 +23,16 @@ interface TokenPair {
refreshToken: string;
}
type UserForToken = { id: string; email: string; role: UserRole; roles?: unknown };
/** Parse the roles JSON field into a UserRole[] array */
function parseRoles(user: UserForToken): UserRole[] {
if (Array.isArray(user.roles) && user.roles.length > 0) {
return user.roles as UserRole[];
}
return [user.role];
}
export const authService = {
async login(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
@ -32,6 +47,17 @@ export const authService = {
throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');
}
// Status-specific errors
if (user.status === UserStatus.PENDING_VERIFICATION) {
recordLoginAttempt('failure');
throw new AppError(403, 'Please verify your email address before logging in', 'EMAIL_NOT_VERIFIED');
}
if (user.status === UserStatus.PENDING_APPROVAL) {
recordLoginAttempt('failure');
throw new AppError(403, 'Your account is pending admin approval', 'ACCOUNT_PENDING');
}
if (user.status !== UserStatus.ACTIVE) {
recordLoginAttempt('failure');
throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');
@ -56,6 +82,12 @@ export const authService = {
},
async register(data: RegisterInput) {
// Check if public registration is enabled
const settings = await siteSettingsService.get();
if (!settings.enablePublicRegistration) {
throw new AppError(403, 'Public registration is currently disabled', 'REGISTRATION_DISABLED');
}
const existing = await prisma.user.findUnique({ where: { email: data.email } });
if (existing) {
throw new AppError(409, 'Email already registered', 'EMAIL_EXISTS');
@ -63,16 +95,45 @@ export const authService = {
const hashedPassword = await bcrypt.hash(data.password, 12);
// Determine if email verification is needed
const smtpReady = await emailService.isSmtpConfigured();
const requireVerification = settings.enableEmailVerification && smtpReady;
const user = await prisma.user.create({
data: {
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone,
role: UserRole.USER, // Always USER for public registration
role: UserRole.USER,
roles: JSON.parse(JSON.stringify([UserRole.USER])),
status: requireVerification ? UserStatus.PENDING_VERIFICATION : UserStatus.ACTIVE,
emailVerified: !requireVerification,
createdVia: 'SELF_REGISTRATION',
},
});
// If verification required, send email and don't issue tokens
if (requireVerification) {
const token = await verificationTokenService.createToken(user.id);
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
const verificationUrl = `${adminUrl}/verify-email?token=${token}`;
await emailService.sendVerificationEmail({
recipientEmail: user.email,
recipientName: user.name || 'there',
verificationUrl,
});
const { password: _, ...userWithoutPassword } = user;
return {
user: userWithoutPassword,
requiresVerification: true,
message: 'Please check your email to verify your account',
};
}
// No verification needed — issue tokens immediately
const tokens = await this.generateTokenPair(user);
const { password: _, ...userWithoutPassword } = user;
@ -105,12 +166,13 @@ export const authService = {
const tokens = await prisma.$transaction(async (tx) => {
await tx.refreshToken.delete({ where: { id: stored.id } });
// Generate new token pair
const userRoles = parseRoles(stored.user);
const accessToken = this.generateAccessToken(stored.user);
const refreshPayload: TokenPayload = {
id: stored.user.id,
email: stored.user.email,
role: stored.user.role
role: getPrimaryRole(userRoles),
roles: userRoles,
};
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
@ -139,20 +201,31 @@ export const authService = {
await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });
},
generateAccessToken(user: { id: string; email: string; role: UserRole }): string {
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
generateAccessToken(user: UserForToken): string {
const userRoles = parseRoles(user);
const payload: TokenPayload = {
id: user.id,
email: user.email,
role: getPrimaryRole(userRoles),
roles: userRoles,
};
return jwt.sign(payload, env.JWT_ACCESS_SECRET, {
expiresIn: env.JWT_ACCESS_EXPIRY as SignOptions['expiresIn'],
});
},
async generateRefreshToken(user: { id: string; email: string; role: UserRole }): Promise<string> {
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
async generateRefreshToken(user: UserForToken): Promise<string> {
const userRoles = parseRoles(user);
const payload: TokenPayload = {
id: user.id,
email: user.email,
role: getPrimaryRole(userRoles),
roles: userRoles,
};
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
});
// Parse expiry to get a Date
const decoded = jwt.decode(token) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
@ -167,7 +240,7 @@ export const authService = {
return token;
},
async generateTokenPair(user: { id: string; email: string; role: UserRole }): Promise<TokenPair> {
async generateTokenPair(user: UserForToken): Promise<TokenPair> {
const accessToken = this.generateAccessToken(user);
const refreshToken = await this.generateRefreshToken(user);
return { accessToken, refreshToken };

View File

@ -0,0 +1,124 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import {
getDashboardSummary,
getSystemInfo,
getContainerStatuses,
getWeather,
getApiMetrics,
getTimeSeries,
getContainerResources,
} from './dashboard.service';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
// GET /api/dashboard/summary — platform counts
router.get('/summary', async (_req: Request, res: Response, next: NextFunction) => {
try {
const summary = await getDashboardSummary();
res.json(summary);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/system — hardware + OS info (SUPER_ADMIN only)
router.get('/system', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const info = getSystemInfo();
res.json(info);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/containers — Docker container statuses (SUPER_ADMIN only)
router.get('/containers', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const containers = await getContainerStatuses();
res.json(containers);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/weather — weather from map settings location
router.get('/weather', async (_req: Request, res: Response, next: NextFunction) => {
try {
const weather = await getWeather();
if (!weather) {
res.json({ error: 'No location configured or weather unavailable' });
return;
}
res.json(weather);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/api-metrics — Prometheus API performance metrics (SUPER_ADMIN only)
router.get('/api-metrics', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const metrics = await getApiMetrics();
if (!metrics) {
res.json({ error: 'Prometheus unavailable' });
return;
}
res.json(metrics);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/time-series — predefined-key time-series from Prometheus (SUPER_ADMIN only)
const ALLOWED_METRIC_KEYS = new Set([
'request_rate_2xx', 'request_rate_4xx', 'request_rate_5xx',
'latency_p50', 'latency_p95', 'latency_p99',
'email_sent_rate', 'email_failed_rate', 'email_queue_size',
'cpu_usage', 'memory_usage', 'active_sessions', 'login_rate',
]);
const ALLOWED_RANGES = new Set(['1h', '6h', '24h']);
const ALLOWED_STEPS = new Set(['1m', '5m', '15m']);
router.get('/time-series', requireRole('SUPER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const metricsParam = (req.query.metrics as string) || '';
const range = (req.query.range as string) || '1h';
const step = (req.query.step as string) || '5m';
if (!ALLOWED_RANGES.has(range)) {
res.status(400).json({ error: 'Invalid range. Allowed: 1h, 6h, 24h' });
return;
}
if (!ALLOWED_STEPS.has(step)) {
res.status(400).json({ error: 'Invalid step. Allowed: 1m, 5m, 15m' });
return;
}
const metricKeys = metricsParam.split(',').filter(k => ALLOWED_METRIC_KEYS.has(k.trim()));
if (metricKeys.length === 0) {
res.status(400).json({ error: 'No valid metric keys provided' });
return;
}
const data = await getTimeSeries(metricKeys, range, step);
res.json(data);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/container-resources — cAdvisor container CPU/memory/network (SUPER_ADMIN only)
router.get('/container-resources', requireRole('SUPER_ADMIN'), async (_req: Request, res: Response, next: NextFunction) => {
try {
const containers = await getContainerResources();
res.json({ containers });
} catch (err) {
next(err);
}
});
export const dashboardRouter = router;

View File

@ -0,0 +1,621 @@
import os from 'os';
import fs from 'fs';
import { prisma } from '../../config/database';
import { dockerService } from '../../services/docker.service';
import { mapSettingsService } from '../map/settings/settings.service';
import { env } from '../../config/env';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout';
import { validatePromQLQueries } from '../../utils/promql-validator';
import { isServiceOnline } from '../../utils/health-check';
import { logger } from '../../utils/logger';
// --- Types ---
export interface DashboardSummary {
users: {
total: number;
byRole: Record<string, number>;
active: number;
suspended: number;
};
campaigns: {
total: number;
active: number;
draft: number;
paused: number;
archived: number;
};
locations: {
total: number;
geocoded: number;
addresses: number;
};
emails: {
total: number;
sent: number;
failed: number;
queued: number;
};
shifts: {
total: number;
open: number;
upcoming: number;
};
canvass: {
totalSessions: number;
totalVisits: number;
activeSessions: number;
};
responses: {
total: number;
pending: number;
approved: number;
};
videos: {
total: number;
published: number;
};
pages: {
total: number;
published: number;
};
emailTemplates: {
total: number;
};
cuts: {
total: number;
};
representatives: {
totalCached: number;
};
campaignModeration: {
pendingReview: number;
};
}
export interface SystemInfo {
hostname: string;
platform: string;
arch: string;
nodeVersion: string;
uptime: number; // seconds
cpu: {
model: string;
cores: number;
loadAvg: number[]; // 1, 5, 15 min
};
memory: {
totalMB: number;
usedMB: number;
freeMB: number;
usagePercent: number;
};
disk: {
totalGB: number;
usedGB: number;
freeGB: number;
usagePercent: number;
} | null;
process: {
heapUsedMB: number;
heapTotalMB: number;
rssMB: number;
uptimeSeconds: number;
};
}
export interface ContainerInfo {
name: string;
label: string;
running: boolean;
status: string;
}
export interface WeatherData {
latitude: number;
longitude: number;
temperature: number;
apparentTemperature: number;
humidity: number;
windSpeed: number;
windDirection: number;
weatherCode: number;
weatherDescription: string;
isDay: boolean;
precipitation: number;
cloudCover: number;
time: string;
}
// --- Container definitions ---
const CONTAINERS: { name: string; label: string }[] = [
{ name: 'changemaker-v2-api', label: 'API' },
{ name: 'changemaker-media-api', label: 'Media API' },
{ name: 'changemaker-v2-admin', label: 'Admin' },
{ name: 'changemaker-v2-postgres', label: 'PostgreSQL' },
{ name: 'redis-changemaker', label: 'Redis' },
{ name: 'changemaker-v2-nginx', label: 'Nginx' },
{ name: 'changemaker-v2-nocodb', label: 'NocoDB' },
{ name: 'listmonk-app', label: 'Listmonk' },
{ name: 'n8n-changemaker', label: 'n8n' },
{ name: 'gitea-changemaker', label: 'Gitea' },
{ name: 'mailhog-changemaker', label: 'MailHog' },
{ name: 'mini-qr', label: 'Mini QR' },
{ name: 'code-server-changemaker', label: 'Code Server' },
{ name: 'mkdocs-changemaker', label: 'MkDocs' },
{ name: 'newt-changemaker', label: 'Newt Tunnel' },
];
// --- WMO weather code descriptions ---
const WMO_CODES: Record<number, string> = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Depositing rime fog',
51: 'Light drizzle',
53: 'Moderate drizzle',
55: 'Dense drizzle',
56: 'Light freezing drizzle',
57: 'Dense freezing drizzle',
61: 'Slight rain',
63: 'Moderate rain',
65: 'Heavy rain',
66: 'Light freezing rain',
67: 'Heavy freezing rain',
71: 'Slight snowfall',
73: 'Moderate snowfall',
75: 'Heavy snowfall',
77: 'Snow grains',
80: 'Slight rain showers',
81: 'Moderate rain showers',
82: 'Violent rain showers',
85: 'Slight snow showers',
86: 'Heavy snow showers',
95: 'Thunderstorm',
96: 'Thunderstorm with slight hail',
99: 'Thunderstorm with heavy hail',
};
// --- Service functions ---
export async function getDashboardSummary(): Promise<DashboardSummary> {
const now = new Date();
const [
usersTotal, usersSuperAdmin, usersInfluenceAdmin, usersMapAdmin, usersUser, usersTemp,
usersActive, usersSuspended,
campaignsTotal, campaignsActive, campaignsDraft, campaignsPaused, campaignsArchived,
locationsTotal, locationsGeocoded, addressesTotal,
emailsTotal, emailsSent, emailsFailed, emailsQueued,
shiftsTotal, shiftsOpen, shiftsUpcoming,
canvassSessions, canvassVisits, canvassActive,
responsesTotal, responsesPending, responsesApproved,
videosTotal, videosPublished,
pagesTotal, pagesPublished,
emailTemplatesTotal,
cutsTotal,
repsCached,
campaignsPendingReview,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { role: 'SUPER_ADMIN' } }),
prisma.user.count({ where: { role: 'INFLUENCE_ADMIN' } }),
prisma.user.count({ where: { role: 'MAP_ADMIN' } }),
prisma.user.count({ where: { role: 'USER' } }),
prisma.user.count({ where: { role: 'TEMP' } }),
prisma.user.count({ where: { status: 'ACTIVE' } }),
prisma.user.count({ where: { status: 'SUSPENDED' } }),
prisma.campaign.count(),
prisma.campaign.count({ where: { status: 'ACTIVE' } }),
prisma.campaign.count({ where: { status: 'DRAFT' } }),
prisma.campaign.count({ where: { status: 'PAUSED' } }),
prisma.campaign.count({ where: { status: 'ARCHIVED' } }),
prisma.location.count(),
prisma.location.count({ where: { geocodeProvider: { not: null } } }),
prisma.address.count(),
prisma.campaignEmail.count(),
prisma.campaignEmail.count({ where: { status: 'SENT' } }),
prisma.campaignEmail.count({ where: { status: 'FAILED' } }),
prisma.campaignEmail.count({ where: { status: 'QUEUED' } }),
prisma.shift.count(),
prisma.shift.count({ where: { status: 'OPEN' } }),
prisma.shift.count({ where: { date: { gte: now }, status: { not: 'CANCELLED' } } }),
prisma.canvassSession.count(),
prisma.canvassVisit.count(),
prisma.canvassSession.count({ where: { status: 'ACTIVE' } }),
prisma.representativeResponse.count(),
prisma.representativeResponse.count({ where: { status: 'PENDING' } }),
prisma.representativeResponse.count({ where: { status: 'APPROVED' } }),
prisma.video.count(),
prisma.video.count({ where: { isPublished: true } }),
prisma.landingPage.count(),
prisma.landingPage.count({ where: { published: true } }),
prisma.emailTemplate.count(),
prisma.cut.count(),
prisma.representative.count(),
prisma.campaign.count({ where: { moderationStatus: 'PENDING_REVIEW' } }),
]);
return {
users: {
total: usersTotal,
byRole: {
SUPER_ADMIN: usersSuperAdmin, INFLUENCE_ADMIN: usersInfluenceAdmin,
MAP_ADMIN: usersMapAdmin, USER: usersUser, TEMP: usersTemp,
},
active: usersActive, suspended: usersSuspended,
},
campaigns: { total: campaignsTotal, active: campaignsActive, draft: campaignsDraft, paused: campaignsPaused, archived: campaignsArchived },
locations: { total: locationsTotal, geocoded: locationsGeocoded, addresses: addressesTotal },
emails: { total: emailsTotal, sent: emailsSent, failed: emailsFailed, queued: emailsQueued },
shifts: { total: shiftsTotal, open: shiftsOpen, upcoming: shiftsUpcoming },
canvass: { totalSessions: canvassSessions, totalVisits: canvassVisits, activeSessions: canvassActive },
responses: { total: responsesTotal, pending: responsesPending, approved: responsesApproved },
videos: { total: videosTotal, published: videosPublished },
pages: { total: pagesTotal, published: pagesPublished },
emailTemplates: { total: emailTemplatesTotal },
cuts: { total: cutsTotal },
representatives: { totalCached: repsCached },
campaignModeration: { pendingReview: campaignsPendingReview },
};
}
export function getSystemInfo(): SystemInfo {
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpus = os.cpus();
const mem = process.memoryUsage();
let disk: SystemInfo['disk'] = null;
try {
const stats = fs.statfsSync('/');
const totalBytes = stats.bsize * stats.blocks;
const freeBytes = stats.bsize * stats.bavail;
const usedBytes = totalBytes - freeBytes;
disk = {
totalGB: Math.round((totalBytes / 1073741824) * 10) / 10,
usedGB: Math.round((usedBytes / 1073741824) * 10) / 10,
freeGB: Math.round((freeBytes / 1073741824) * 10) / 10,
usagePercent: Math.round((usedBytes / totalBytes) * 100),
};
} catch {
// statfsSync may not be available on all platforms
}
return {
hostname: os.hostname(),
platform: `${os.type()} ${os.release()}`,
arch: os.arch(),
nodeVersion: process.version,
uptime: Math.floor(os.uptime()),
cpu: {
model: cpus[0]?.model?.trim() || 'Unknown',
cores: cpus.length,
loadAvg: os.loadavg().map(v => Math.round(v * 100) / 100),
},
memory: {
totalMB: Math.round(totalMem / 1048576),
usedMB: Math.round(usedMem / 1048576),
freeMB: Math.round(freeMem / 1048576),
usagePercent: Math.round((usedMem / totalMem) * 100),
},
disk,
process: {
heapUsedMB: Math.round(mem.heapUsed / 1048576),
heapTotalMB: Math.round(mem.heapTotal / 1048576),
rssMB: Math.round(mem.rss / 1048576),
uptimeSeconds: Math.floor(process.uptime()),
},
};
}
export async function getContainerStatuses(): Promise<ContainerInfo[]> {
const results = await Promise.all(
CONTAINERS.map(async (c) => {
try {
const status = await dockerService.getContainerStatus(c.name);
return { name: c.name, label: c.label, running: status.running, status: status.status };
} catch {
return { name: c.name, label: c.label, running: false, status: 'unknown' };
}
}),
);
return results;
}
export async function getWeather(): Promise<WeatherData | null> {
try {
const settings = await mapSettingsService.get();
const lat = settings.latitude ? Number(settings.latitude) : null;
const lng = settings.longitude ? Number(settings.longitude) : null;
if (!lat || !lng) return null;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,is_day&temperature_unit=celsius&wind_speed_unit=kmh`;
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!response.ok) return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await response.json() as { current: Record<string, any> };
const current = data.current;
return {
latitude: lat,
longitude: lng,
temperature: current.temperature_2m,
apparentTemperature: current.apparent_temperature,
humidity: current.relative_humidity_2m,
windSpeed: current.wind_speed_10m,
windDirection: current.wind_direction_10m,
weatherCode: current.weather_code,
weatherDescription: WMO_CODES[current.weather_code] || 'Unknown',
isDay: current.is_day === 1,
precipitation: current.precipitation,
cloudCover: current.cloud_cover,
time: current.time,
};
} catch (err) {
logger.warn('Failed to fetch weather data', err);
return null;
}
}
// --- Time-Series from Prometheus ---
/** Predefined metric key → PromQL mapping (prevents PromQL injection) */
const METRIC_QUERIES: Record<string, string> = {
request_rate_2xx: 'sum(rate(http_requests_total{status_code=~"2.."}[5m]))',
request_rate_4xx: 'sum(rate(http_requests_total{status_code=~"4.."}[5m]))',
request_rate_5xx: 'sum(rate(http_requests_total{status_code=~"5.."}[5m]))',
latency_p50: 'histogram_quantile(0.50, sum by(le) (rate(http_request_duration_seconds_bucket[5m])))',
latency_p95: 'histogram_quantile(0.95, sum by(le) (rate(http_request_duration_seconds_bucket[5m])))',
latency_p99: 'histogram_quantile(0.99, sum by(le) (rate(http_request_duration_seconds_bucket[5m])))',
email_sent_rate: 'rate(cm_emails_sent_total[5m])',
email_failed_rate: 'rate(cm_emails_failed_total[5m])',
email_queue_size: 'cm_email_queue_size',
cpu_usage: '100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
memory_usage: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
active_sessions: 'cm_active_sessions',
login_rate: 'rate(cm_login_attempts_total[5m])',
};
const ALLOWED_RANGES: Record<string, number> = { '1h': 3600, '6h': 21600, '24h': 86400 };
const ALLOWED_STEPS: Record<string, string> = { '1m': '60', '5m': '300', '15m': '900' };
export interface TimeSeriesPoint {
timestamps: number[];
values: number[];
}
export type TimeSeriesResult = Record<string, TimeSeriesPoint>;
export async function getTimeSeries(
metricKeys: string[],
range: string,
step: string,
): Promise<TimeSeriesResult> {
const online = await isServiceOnline(`${env.PROMETHEUS_URL}/api/v1/status/config`);
if (!online) return {};
const rangeSec = ALLOWED_RANGES[range];
const stepSec = ALLOWED_STEPS[step];
if (!rangeSec || !stepSec) return {};
const now = Math.floor(Date.now() / 1000);
const start = now - rangeSec;
const result: TimeSeriesResult = {};
await Promise.all(
metricKeys
.filter(k => METRIC_QUERIES[k])
.map(async (key) => {
try {
const query = METRIC_QUERIES[key];
const url = `${env.PROMETHEUS_URL}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${start}&end=${now}&step=${stepSec}`;
const response = await fetchWithTimeout(url, {}, 8000);
const data = await response.json() as {
data?: { result?: Array<{ values?: Array<[number, string]> }> };
};
const values = data?.data?.result?.[0]?.values || [];
result[key] = {
timestamps: values.map(v => v[0]),
values: values.map(v => {
const n = parseFloat(v[1]);
return isNaN(n) || !isFinite(n) ? 0 : Math.round(n * 1000) / 1000;
}),
};
} catch (err) {
logger.debug(`Failed to fetch time-series for ${key}`, err);
result[key] = { timestamps: [], values: [] };
}
}),
);
return result;
}
// --- Container Resources from cAdvisor Prometheus metrics ---
export interface ContainerResource {
name: string;
label: string;
cpuPercent: number;
memoryMB: number;
memoryLimitMB: number;
networkRxKBps: number;
networkTxKBps: number;
}
export async function getContainerResources(): Promise<ContainerResource[]> {
const online = await isServiceOnline(`${env.PROMETHEUS_URL}/api/v1/status/config`);
if (!online) return [];
const containerNames = CONTAINERS.map(c => c.name).join('|');
const nameFilter = `name=~"${containerNames}"`;
const queries = [
`rate(container_cpu_usage_seconds_total{${nameFilter}}[1m])`,
`container_memory_usage_bytes{${nameFilter}}`,
`container_spec_memory_limit_bytes{${nameFilter}}`,
`rate(container_network_receive_bytes_total{${nameFilter}}[1m])`,
`rate(container_network_transmit_bytes_total{${nameFilter}}[1m])`,
];
try {
const results = await Promise.all(
queries.map(async (q) => {
const response = await fetchWithTimeout(
`${env.PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(q)}`,
{},
5000,
);
const data = await response.json() as {
data?: { result?: Array<{ metric?: { name?: string }; value?: [number, string] }> };
};
return data?.data?.result || [];
}),
);
const [cpuResults, memResults, memLimitResults, rxResults, txResults] = results;
const getVal = (results: typeof cpuResults, containerName: string): number => {
const item = results.find(r => r.metric?.name === containerName);
const v = parseFloat(item?.value?.[1] || '0');
return isNaN(v) || !isFinite(v) ? 0 : v;
};
return CONTAINERS.map(c => ({
name: c.name,
label: c.label,
cpuPercent: Math.round(getVal(cpuResults, c.name) * 100 * 100) / 100,
memoryMB: Math.round(getVal(memResults, c.name) / 1048576),
memoryLimitMB: Math.round(getVal(memLimitResults, c.name) / 1048576),
networkRxKBps: Math.round(getVal(rxResults, c.name) / 1024 * 100) / 100,
networkTxKBps: Math.round(getVal(txResults, c.name) / 1024 * 100) / 100,
}));
} catch (err) {
logger.warn('Failed to fetch container resources', err);
return [];
}
}
// --- API Metrics from Prometheus ---
export interface ApiMetrics {
requestRate: number; // req/s over last 5m
errorRate: number; // 4xx+5xx rate
avgLatencyMs: number; // average response time
p95LatencyMs: number; // 95th percentile
topRoutes: { method: string; route: string; count: number }[];
slowRoutes: { method: string; route: string; p95Ms: number }[];
statusBreakdown: { status: string; count: number }[];
}
async function queryPrometheus(query: string): Promise<any> {
const response = await fetchWithTimeout(
`${env.PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`,
{},
5000,
);
return response.json();
}
export async function getApiMetrics(): Promise<ApiMetrics | null> {
try {
const online = await isServiceOnline(`${env.PROMETHEUS_URL}/api/v1/status/config`);
if (!online) return null;
const queries = [
// Total request rate
'sum(rate(http_requests_total[5m]))',
// Error rate (4xx + 5xx)
'sum(rate(http_requests_total{status_code=~"[45].."}[5m]))',
// Average latency
'sum(rate(http_request_duration_seconds_sum[5m])) / sum(rate(http_request_duration_seconds_count[5m]))',
// P95 latency
'histogram_quantile(0.95, sum by(le) (rate(http_request_duration_seconds_bucket[5m])))',
// Top 10 routes by request count (last 15m for more data)
'topk(10, sum by(method, route) (increase(http_requests_total[15m])))',
// Slowest 10 routes by p95
'topk(10, histogram_quantile(0.95, sum by(method, route, le) (rate(http_request_duration_seconds_bucket[5m]))))',
// Status code breakdown
'sum by(status_code) (increase(http_requests_total[15m]))',
];
validatePromQLQueries(queries);
const [rateRes, errorRes, avgLatRes, p95Res, topRes, slowRes, statusRes] = await Promise.all(
queries.map(q => queryPrometheus(q).catch(() => null)),
);
const extractScalar = (res: any): number => {
const val = parseFloat(res?.data?.result?.[0]?.value?.[1] || '0');
return isNaN(val) || !isFinite(val) ? 0 : val;
};
// Parse top routes
const topRoutes: ApiMetrics['topRoutes'] = [];
for (const item of (topRes?.data?.result || [])) {
const route = item.metric?.route || '';
// Skip internal routes
if (route === '/api/metrics' || route === '/api/health') continue;
const count = parseFloat(item.value?.[1] || '0');
if (count > 0) {
topRoutes.push({
method: item.metric?.method || '?',
route,
count: Math.round(count),
});
}
}
// Parse slow routes
const slowRoutes: ApiMetrics['slowRoutes'] = [];
for (const item of (slowRes?.data?.result || [])) {
const route = item.metric?.route || '';
if (route === '/api/metrics' || route === '/api/health') continue;
const p95 = parseFloat(item.value?.[1] || '0');
if (p95 > 0 && isFinite(p95)) {
slowRoutes.push({
method: item.metric?.method || '?',
route,
p95Ms: Math.round(p95 * 1000),
});
}
}
slowRoutes.sort((a, b) => b.p95Ms - a.p95Ms);
// Parse status breakdown
const statusBreakdown: ApiMetrics['statusBreakdown'] = [];
for (const item of (statusRes?.data?.result || [])) {
const count = parseFloat(item.value?.[1] || '0');
if (count > 0) {
statusBreakdown.push({
status: item.metric?.status_code || '?',
count: Math.round(count),
});
}
}
statusBreakdown.sort((a, b) => b.count - a.count);
return {
requestRate: Math.round(extractScalar(rateRes) * 100) / 100,
errorRate: Math.round(extractScalar(errorRes) * 100) / 100,
avgLatencyMs: Math.round(extractScalar(avgLatRes) * 1000),
p95LatencyMs: Math.round(extractScalar(p95Res) * 1000),
topRoutes: topRoutes.slice(0, 8),
slowRoutes: slowRoutes.slice(0, 5),
statusBreakdown,
};
} catch (err) {
logger.warn('Failed to fetch API metrics from Prometheus', err);
return null;
}
}

View File

@ -0,0 +1,58 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { campaignsService } from './campaigns.service';
import { listModerationQueueSchema, moderateCampaignSchema } from './campaigns.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
// GET /api/campaigns/moderation/queue — list moderation queue
router.get(
'/moderation/queue',
validate(listModerationQueueSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await campaignsService.findModerationQueue(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
}
);
// GET /api/campaigns/moderation/stats — moderation stats
router.get(
'/moderation/stats',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const stats = await campaignsService.getModerationStats();
res.json(stats);
} catch (err) {
next(err);
}
}
);
// PATCH /api/campaigns/moderation/:id — moderate a campaign
router.patch(
'/moderation/:id',
validate(moderateCampaignSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!);
res.json(campaign);
} catch (err) {
next(err);
}
}
);
export { router as campaignModerationRouter };

View File

@ -0,0 +1,77 @@
import { Router, Request, Response, NextFunction } from 'express';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../../config/redis';
import { campaignsService } from './campaigns.service';
import { createUserCampaignSchema, updateUserCampaignSchema } from './campaigns.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireNonTemp } from '../../../middleware/rbac.middleware';
const campaignSubmitRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:campaign-submit:',
}),
message: {
error: {
message: 'Too many campaign submissions, please try again later',
code: 'CAMPAIGN_SUBMIT_RATE_LIMIT_EXCEEDED',
},
},
});
const router = Router();
// All user campaign routes require auth + non-temp
router.use(authenticate);
router.use(requireNonTemp);
// POST /api/campaigns/user/submit — create a user-generated campaign
router.post(
'/user/submit',
campaignSubmitRateLimit,
validate(createUserCampaignSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const campaign = await campaignsService.createUserCampaign(req.body, req.user!);
res.status(201).json(campaign);
} catch (err) {
next(err);
}
}
);
// GET /api/campaigns/user/my-campaigns — list own campaigns
router.get(
'/user/my-campaigns',
async (req: Request, res: Response, next: NextFunction) => {
try {
const campaigns = await campaignsService.findUserCampaigns(req.user!.id);
res.json(campaigns);
} catch (err) {
next(err);
}
}
);
// PUT /api/campaigns/user/:id — edit own campaign
router.put(
'/user/:id',
validate(updateUserCampaignSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await campaignsService.updateUserCampaign(id, req.body, req.user!);
res.json(campaign);
} catch (err) {
next(err);
}
}
);
export { router as campaignUserRouter };

View File

@ -1,5 +1,5 @@
import { z } from 'zod';
import { CampaignStatus, GovernmentLevel } from '@prisma/client';
import { CampaignStatus, CampaignModerationStatus, GovernmentLevel } from '@prisma/client';
export const createCampaignSchema = z.object({
title: z.string().min(1, 'Title is required'),
@ -52,6 +52,38 @@ export const campaignIdSchema = z.object({
id: z.string().min(1),
});
// User-submitted campaign (restricted fields)
export const createUserCampaignSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters').max(200),
description: z.string().max(2000).optional(),
emailSubject: z.string().min(3, 'Email subject is required').max(200),
emailBody: z.string().min(10, 'Email body must be at least 10 characters').max(5000),
callToAction: z.string().max(500).optional(),
targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).min(1, 'Select at least one government level'),
});
// Update own user campaign (same restricted fields)
export const updateUserCampaignSchema = createUserCampaignSchema.partial();
// Admin moderation action
export const moderateCampaignSchema = z.object({
action: z.enum(['approve', 'reject', 'request_changes']),
reason: z.string().max(2000).optional(),
notes: z.string().max(2000).optional(),
});
// Moderation queue filters
export const listModerationQueueSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
moderationStatus: z.nativeEnum(CampaignModerationStatus).optional(),
});
export type CreateCampaignInput = z.infer<typeof createCampaignSchema>;
export type UpdateCampaignInput = z.infer<typeof updateCampaignSchema>;
export type ListCampaignsInput = z.infer<typeof listCampaignsSchema>;
export type CreateUserCampaignInput = z.infer<typeof createUserCampaignSchema>;
export type UpdateUserCampaignInput = z.infer<typeof updateUserCampaignSchema>;
export type ModerateCampaignInput = z.infer<typeof moderateCampaignSchema>;
export type ListModerationQueueInput = z.infer<typeof listModerationQueueSchema>;

View File

@ -1,7 +1,20 @@
import { Prisma, UserRole } from '@prisma/client';
import { Prisma, UserRole, CampaignModerationStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import type { CreateCampaignInput, UpdateCampaignInput, ListCampaignsInput } from './campaigns.schemas';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import type {
CreateCampaignInput, UpdateCampaignInput, ListCampaignsInput,
CreateUserCampaignInput, UpdateUserCampaignInput, ModerateCampaignInput, ListModerationQueueInput,
} from './campaigns.schemas';
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
const campaignSelect = {
id: true,
@ -26,6 +39,12 @@ const campaignSelect = {
createdByUserId: true,
createdByUserEmail: true,
createdByUserName: true,
isUserGenerated: true,
moderationStatus: true,
reviewedByUserId: true,
reviewedAt: true,
rejectionReason: true,
moderationNotes: true,
createdAt: true,
updatedAt: true,
_count: {
@ -86,8 +105,7 @@ export const campaignsService = {
if (status) where.status = status;
// Non-admin users only see their own campaigns
const adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
if (user && !adminRoles.includes(user.role)) {
if (user && !hasAnyRole(user, ADMIN_ROLES)) {
where.createdByUserId = user.id;
}
@ -238,4 +256,177 @@ export const campaignsService = {
await prisma.campaign.delete({ where: { id } });
},
// --- User-Generated Campaign Methods ---
async createUserCampaign(data: CreateUserCampaignInput, user: AuthUser) {
const baseSlug = generateSlug(data.title);
const slug = await resolveSlugCollision(baseSlug);
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { name: true },
});
const campaign = await prisma.campaign.create({
data: {
slug,
title: escapeHtml(data.title),
description: data.description ? escapeHtml(data.description) : null,
emailSubject: escapeHtml(data.emailSubject),
emailBody: escapeHtml(data.emailBody),
callToAction: data.callToAction ? escapeHtml(data.callToAction) : null,
targetGovernmentLevels: data.targetGovernmentLevels,
status: 'DRAFT',
isUserGenerated: true,
moderationStatus: CampaignModerationStatus.PENDING_REVIEW,
allowSmtpEmail: false,
allowMailtoLink: true,
collectUserInfo: true,
showEmailCount: true,
showCallCount: false,
allowEmailEditing: false,
allowCustomRecipients: false,
showResponseWall: false,
highlightCampaign: false,
createdByUserId: user.id,
createdByUserEmail: user.email,
createdByUserName: dbUser?.name ?? null,
},
select: campaignSelect,
});
return campaign;
},
async findUserCampaigns(userId: string) {
return prisma.campaign.findMany({
where: { createdByUserId: userId, isUserGenerated: true },
select: campaignSelect,
orderBy: { createdAt: 'desc' },
});
},
async updateUserCampaign(id: string, data: Partial<CreateUserCampaignInput>, user: AuthUser) {
const existing = await prisma.campaign.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
if (existing.createdByUserId !== user.id) {
throw new AppError(403, 'You can only edit your own campaigns', 'FORBIDDEN');
}
if (!existing.isUserGenerated) {
throw new AppError(403, 'Cannot edit admin-created campaigns', 'FORBIDDEN');
}
if (
existing.moderationStatus !== CampaignModerationStatus.CHANGES_REQUESTED &&
existing.moderationStatus !== CampaignModerationStatus.PENDING_REVIEW
) {
throw new AppError(400, 'Campaign cannot be edited in its current state', 'INVALID_STATE');
}
const updateData: Prisma.CampaignUncheckedUpdateInput = {};
if (data.title) {
updateData.title = escapeHtml(data.title);
const baseSlug = generateSlug(data.title);
updateData.slug = await resolveSlugCollision(baseSlug, id);
}
if (data.description !== undefined) updateData.description = data.description ? escapeHtml(data.description) : null;
if (data.emailSubject) updateData.emailSubject = escapeHtml(data.emailSubject);
if (data.emailBody) updateData.emailBody = escapeHtml(data.emailBody);
if (data.callToAction !== undefined) updateData.callToAction = data.callToAction ? escapeHtml(data.callToAction) : null;
if (data.targetGovernmentLevels) updateData.targetGovernmentLevels = data.targetGovernmentLevels;
// Reset to pending review on edit
updateData.moderationStatus = CampaignModerationStatus.PENDING_REVIEW;
updateData.rejectionReason = null;
return prisma.campaign.update({
where: { id },
data: updateData,
select: campaignSelect,
});
},
// --- Moderation Methods ---
async findModerationQueue(filters: ListModerationQueueInput) {
const { page, limit, search, moderationStatus } = filters;
const skip = (page - 1) * limit;
const where: Prisma.CampaignWhereInput = { isUserGenerated: true };
if (moderationStatus) where.moderationStatus = moderationStatus;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ createdByUserName: { contains: search, mode: 'insensitive' } },
{ createdByUserEmail: { contains: search, mode: 'insensitive' } },
];
}
const [campaigns, total] = await Promise.all([
prisma.campaign.findMany({
where,
select: campaignSelect,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.campaign.count({ where }),
]);
return {
campaigns,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getModerationStats() {
const [total, pending, approved, rejected, changesRequested] = await Promise.all([
prisma.campaign.count({ where: { isUserGenerated: true } }),
prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.PENDING_REVIEW } }),
prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.APPROVED } }),
prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.REJECTED } }),
prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.CHANGES_REQUESTED } }),
]);
return { total, pending, approved, rejected, changesRequested };
},
async moderateCampaign(id: string, input: ModerateCampaignInput, reviewer: AuthUser) {
const existing = await prisma.campaign.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
if (!existing.isUserGenerated) {
throw new AppError(400, 'Only user-generated campaigns can be moderated', 'INVALID_STATE');
}
const updateData: Prisma.CampaignUncheckedUpdateInput = {
reviewedByUserId: reviewer.id,
reviewedAt: new Date(),
moderationNotes: input.notes ?? null,
};
switch (input.action) {
case 'approve':
updateData.moderationStatus = CampaignModerationStatus.APPROVED;
updateData.status = 'ACTIVE';
updateData.rejectionReason = null;
break;
case 'reject':
updateData.moderationStatus = CampaignModerationStatus.REJECTED;
updateData.rejectionReason = input.reason ?? null;
break;
case 'request_changes':
updateData.moderationStatus = CampaignModerationStatus.CHANGES_REQUESTED;
updateData.rejectionReason = input.reason ?? null;
break;
}
return prisma.campaign.update({
where: { id },
data: updateData,
select: campaignSelect,
});
},
};

View File

@ -189,18 +189,18 @@ volunteerRouter.post(
validate(volunteerCreateLocationSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const role = req.user!.role;
const data = { ...req.body };
// Strip fields based on role
const isAdmin = role === UserRole.SUPER_ADMIN || role === UserRole.MAP_ADMIN;
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some((r: string) => r === UserRole.SUPER_ADMIN || r === UserRole.MAP_ADMIN);
if (!isAdmin) {
delete data.firstName;
delete data.lastName;
delete data.email;
delete data.phone;
}
if (role === UserRole.TEMP) {
if (userRoles.length === 1 && userRoles[0] === UserRole.TEMP) {
delete data.supportLevel;
delete data.sign;
delete data.signSize;

View File

@ -0,0 +1,89 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { randomUUID } from 'crypto';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { areaImportPreviewSchema, areaImportStartSchema } from './area-import.schemas';
import { areaImportService, type AreaImportProgress } from './area-import.service';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
const areaImportRouter = Router();
areaImportRouter.use(authenticate);
areaImportRouter.use(requireRole(...MAP_ADMIN_ROLES));
// POST /api/map/area-import/preview — get bounds, estimates, and existing count
areaImportRouter.post(
'/preview',
validate(areaImportPreviewSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await areaImportService.previewAreaImport(req.body);
res.json(result);
} catch (err) {
next(err);
}
},
);
// POST /api/map/area-import — start import (fire-and-forget, returns importId)
areaImportRouter.post(
'/',
validate(areaImportStartSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const importId = randomUUID();
const userId = req.user!.id;
// Write initial progress so status endpoint works immediately
const initialProgress: AreaImportProgress = {
status: 'initializing',
bounds: null,
areaSqKm: 0,
sources: {
osm: { status: 'pending', candidatesFound: 0 },
nar: { status: 'pending', candidatesFound: 0 },
reverseGeocode: { status: 'pending', candidatesFound: 0 },
},
locationsCreated: 0,
addressesCreated: 0,
skippedDuplicate: 0,
totalCandidates: 0,
};
await redis.set(`area-import:${importId}`, JSON.stringify(initialProgress), 'EX', 3600);
// Fire and forget
areaImportService.runAreaImport(userId, req.body, importId).catch((err) => {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
logger.error(`Area import ${importId} failed: ${errorMsg}`);
});
res.json({ importId });
} catch (err) {
next(err);
}
},
);
// GET /api/map/area-import/status/:importId — poll import progress
areaImportRouter.get(
'/status/:importId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const importId = req.params.importId as string;
const progress = await areaImportService.getProgress(importId);
if (!progress) {
res.status(404).json({ error: { message: 'Import not found or expired', code: 'NOT_FOUND' } });
return;
}
res.json(progress);
} catch (err) {
next(err);
}
},
);
export { areaImportRouter };

View File

@ -0,0 +1,57 @@
import { z } from 'zod';
const sourcesSchema = z.object({
osm: z.boolean().default(false),
nar: z.union([
z.boolean(),
z.object({
residentialOnly: z.boolean().default(true),
}),
]).default(false),
reverseGeocode: z.union([
z.boolean(),
z.object({
gridSpacingMeters: z.number().min(20).max(500).default(100),
maxPoints: z.number().min(10).max(2000).default(500),
}),
]).default(false),
});
export const areaImportPreviewSchema = z.object({
areaType: z.enum(['cut', 'viewport']),
cutId: z.string().optional(),
center: z.object({ lat: z.number(), lng: z.number() }).optional(),
zoom: z.number().optional(),
viewportWidth: z.number().optional(),
viewportHeight: z.number().optional(),
sources: sourcesSchema,
}).refine(
(data) => {
if (data.areaType === 'cut') return !!data.cutId;
if (data.areaType === 'viewport') return data.center && data.zoom !== undefined;
return false;
},
{ message: 'Cut ID required for cut area type, center+zoom required for viewport type' },
);
export const areaImportStartSchema = z.object({
areaType: z.enum(['cut', 'viewport']),
cutId: z.string().optional(),
center: z.object({ lat: z.number(), lng: z.number() }).optional(),
zoom: z.number().optional(),
viewportWidth: z.number().optional(),
viewportHeight: z.number().optional(),
sources: sourcesSchema,
deduplicateRadius: z.number().min(0).max(100).default(5),
batchSize: z.number().int().min(100).max(5000).default(1000),
}).refine(
(data) => {
if (data.areaType === 'cut') return !!data.cutId;
if (data.areaType === 'viewport') return data.center && data.zoom !== undefined;
return false;
},
{ message: 'Cut ID required for cut area type, center+zoom required for viewport type' },
);
export type AreaImportPreviewInput = z.infer<typeof areaImportPreviewSchema>;
export type AreaImportStartInput = z.infer<typeof areaImportStartSchema>;

View File

@ -0,0 +1,671 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../../config/database';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
import { env } from '../../../config/env';
import {
calculateBounds,
parseGeoJsonPolygon,
boundsFromCenterZoom,
isPointInPolygon,
haversineDistance,
} from '../../../utils/spatial';
import { overpassService, type CandidateLocation } from './overpass.service';
import { narImportService } from './nar-import.service';
import { geocodingService } from '../geocoding/geocoding.service';
import type { AreaImportPreviewInput, AreaImportStartInput } from './area-import.schemas';
// ---- Types ----
export type SourceStatus = 'pending' | 'running' | 'complete' | 'failed' | 'skipped';
export interface AreaImportSourceProgress {
status: SourceStatus;
candidatesFound: number;
message?: string;
error?: string;
}
export interface AreaImportProgress {
status: 'initializing' | 'running' | 'creating-records' | 'complete' | 'failed';
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number } | null;
areaSqKm: number;
sources: {
osm: AreaImportSourceProgress;
nar: AreaImportSourceProgress;
reverseGeocode: AreaImportSourceProgress;
};
locationsCreated: number;
addressesCreated: number;
skippedDuplicate: number;
totalCandidates: number;
error?: string;
}
export interface AreaImportPreviewResult {
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
areaSqKm: number;
existingLocations: number;
estimates: {
osm: number;
nar: number;
reverseGeocode: number;
};
narProvincesDetected: string[];
}
// ---- Helpers ----
const PROGRESS_TTL = 3600; // 1 hour
const PROGRESS_KEY_PREFIX = 'area-import:';
function roundCoord(val: number, decimals: number = 5): number {
const factor = Math.pow(10, decimals);
return Math.round(val * factor) / factor;
}
function coordKey(lat: number, lng: number): string {
return `${roundCoord(lat)}:${roundCoord(lng)}`;
}
function areaSqKm(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): number {
// Approximate area in sq km using lat/lng → meters
const latMeters = (bounds.maxLat - bounds.minLat) * 111320;
const avgLat = (bounds.minLat + bounds.maxLat) / 2;
const lngMeters = (bounds.maxLng - bounds.minLng) * 111320 * Math.cos((avgLat * Math.PI) / 180);
return (latMeters * lngMeters) / 1_000_000;
}
async function writeProgress(importId: string, progress: AreaImportProgress): Promise<void> {
try {
await redis.set(
`${PROGRESS_KEY_PREFIX}${importId}`,
JSON.stringify(progress),
'EX',
PROGRESS_TTL,
);
} catch (err) {
logger.warn('Failed to write area import progress to Redis', err);
}
}
/**
* Resolve bounding box from area type options.
*/
async function resolveBounds(
options: AreaImportPreviewInput | AreaImportStartInput,
): Promise<{
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
cutPolygons?: number[][][];
}> {
if (options.areaType === 'cut' && options.cutId) {
const cut = await prisma.cut.findUnique({ where: { id: options.cutId } });
if (!cut) throw new Error('Cut not found');
const polygons = parseGeoJsonPolygon(cut.geojson);
// Flatten all polygon coordinates for bounds calculation
const allCoords = polygons.flat();
const bounds = calculateBounds(allCoords);
return { bounds, cutPolygons: polygons };
}
if (options.areaType === 'viewport' && options.center && options.zoom !== undefined) {
const bounds = boundsFromCenterZoom(
options.center.lat,
options.center.lng,
options.zoom,
options.viewportWidth,
options.viewportHeight,
);
return { bounds };
}
throw new Error('Invalid area type configuration');
}
/**
* Load existing location coordinates within bounds for deduplication.
*/
async function loadExistingCoords(
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): Promise<Set<string>> {
const existing = await prisma.location.findMany({
where: {
latitude: { gte: new Prisma.Decimal(bounds.minLat.toString()), lte: new Prisma.Decimal(bounds.maxLat.toString()) },
longitude: { gte: new Prisma.Decimal(bounds.minLng.toString()), lte: new Prisma.Decimal(bounds.maxLng.toString()) },
},
select: { latitude: true, longitude: true },
});
const coords = new Set<string>();
for (const loc of existing) {
coords.add(coordKey(Number(loc.latitude), Number(loc.longitude)));
}
return coords;
}
/**
* Rough province bounding boxes for auto-detecting which NAR datasets
* might overlap with the import area.
*/
const PROVINCE_BOUNDS: Record<string, { minLat: number; maxLat: number; minLng: number; maxLng: number }> = {
'10': { minLat: 46.6, maxLat: 60.4, minLng: -67.8, maxLng: -52.6 }, // NL
'11': { minLat: 45.9, maxLat: 47.1, minLng: -64.4, maxLng: -62.0 }, // PE
'12': { minLat: 43.4, maxLat: 47.0, minLng: -66.4, maxLng: -59.7 }, // NS
'13': { minLat: 44.6, maxLat: 48.1, minLng: -69.1, maxLng: -63.8 }, // NB
'24': { minLat: 45.0, maxLat: 62.6, minLng: -79.8, maxLng: -57.1 }, // QC
'35': { minLat: 41.7, maxLat: 56.9, minLng: -95.2, maxLng: -74.3 }, // ON
'46': { minLat: 49.0, maxLat: 60.0, minLng: -102.0, maxLng: -88.9 }, // MB
'47': { minLat: 49.0, maxLat: 60.0, minLng: -110.0, maxLng: -101.4 }, // SK
'48': { minLat: 49.0, maxLat: 60.0, minLng: -120.0, maxLng: -110.0 }, // AB
'59': { minLat: 48.3, maxLat: 60.0, minLng: -139.1, maxLng: -114.1 }, // BC
'60': { minLat: 60.0, maxLat: 69.6, minLng: -141.0, maxLng: -124.0 }, // YT
'61': { minLat: 60.0, maxLat: 78.8, minLng: -136.5, maxLng: -102.0 }, // NT
'62': { minLat: 51.7, maxLat: 83.1, minLng: -120.4, maxLng: -61.2 }, // NU
};
function detectOverlappingProvinces(
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): string[] {
const overlapping: string[] = [];
for (const [code, pb] of Object.entries(PROVINCE_BOUNDS)) {
const overlaps = bounds.minLat <= pb.maxLat && bounds.maxLat >= pb.minLat &&
bounds.minLng <= pb.maxLng && bounds.maxLng >= pb.minLng;
if (overlaps) overlapping.push(code);
}
return overlapping;
}
/**
* Generate grid points within bounds for reverse geocoding.
*/
function generateGridPoints(
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
spacingMeters: number,
maxPoints: number,
cutPolygons?: number[][][],
): { lat: number; lng: number }[] {
const avgLat = (bounds.minLat + bounds.maxLat) / 2;
const latStep = spacingMeters / 111320;
const lngStep = spacingMeters / (111320 * Math.cos((avgLat * Math.PI) / 180));
const points: { lat: number; lng: number }[] = [];
for (let lat = bounds.minLat; lat <= bounds.maxLat; lat += latStep) {
for (let lng = bounds.minLng; lng <= bounds.maxLng; lng += lngStep) {
if (points.length >= maxPoints) break;
// If cut polygon provided, only include points inside it
if (cutPolygons && cutPolygons.length > 0) {
const inside = cutPolygons.some((ring) => isPointInPolygon(lat, lng, ring));
if (!inside) continue;
}
points.push({ lat, lng });
}
if (points.length >= maxPoints) break;
}
return points;
}
// ---- Main Service ----
export const areaImportService = {
/**
* Preview area import: returns bounds, area, estimates, and existing count.
*/
async previewAreaImport(options: AreaImportPreviewInput): Promise<AreaImportPreviewResult> {
const { bounds, cutPolygons } = await resolveBounds(options);
const area = areaSqKm(bounds);
// Count existing locations in bounds
const existingCount = await prisma.location.count({
where: {
latitude: { gte: new Prisma.Decimal(bounds.minLat.toString()), lte: new Prisma.Decimal(bounds.maxLat.toString()) },
longitude: { gte: new Prisma.Decimal(bounds.minLng.toString()), lte: new Prisma.Decimal(bounds.maxLng.toString()) },
},
});
const estimates = { osm: 0, nar: 0, reverseGeocode: 0 };
const narProvincesDetected: string[] = [];
// OSM estimate
if (options.sources.osm) {
const count = await overpassService.estimateCount(bounds);
estimates.osm = count >= 0 ? count : -1;
}
// NAR estimate: detect provinces, count total address file sizes as rough guide
if (options.sources.nar) {
const overlapping = detectOverlappingProvinces(bounds);
narProvincesDetected.push(...overlapping);
if (overlapping.length > 0) {
const { datasets } = await narImportService.listDatasets();
for (const code of overlapping) {
const ds = datasets.find((d) => d.provinceCode === code);
if (ds && ds.addressFiles.length > 0) {
// Very rough estimate: ~50 bytes per address row, filtered by area ratio
const provinceArea = areaSqKm(PROVINCE_BOUNDS[code]!);
const overlapRatio = Math.min(1, area / provinceArea);
const totalRows = ds.totalAddressSize / 50;
estimates.nar += Math.round(totalRows * overlapRatio);
}
}
}
}
// Reverse geocode estimate: number of grid points
if (options.sources.reverseGeocode) {
const rgConfig = typeof options.sources.reverseGeocode === 'object'
? options.sources.reverseGeocode
: { gridSpacingMeters: 100, maxPoints: env.AREA_IMPORT_MAX_GRID_POINTS };
const points = generateGridPoints(bounds, rgConfig.gridSpacingMeters, rgConfig.maxPoints, cutPolygons);
estimates.reverseGeocode = points.length;
}
return { bounds, areaSqKm: area, existingLocations: existingCount, estimates, narProvincesDetected };
},
/**
* Get import progress from Redis.
*/
async getProgress(importId: string): Promise<AreaImportProgress | null> {
const data = await redis.get(`${PROGRESS_KEY_PREFIX}${importId}`);
if (!data) return null;
return JSON.parse(data) as AreaImportProgress;
},
/**
* Run the full area import (fire-and-forget).
*/
async runAreaImport(
userId: string,
options: AreaImportStartInput,
importId: string,
): Promise<void> {
const progress: AreaImportProgress = {
status: 'initializing',
bounds: null,
areaSqKm: 0,
sources: {
osm: { status: 'pending', candidatesFound: 0 },
nar: { status: 'pending', candidatesFound: 0 },
reverseGeocode: { status: 'pending', candidatesFound: 0 },
},
locationsCreated: 0,
addressesCreated: 0,
skippedDuplicate: 0,
totalCandidates: 0,
};
const updateProgress = async (updates?: Partial<AreaImportProgress>) => {
if (updates) Object.assign(progress, updates);
await writeProgress(importId, progress);
};
try {
// 1. Resolve bounds
const { bounds, cutPolygons } = await resolveBounds(options);
const area = areaSqKm(bounds);
await updateProgress({ bounds, areaSqKm: area, status: 'running' });
// 2. Load existing coords for dedup
const existingCoords = options.deduplicateRadius > 0
? await loadExistingCoords(bounds)
: new Set<string>();
// 3. Run enabled sources in parallel (OSM=network, NAR=disk — they don't compete)
const allCandidates: CandidateLocation[] = [];
// Mark skipped sources
if (!options.sources.osm) progress.sources.osm.status = 'skipped';
if (!options.sources.nar) progress.sources.nar.status = 'skipped';
if (!options.sources.reverseGeocode) progress.sources.reverseGeocode.status = 'skipped';
await updateProgress();
const sourcePromises: Promise<void>[] = [];
// --- OSM Source ---
if (options.sources.osm) {
sourcePromises.push((async () => {
progress.sources.osm.status = 'running';
await updateProgress();
try {
const osmCandidates = await overpassService.queryArea(bounds, (msg) => {
progress.sources.osm.message = msg;
writeProgress(importId, progress).catch(() => {});
});
// Filter by cut polygon if applicable
let filtered = osmCandidates;
if (cutPolygons && cutPolygons.length > 0) {
filtered = osmCandidates.filter((c) =>
cutPolygons.some((ring) => isPointInPolygon(c.latitude, c.longitude, ring)),
);
}
allCandidates.push(...filtered);
progress.sources.osm.status = 'complete';
progress.sources.osm.candidatesFound = filtered.length;
await updateProgress();
logger.info(`OSM source: ${filtered.length} candidates (${osmCandidates.length} pre-filter)`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
progress.sources.osm.status = 'failed';
progress.sources.osm.error = msg;
await updateProgress();
logger.error(`OSM source failed: ${msg}`);
}
})());
}
// --- NAR Source ---
if (options.sources.nar) {
sourcePromises.push((async () => {
progress.sources.nar.status = 'running';
progress.sources.nar.message = 'Detecting provinces...';
await updateProgress();
try {
const overlapping = detectOverlappingProvinces(bounds);
if (overlapping.length === 0) {
progress.sources.nar.status = 'complete';
progress.sources.nar.message = 'No NAR data for this area';
await updateProgress();
return;
}
const { datasets } = await narImportService.listDatasets();
const narCandidates: CandidateLocation[] = [];
const narConfig = typeof options.sources.nar === 'object' ? options.sources.nar : { residentialOnly: true };
for (const code of overlapping) {
const dataset = datasets.find((d) => d.provinceCode === code);
if (!dataset || dataset.addressFiles.length === 0) continue;
progress.sources.nar.message = `Processing ${dataset.provinceName}...`;
await updateProgress();
// Load location lookup for this province
const locationLookup = dataset.locationFiles.length > 0
? await narImportService.loadLocationLookup(dataset.locationFiles)
: new Map<string, { lat: number; lng: number; fedDistrict?: string }>();
// Stream address files and filter by bounds
for (const addressFile of dataset.addressFiles) {
const { parse } = await import('csv-parse');
const fs = await import('fs');
const parser = fs.createReadStream(addressFile.fullPath).pipe(
parse({ columns: true, skip_empty_lines: true, trim: true, bom: true }),
);
for await (const record of parser) {
const locGuid = (record.LOC_GUID ?? '').trim();
if (!locGuid) continue;
// Residential filter
const buUse = parseInt(record.BU_USE ?? '', 10);
if (narConfig.residentialOnly && buUse === 3) continue;
// Get coordinates
let lat: number | undefined;
let lng: number | undefined;
let federalDistrict: string | undefined;
const locData = locationLookup.get(locGuid);
if (locData) {
lat = locData.lat;
lng = locData.lng;
federalDistrict = locData.fedDistrict;
}
if (lat === undefined || lng === undefined) continue;
// Bounds filter
if (lat < bounds.minLat || lat > bounds.maxLat || lng < bounds.minLng || lng > bounds.maxLng) continue;
// Cut polygon filter
if (cutPolygons && cutPolygons.length > 0) {
const inside = cutPolygons.some((ring) => isPointInPolygon(lat!, lng!, ring));
if (!inside) continue;
}
// Build address string
const civicNo = (record.CIVIC_NO ?? '').trim();
const civicSuffix = (record.CIVIC_NO_SUFFIX ?? '').trim();
const streetName = (record.OFFICIAL_STREET_NAME ?? '').trim();
const streetType = (record.OFFICIAL_STREET_TYPE ?? '').trim();
const streetDir = (record.OFFICIAL_STREET_DIR ?? '').trim();
const city = (record.MAIL_MUN_NAME ?? record.CSD_ENG_NAME ?? '').trim();
const prov = (record.MAIL_PROV_ABVN ?? '').trim();
const postalCode = (record.MAIL_POSTAL_CODE ?? '').trim() || undefined;
if (!streetName) continue;
const streetParts = [
civicNo + (civicSuffix || ''),
streetName,
streetType,
streetDir,
].filter(Boolean);
let address = streetParts.join(' ');
if (city) address += `, ${city}`;
if (prov) address += `, ${prov}`;
narCandidates.push({
latitude: lat,
longitude: lng,
address,
postalCode,
city: city || undefined,
province: prov || undefined,
source: 'nar',
confidence: 90,
priority: 3,
});
}
}
}
allCandidates.push(...narCandidates);
progress.sources.nar.status = 'complete';
progress.sources.nar.candidatesFound = narCandidates.length;
await updateProgress();
logger.info(`NAR source: ${narCandidates.length} candidates`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
progress.sources.nar.status = 'failed';
progress.sources.nar.error = msg;
await updateProgress();
logger.error(`NAR source failed: ${msg}`);
}
})());
}
// Wait for OSM and NAR to finish before starting reverse geocode
await Promise.all(sourcePromises);
// --- Reverse Geocode Source (runs after OSM/NAR for better dedup) ---
if (options.sources.reverseGeocode) {
progress.sources.reverseGeocode.status = 'running';
await updateProgress();
try {
const rgConfig = typeof options.sources.reverseGeocode === 'object'
? options.sources.reverseGeocode
: { gridSpacingMeters: 100, maxPoints: env.AREA_IMPORT_MAX_GRID_POINTS };
const gridPoints = generateGridPoints(
bounds,
rgConfig.gridSpacingMeters,
rgConfig.maxPoints,
cutPolygons,
);
// Build set of already-discovered locations for proximity skip
const discoveredCoords = new Set<string>();
for (const c of allCandidates) {
discoveredCoords.add(coordKey(c.latitude, c.longitude));
}
const rgCandidates: CandidateLocation[] = [];
let processed = 0;
for (const point of gridPoints) {
// Skip points near already-discovered locations
const pk = coordKey(point.lat, point.lng);
if (discoveredCoords.has(pk) || existingCoords.has(pk)) {
processed++;
continue;
}
// Also check proximity (30m) to any discovered candidate
let tooClose = false;
for (const c of allCandidates) {
if (haversineDistance(point.lat, point.lng, c.latitude, c.longitude) < 30) {
tooClose = true;
break;
}
}
if (tooClose) {
processed++;
continue;
}
try {
const result = await geocodingService.reverseGeocode(point.lat, point.lng);
if (result && result.address) {
const candidate: CandidateLocation = {
latitude: point.lat,
longitude: point.lng,
address: result.address,
city: result.city,
province: result.province,
source: 'reverse-geocode',
confidence: 40,
priority: 1,
};
rgCandidates.push(candidate);
discoveredCoords.add(pk);
}
} catch {
// Skip failed reverse geocode points
}
processed++;
if (processed % 10 === 0) {
progress.sources.reverseGeocode.message = `${processed}/${gridPoints.length} grid points`;
progress.sources.reverseGeocode.candidatesFound = rgCandidates.length;
await updateProgress();
}
// Nominatim rate limit: ~1 request/second
await new Promise((resolve) => setTimeout(resolve, 1100));
}
allCandidates.push(...rgCandidates);
progress.sources.reverseGeocode.status = 'complete';
progress.sources.reverseGeocode.candidatesFound = rgCandidates.length;
await updateProgress();
logger.info(`Reverse geocode source: ${rgCandidates.length} candidates from ${gridPoints.length} grid points`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
progress.sources.reverseGeocode.status = 'failed';
progress.sources.reverseGeocode.error = msg;
await updateProgress();
logger.error(`Reverse geocode source failed: ${msg}`);
}
}
// 4. Cross-source dedup: coordinate hash, priority ordering (NAR > OSM > ReverseGeocode)
progress.totalCandidates = allCandidates.length;
progress.status = 'creating-records';
await updateProgress();
// Sort by priority descending so highest-priority source wins on duplicate coords
allCandidates.sort((a, b) => b.priority - a.priority);
const dedupMap = new Map<string, CandidateLocation>();
let skippedDuplicate = 0;
for (const candidate of allCandidates) {
const key = coordKey(candidate.latitude, candidate.longitude);
// Skip if already exists in DB
if (existingCoords.has(key)) {
skippedDuplicate++;
continue;
}
// Cross-source dedup: first (highest priority) wins
if (!dedupMap.has(key)) {
dedupMap.set(key, candidate);
} else {
skippedDuplicate++;
}
}
progress.skippedDuplicate = skippedDuplicate;
await updateProgress();
// 5. Batch create Location + Address records
const uniqueCandidates = Array.from(dedupMap.values());
logger.info(`Creating ${uniqueCandidates.length} locations (${skippedDuplicate} duplicates skipped)`);
const batchSize = ('batchSize' in options) ? options.batchSize : 1000;
let locationsCreated = 0;
let addressesCreated = 0;
for (let i = 0; i < uniqueCandidates.length; i += batchSize) {
const batch = uniqueCandidates.slice(i, i + batchSize);
const locationBatch: Prisma.LocationCreateManyInput[] = [];
const addressBatch: Prisma.AddressCreateManyInput[] = [];
for (const c of batch) {
const locationId = `loc_${Date.now()}_${Math.random().toString(36).substring(7)}`;
locationBatch.push({
id: locationId,
latitude: c.latitude,
longitude: c.longitude,
address: c.address,
postalCode: c.postalCode,
province: c.province,
geocodeConfidence: c.confidence,
geocodeProvider: c.source === 'osm' ? 'NOMINATIM' : c.source === 'nar' ? 'UNKNOWN' : 'NOMINATIM',
buildingType: 'SINGLE_FAMILY',
totalUnits: 1,
createdByUserId: userId,
});
addressBatch.push({
id: `addr_${Date.now()}_${Math.random().toString(36).substring(7)}`,
locationId,
createdByUserId: userId,
});
}
await prisma.location.createMany({ data: locationBatch, skipDuplicates: true });
await prisma.address.createMany({ data: addressBatch, skipDuplicates: true });
locationsCreated += locationBatch.length;
addressesCreated += addressBatch.length;
progress.locationsCreated = locationsCreated;
progress.addressesCreated = addressesCreated;
await updateProgress();
}
progress.status = 'complete';
await updateProgress();
logger.info(`Area import ${importId} complete: ${locationsCreated} locations, ${addressesCreated} addresses`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
progress.status = 'failed';
progress.error = msg;
await updateProgress();
logger.error(`Area import ${importId} failed: ${msg}`);
}
},
};

View File

@ -0,0 +1,211 @@
import { env } from '../../../config/env';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
export interface CandidateLocation {
latitude: number;
longitude: number;
address: string;
postalCode?: string;
city?: string;
province?: string;
source: 'osm' | 'nar' | 'reverse-geocode';
confidence: number;
priority: number; // NAR=3, OSM=2, ReverseGeocode=1
}
interface OverpassElement {
type: 'node' | 'way' | 'relation';
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
interface OverpassResponse {
elements: OverpassElement[];
}
interface OverpassCountResponse {
elements: { tags: { total: string } }[];
}
const REDIS_LAST_REQUEST_KEY = 'overpass:last-request';
const MAX_AREA_SQ_DEG = 0.05; // ~25 sq km — split above this
/**
* Enforce minimum delay between Overpass API requests.
* Uses Redis timestamp to coordinate across potential instances.
*/
async function waitForRateLimit(): Promise<void> {
const minDelay = env.OVERPASS_MIN_DELAY_MS;
const now = Date.now();
const lastStr = await redis.get(REDIS_LAST_REQUEST_KEY);
const lastRequest = lastStr ? parseInt(lastStr, 10) : 0;
const elapsed = now - lastRequest;
if (elapsed < minDelay) {
const waitMs = minDelay - elapsed;
logger.debug(`Overpass rate limit: waiting ${waitMs}ms`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
await redis.set(REDIS_LAST_REQUEST_KEY, Date.now().toString(), 'EX', 120);
}
/**
* Execute an Overpass API query.
*/
async function queryOverpass<T>(query: string): Promise<T> {
await waitForRateLimit();
const url = env.OVERPASS_API_URL;
logger.info(`Overpass query to ${url} (${query.length} chars)`);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
signal: AbortSignal.timeout(200000), // 200s timeout (Overpass can be slow)
});
if (response.status === 429) {
throw new Error('Overpass API rate limit exceeded. Try again later or use a private instance.');
}
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Overpass API error ${response.status}: ${text.substring(0, 200)}`);
}
return response.json() as Promise<T>;
}
/**
* Split a bounding box into 4 quadrants.
*/
function splitBounds(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) {
const midLat = (bounds.minLat + bounds.maxLat) / 2;
const midLng = (bounds.minLng + bounds.maxLng) / 2;
return [
{ minLat: bounds.minLat, maxLat: midLat, minLng: bounds.minLng, maxLng: midLng },
{ minLat: bounds.minLat, maxLat: midLat, minLng: midLng, maxLng: bounds.maxLng },
{ minLat: midLat, maxLat: bounds.maxLat, minLng: bounds.minLng, maxLng: midLng },
{ minLat: midLat, maxLat: bounds.maxLat, minLng: midLng, maxLng: bounds.maxLng },
];
}
function areaSqDeg(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): number {
return (bounds.maxLat - bounds.minLat) * (bounds.maxLng - bounds.minLng);
}
/**
* Build address string from OSM tags.
*/
function buildAddress(tags: Record<string, string>): string {
const parts: string[] = [];
const houseNumber = tags['addr:housenumber'];
const street = tags['addr:street'];
if (houseNumber) parts.push(houseNumber);
if (street) parts.push(street);
const city = tags['addr:city'];
if (city) parts.push(city);
const province = tags['addr:province'] || tags['addr:state'];
if (province) parts.push(province);
return parts.join(', ') || `${houseNumber || '?'} ${street || 'Unknown Street'}`;
}
/**
* Parse Overpass elements into CandidateLocation objects.
*/
function parseElements(elements: OverpassElement[]): CandidateLocation[] {
const candidates: CandidateLocation[] = [];
for (const el of elements) {
const lat = el.lat ?? el.center?.lat;
const lon = el.lon ?? el.center?.lon;
if (!lat || !lon) continue;
const tags = el.tags ?? {};
if (!tags['addr:housenumber']) continue;
candidates.push({
latitude: lat,
longitude: lon,
address: buildAddress(tags),
postalCode: tags['addr:postcode'],
city: tags['addr:city'],
province: tags['addr:province'] || tags['addr:state'],
source: 'osm',
confidence: 70,
priority: 2,
});
}
return candidates;
}
export const overpassService = {
/**
* Estimate the number of address nodes in a bounding box using [out:count].
*/
async estimateCount(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): Promise<number> {
const bbox = `${bounds.minLat},${bounds.minLng},${bounds.maxLat},${bounds.maxLng}`;
const query = `[out:json][timeout:60];(node["addr:housenumber"](${bbox});way["building"]["addr:housenumber"](${bbox}););out count;`;
try {
const data = await queryOverpass<OverpassCountResponse>(query);
const totalStr = data.elements?.[0]?.tags?.total;
return totalStr ? parseInt(totalStr, 10) : 0;
} catch (err) {
logger.warn('Overpass count estimate failed:', err);
return -1; // -1 indicates unknown
}
},
/**
* Query all address data within a bounding box.
* Automatically splits large areas into sub-quadrants.
*/
async queryArea(
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
onProgress?: (msg: string) => void,
): Promise<CandidateLocation[]> {
const area = areaSqDeg(bounds);
// If area is too large, split into quadrants and query each
if (area > MAX_AREA_SQ_DEG) {
const quadrants = splitBounds(bounds);
const allCandidates: CandidateLocation[] = [];
const totalQuadrants = quadrants.length;
for (let i = 0; i < totalQuadrants; i++) {
onProgress?.(`Querying OSM quadrant ${i + 1}/${totalQuadrants}`);
try {
const subCandidates = await this.queryArea(quadrants[i]!, onProgress);
allCandidates.push(...subCandidates);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.warn(`Overpass quadrant ${i + 1} failed: ${msg}`);
// Continue with other quadrants
}
}
return allCandidates;
}
const bbox = `${bounds.minLat},${bounds.minLng},${bounds.maxLat},${bounds.maxLng}`;
const query = `[out:json][timeout:180];(node["addr:housenumber"](${bbox});way["building"]["addr:housenumber"](${bbox}););out center body;`;
onProgress?.('Querying OSM addresses...');
const data = await queryOverpass<OverpassResponse>(query);
return parseElements(data.elements);
},
};

View File

@ -3,7 +3,6 @@ import { Prisma, ShiftStatus, SignupStatus, SignupSource } from '@prisma/client'
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { emailService } from '../../../services/email.service';
import { siteSettingsService } from '../../settings/settings.service';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { recordShiftSignup } from '../../../utils/metrics';
@ -326,6 +325,7 @@ export const shiftsService = {
name: data.name,
phone: data.phone,
role: 'TEMP',
roles: JSON.parse(JSON.stringify(['TEMP'])),
createdVia: 'PUBLIC_SHIFT_SIGNUP',
expiresAt: shiftDate,
},
@ -388,32 +388,16 @@ export const shiftsService = {
day: 'numeric',
});
const htmlTemplate = emailService.loadTemplate('shift-signup-confirmation', 'html');
const txtTemplate = emailService.loadTemplate('shift-signup-confirmation', 'txt');
let orgName = 'Changemaker Lite';
try { orgName = (await siteSettingsService.get()).organizationName || orgName; } catch { /* use default */ }
const vars: Record<string, string> = {
USER_NAME: data.name,
USER_EMAIL: data.email,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_TIME: `${shift.startTime}${shift.endTime}`,
SHIFT_LOCATION: shift.location || 'TBD',
IS_NEW_USER: isNewUser ? 'true' : '',
TEMP_PASSWORD: tempPassword || '',
LOGIN_URL: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
ORGANIZATION_NAME: orgName,
};
const html = emailService.processTemplate(htmlTemplate, vars);
const text = emailService.processTemplate(txtTemplate, vars);
await emailService.sendEmail({
to: data.email,
subject: `Signup Confirmed — ${shift.title}`,
html,
text,
await emailService.sendShiftSignupConfirmation({
recipientEmail: data.email,
recipientName: data.name,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
isNewUser,
tempPassword,
loginUrl: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
});
} catch (err) {
logger.error('Failed to send shift signup confirmation email:', err);
@ -561,32 +545,16 @@ export const shiftsService = {
const dateStr = shiftDate.toLocaleDateString('en-CA', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
const htmlTemplate = emailService.loadTemplate('shift-signup-confirmation', 'html');
const txtTemplate = emailService.loadTemplate('shift-signup-confirmation', 'txt');
let orgName = 'Changemaker Lite';
try { orgName = (await siteSettingsService.get()).organizationName || orgName; } catch { /* default */ }
const vars: Record<string, string> = {
USER_NAME: user.name || user.email,
USER_EMAIL: user.email,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_TIME: `${shift.startTime}${shift.endTime}`,
SHIFT_LOCATION: shift.location || 'TBD',
IS_NEW_USER: '',
TEMP_PASSWORD: '',
LOGIN_URL: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
ORGANIZATION_NAME: orgName,
};
const html = emailService.processTemplate(htmlTemplate, vars);
const text = emailService.processTemplate(txtTemplate, vars);
await emailService.sendEmail({
to: user.email,
subject: `Signup Confirmed — ${shift.title}`,
html,
text,
await emailService.sendShiftSignupConfirmation({
recipientEmail: user.email,
recipientName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
shiftLocation: shift.location || 'TBD',
isNewUser: false,
loginUrl: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
});
} catch (err) {
logger.error('Failed to send volunteer shift signup confirmation email:', err);
@ -703,38 +671,23 @@ export const shiftsService = {
day: 'numeric',
});
const htmlTemplate = emailService.loadTemplate('shift-details', 'html');
const txtTemplate = emailService.loadTemplate('shift-details', 'txt');
let orgName = 'Changemaker Lite';
try { orgName = (await siteSettingsService.get()).organizationName || orgName; } catch { /* use default */ }
let sent = 0;
let failed = 0;
for (const signup of shift.signups) {
try {
const vars: Record<string, string> = {
USER_NAME: signup.userName || signup.userEmail,
SHIFT_TITLE: shift.title,
SHIFT_DATE: dateStr,
SHIFT_START_TIME: shift.startTime,
SHIFT_END_TIME: shift.endTime,
SHIFT_LOCATION: shift.location || 'TBD',
SHIFT_DESCRIPTION: shift.description || '',
CURRENT_VOLUNTEERS: shift.currentVolunteers.toString(),
MAX_VOLUNTEERS: shift.maxVolunteers.toString(),
SHIFT_STATUS: shift.status,
ORGANIZATION_NAME: orgName,
};
const html = emailService.processTemplate(htmlTemplate, vars);
const text = emailService.processTemplate(txtTemplate, vars);
const result = await emailService.sendEmail({
to: signup.userEmail,
subject: `Shift Details — ${shift.title}`,
html,
text,
const result = await emailService.sendShiftDetailsEmail({
recipientEmail: signup.userEmail,
recipientName: signup.userName || signup.userEmail,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime,
shiftLocation: shift.location || 'TBD',
shiftDescription: shift.description || '',
currentVolunteers: shift.currentVolunteers,
maxVolunteers: shift.maxVolunteers,
shiftStatus: shift.status,
});
if (result.success) {

View File

@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { hasAnyRole, ADMIN_ROLES as ADMIN_ROLE_LIST, getUserRoles } from '../../../utils/roles';
// Extend FastifyRequest to include user
declare module 'fastify' {
@ -11,6 +12,7 @@ declare module 'fastify' {
id: string;
email: string;
role: UserRole;
roles: UserRole[];
};
}
}
@ -19,6 +21,7 @@ interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles?: UserRole[];
}
/**
@ -58,6 +61,7 @@ export async function authenticate(
id: true,
email: true,
role: true,
roles: true,
status: true,
expiresAt: true,
},
@ -86,10 +90,12 @@ export async function authenticate(
}
// Attach user to request
const userRoles = getUserRoles(user);
request.user = {
id: user.id,
email: user.email,
role: user.role as UserRole,
roles: userRoles,
};
}
@ -109,9 +115,8 @@ export async function requireAdminRole(
return;
}
// Check admin role (allow all admin roles)
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
if (!request.user || !ADMIN_ROLES.includes(request.user.role)) {
// Check admin role using multi-role utility
if (!request.user || !hasAnyRole(request.user, ADMIN_ROLE_LIST)) {
return reply.status(403).send({
error: 'Admin access required',
code: 'ADMIN_REQUIRED'
@ -145,15 +150,18 @@ export async function optionalAuth(
id: true,
email: true,
role: true,
roles: true,
status: true,
},
});
if (user && user.status === UserStatus.ACTIVE) {
const userRoles = getUserRoles(user);
request.user = {
id: user.id,
email: user.email,
role: user.role as UserRole,
roles: userRoles,
};
}
} catch {

View File

@ -0,0 +1,117 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import jwt from 'jsonwebtoken';
import { env } from '../../../config/env';
import { UserRole } from '@prisma/client';
/**
* Chat Notifications SSE Routes
*
* Provides per-user SSE streams for real-time chat reply notifications.
* Since EventSource can't send Authorization headers, JWT is passed as query param.
*/
interface TokenPayload {
id: string;
email: string;
role: UserRole;
}
// In-memory subscriber map: userId → Set of SSE writers
const subscribers = new Map<string, Set<(data: string) => void>>();
/**
* Notify a user of a chat reply via SSE
*/
export function notifyUser(userId: string, notification: {
type: 'chat_reply';
videoId: number;
videoTitle: string;
commentId: number;
commenterName: string;
contentPreview: string;
}): void {
const writers = subscribers.get(userId);
if (!writers || writers.size === 0) return;
const data = JSON.stringify(notification);
for (const write of writers) {
try {
write(data);
} catch {
// Writer disconnected, will be cleaned up
}
}
}
export async function chatNotificationsRoutes(fastify: FastifyInstance) {
/**
* GET /notifications/stream?token=JWT
* Per-user SSE stream for chat reply notifications
*/
fastify.get(
'/notifications/stream',
async (
request: FastifyRequest<{ Querystring: { token?: string } }>,
reply: FastifyReply
) => {
const token = request.query.token;
if (!token) {
return reply.code(401).send({ message: 'Authentication token required' });
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
} catch {
return reply.code(401).send({ message: 'Invalid or expired token' });
}
const userId = payload.id;
// Set SSE headers
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Writer function for this connection
const writer = (data: string) => {
reply.raw.write(`data: ${data}\n\n`);
};
// Register subscriber
if (!subscribers.has(userId)) {
subscribers.set(userId, new Set());
}
subscribers.get(userId)!.add(writer);
// Send connection confirmation
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`);
// Keep-alive ping every 30 seconds
const pingInterval = setInterval(() => {
try {
reply.raw.write(': ping\n\n');
} catch {
// Connection closed
}
}, 30000);
// Cleanup on disconnect
request.raw.on('close', () => {
clearInterval(pingInterval);
const writers = subscribers.get(userId);
if (writers) {
writers.delete(writer);
if (writers.size === 0) {
subscribers.delete(userId);
}
}
});
}
);
}

View File

@ -25,7 +25,7 @@ export async function chatStreamRoutes(fastify: FastifyInstance) {
* SSE endpoint for real-time chat updates
*/
fastify.get(
'/public/:id/stream',
'/public/:id/chat-stream',
async (
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply

View File

@ -0,0 +1,161 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { authenticate } from '../middleware/auth';
interface ThreadsQuery {
limit?: string;
offset?: string;
}
export async function chatThreadsRoutes(fastify: FastifyInstance) {
// All routes require authentication
fastify.addHook('preHandler', authenticate);
/**
* GET /chat/threads
* List videos where the authenticated user has commented,
* ordered by latest activity, with unread counts.
*/
fastify.get(
'/chat/threads',
async (
request: FastifyRequest<{ Querystring: ThreadsQuery }>,
reply
) => {
try {
const userId = request.user!.id;
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
const offset = parseInt(request.query.offset || '0', 10);
// Find distinct video IDs where user has commented
const userVideoIds = await prisma.comment.findMany({
where: { userId },
select: { mediaId: true },
distinct: ['mediaId'],
});
if (userVideoIds.length === 0) {
return reply.send({ threads: [], total: 0 });
}
const mediaIds = userVideoIds.map((c) => c.mediaId);
// Get the user's read statuses
const readStatuses = await prisma.chatThreadReadStatus.findMany({
where: { userId, mediaId: { in: mediaIds } },
});
const readStatusMap = new Map(readStatuses.map((r) => [r.mediaId, r.lastSeenAt]));
// For each video, get latest comment and unread count
const threads = await Promise.all(
mediaIds.map(async (mediaId) => {
const lastSeenAt = readStatusMap.get(mediaId);
const [latestComment, totalComments, unreadCount, video] = await Promise.all([
prisma.comment.findFirst({
where: { mediaId, isHidden: { not: true } },
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
},
}),
prisma.comment.count({
where: { mediaId, isHidden: { not: true } },
}),
lastSeenAt
? prisma.comment.count({
where: {
mediaId,
isHidden: { not: true },
createdAt: { gt: lastSeenAt },
},
})
: prisma.comment.count({
where: { mediaId, isHidden: { not: true } },
}),
prisma.video.findUnique({
where: { id: mediaId },
select: { id: true, filename: true, thumbnailPath: true },
}),
]);
return {
mediaId,
videoTitle: video?.filename || `Video #${mediaId}`,
thumbnailPath: video?.thumbnailPath || null,
totalComments,
unreadCount,
lastActivity: latestComment?.createdAt.toISOString() || null,
lastMessage: latestComment
? {
content: latestComment.content.length > 100
? latestComment.content.substring(0, 100) + '...'
: latestComment.content,
userName: latestComment.user?.name || latestComment.user?.email || 'Anonymous',
createdAt: latestComment.createdAt.toISOString(),
}
: null,
};
})
);
// Sort by lastActivity descending
threads.sort((a, b) => {
if (!a.lastActivity) return 1;
if (!b.lastActivity) return -1;
return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime();
});
// Paginate
const paginated = threads.slice(offset, offset + limit);
return reply.send({ threads: paginated, total: threads.length });
} catch (error) {
console.error('Failed to fetch chat threads:', error);
return reply.code(500).send({ message: 'Failed to fetch chat threads' });
}
}
);
/**
* POST /chat/threads/:mediaId/read
* Upsert ChatThreadReadStatus (lastSeenAt: now())
*/
fastify.post(
'/chat/threads/:mediaId/read',
async (
request: FastifyRequest<{ Params: { mediaId: string } }>,
reply
) => {
try {
const userId = request.user!.id;
const mediaId = parseInt(request.params.mediaId, 10);
if (isNaN(mediaId)) {
return reply.code(400).send({ message: 'Invalid media ID' });
}
// Find existing read status by unique index
const existing = await prisma.chatThreadReadStatus.findFirst({
where: { userId, mediaId },
});
if (existing) {
await prisma.chatThreadReadStatus.update({
where: { id: existing.id },
data: { lastSeenAt: new Date() },
});
} else {
await prisma.chatThreadReadStatus.create({
data: { userId, mediaId, lastSeenAt: new Date() },
});
}
return reply.send({ message: 'Thread marked as read' });
} catch (error) {
console.error('Failed to mark thread as read:', error);
return reply.code(500).send({ message: 'Failed to mark thread as read' });
}
}
);
}

View File

@ -0,0 +1,505 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
import { invalidateWordListCache } from '../services/word-filter.service';
interface ListCommentsQuery {
page?: string;
limit?: string;
status?: string; // 'pending' | 'safe' | 'flagged' | 'hidden'
videoId?: string;
search?: string;
dateFrom?: string;
dateTo?: string;
}
interface CommentIdParams {
id: string;
}
interface HideCommentBody {
reason?: 'manual' | 'word_filter' | 'spam' | 'link';
}
interface UpdateNotesBody {
notes: string;
}
interface AddWordBody {
word: string;
level: 'low' | 'medium' | 'high' | 'custom';
}
interface WordFilterIdParams {
id: string;
}
export async function commentAdminRoutes(fastify: FastifyInstance) {
// All routes require admin role
fastify.addHook('preHandler', requireAdminRole);
/**
* GET /admin/comments/stats
* Counts by status (pending/flagged/hidden/total)
*/
fastify.get(
'/admin/comments/stats',
async (_request, reply) => {
try {
const [total, pending, flagged, hidden, safe] = await Promise.all([
prisma.comment.count(),
prisma.comment.count({ where: { safetyStatus: 'pending' } }),
prisma.comment.count({ where: { safetyStatus: 'flagged' } }),
prisma.comment.count({ where: { isHidden: true } }),
prisma.comment.count({ where: { safetyStatus: 'safe' } }),
]);
return reply.send({ total, pending, flagged, hidden, safe });
} catch (error) {
console.error('Failed to fetch comment stats:', error);
return reply.code(500).send({ message: 'Failed to fetch comment stats' });
}
}
);
/**
* GET /admin/comments
* List all comments with filters, pagination, includes user + video title
*/
fastify.get(
'/admin/comments',
async (
request: FastifyRequest<{ Querystring: ListCommentsQuery }>,
reply
) => {
try {
const page = parseInt(request.query.page || '1', 10);
const limit = Math.min(parseInt(request.query.limit || '20', 10), 100);
const skip = (page - 1) * limit;
const { status, videoId, search, dateFrom, dateTo } = request.query;
// Build where clause
const where: any = {};
if (status === 'hidden') {
where.isHidden = true;
} else if (status === 'pending' || status === 'safe' || status === 'flagged') {
where.safetyStatus = status;
where.isHidden = { not: true };
}
if (videoId) {
const vid = parseInt(videoId, 10);
if (!isNaN(vid)) where.mediaId = vid;
}
if (search) {
where.content = { contains: search, mode: 'insensitive' };
}
if (dateFrom || dateTo) {
where.createdAt = {};
if (dateFrom) where.createdAt.gte = new Date(dateFrom);
if (dateTo) where.createdAt.lte = new Date(dateTo);
}
const [comments, total] = await Promise.all([
prisma.comment.findMany({
where,
include: {
user: { select: { id: true, email: true, name: true } },
media: { select: { id: true, filename: true } },
moderation: {
select: {
id: true,
status: true,
moderatedAt: true,
reason: true,
moderator: { select: { id: true, name: true } },
},
},
},
orderBy: { createdAt: 'desc' },
take: limit,
skip,
}),
prisma.comment.count({ where }),
]);
const transformed = comments.map((c) => ({
id: c.id,
mediaId: c.mediaId,
videoTitle: c.media.filename,
content: c.content,
createdAt: c.createdAt.toISOString(),
safetyStatus: c.safetyStatus,
safetyCategories: c.safetyCategories,
safetyReasoning: c.safetyReasoning,
isHidden: c.isHidden,
hiddenAt: c.hiddenAt?.toISOString() ?? null,
hiddenReason: c.hiddenReason,
moderationNotes: c.moderationNotes,
user: c.user
? { id: c.user.id, name: c.user.name || c.user.email, email: c.user.email }
: null,
moderation: c.moderation
? {
id: c.moderation.id,
status: c.moderation.status,
moderatedAt: c.moderation.moderatedAt?.toISOString() ?? null,
reason: c.moderation.reason,
moderator: c.moderation.moderator
? { id: c.moderation.moderator.id, name: c.moderation.moderator.name }
: null,
}
: null,
}));
return reply.send({
comments: transformed,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
});
} catch (error) {
console.error('Failed to fetch admin comments:', error);
return reply.code(500).send({ message: 'Failed to fetch comments' });
}
}
);
/**
* PATCH /admin/comments/:id/approve
* Set safetyStatus to 'safe', create/update CommentModeration
*/
fastify.patch(
'/admin/comments/:id/approve',
async (
request: FastifyRequest<{ Params: CommentIdParams }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return reply.code(404).send({ message: 'Comment not found' });
}
await prisma.$transaction([
prisma.comment.update({
where: { id: commentId },
data: {
safetyStatus: 'safe',
isHidden: false,
safetyCheckedAt: new Date(),
},
}),
prisma.commentModeration.upsert({
where: { commentId },
update: {
status: 'approved',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
},
create: {
commentId,
status: 'approved',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
},
}),
]);
return reply.send({ message: 'Comment approved' });
} catch (error) {
console.error('Failed to approve comment:', error);
return reply.code(500).send({ message: 'Failed to approve comment' });
}
}
);
/**
* PATCH /admin/comments/:id/hide
* Set isHidden to true, hiddenAt, hiddenReason, create CommentModeration
*/
fastify.patch(
'/admin/comments/:id/hide',
async (
request: FastifyRequest<{ Params: CommentIdParams; Body: HideCommentBody }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return reply.code(404).send({ message: 'Comment not found' });
}
const reason = request.body?.reason || 'manual';
await prisma.$transaction([
prisma.comment.update({
where: { id: commentId },
data: {
isHidden: true,
hiddenAt: new Date(),
hiddenReason: reason,
},
}),
prisma.commentModeration.upsert({
where: { commentId },
update: {
status: 'rejected',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
reason,
},
create: {
commentId,
status: 'rejected',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
reason,
},
}),
]);
return reply.send({ message: 'Comment hidden' });
} catch (error) {
console.error('Failed to hide comment:', error);
return reply.code(500).send({ message: 'Failed to hide comment' });
}
}
);
/**
* PATCH /admin/comments/:id/unhide
* Set isHidden to false, update CommentModeration to approved
*/
fastify.patch(
'/admin/comments/:id/unhide',
async (
request: FastifyRequest<{ Params: CommentIdParams }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return reply.code(404).send({ message: 'Comment not found' });
}
await prisma.$transaction([
prisma.comment.update({
where: { id: commentId },
data: { isHidden: false },
}),
prisma.commentModeration.upsert({
where: { commentId },
update: {
status: 'approved',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
},
create: {
commentId,
status: 'approved',
moderatedBy: request.user!.id,
moderatedAt: new Date(),
},
}),
]);
return reply.send({ message: 'Comment unhidden' });
} catch (error) {
console.error('Failed to unhide comment:', error);
return reply.code(500).send({ message: 'Failed to unhide comment' });
}
}
);
/**
* PUT /admin/comments/:id/notes
* Update moderationNotes field
*/
fastify.put(
'/admin/comments/:id/notes',
async (
request: FastifyRequest<{ Params: CommentIdParams; Body: UpdateNotesBody }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const { notes } = request.body || {};
if (notes === undefined) {
return reply.code(400).send({ message: 'Notes field is required' });
}
await prisma.comment.update({
where: { id: commentId },
data: { moderationNotes: notes },
});
return reply.send({ message: 'Notes updated' });
} catch (error) {
console.error('Failed to update notes:', error);
return reply.code(500).send({ message: 'Failed to update notes' });
}
}
);
/**
* DELETE /admin/comments/:id
* Hard delete comment + moderation record
*/
fastify.delete(
'/admin/comments/:id',
async (
request: FastifyRequest<{ Params: CommentIdParams }>,
reply
) => {
try {
const commentId = parseInt(request.params.id, 10);
if (isNaN(commentId)) {
return reply.code(400).send({ message: 'Invalid comment ID' });
}
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return reply.code(404).send({ message: 'Comment not found' });
}
// Delete moderation record first (FK constraint), then comment
await prisma.$transaction([
prisma.commentModeration.deleteMany({ where: { commentId } }),
prisma.comment.delete({ where: { id: commentId } }),
]);
return reply.send({ message: 'Comment deleted' });
} catch (error) {
console.error('Failed to delete comment:', error);
return reply.code(500).send({ message: 'Failed to delete comment' });
}
}
);
// ========================================================================
// WORD FILTER ADMIN ROUTES
// ========================================================================
/**
* GET /admin/word-filters
* List all words grouped by level
*/
fastify.get(
'/admin/word-filters',
async (_request, reply) => {
try {
const words = await prisma.moderationWordList.findMany({
orderBy: [{ level: 'desc' }, { word: 'asc' }],
include: {
creator: { select: { id: true, name: true } },
},
});
return reply.send({ words });
} catch (error) {
console.error('Failed to fetch word filters:', error);
return reply.code(500).send({ message: 'Failed to fetch word filters' });
}
}
);
/**
* POST /admin/word-filters
* Add a word filter entry
*/
fastify.post(
'/admin/word-filters',
async (
request: FastifyRequest<{ Body: AddWordBody }>,
reply
) => {
try {
const { word, level } = request.body || {};
if (!word || !word.trim()) {
return reply.code(400).send({ message: 'Word is required' });
}
if (!['low', 'medium', 'high', 'custom'].includes(level)) {
return reply.code(400).send({ message: 'Level must be low, medium, high, or custom' });
}
// Check for duplicates
const existing = await prisma.moderationWordList.findFirst({
where: { word: { equals: word.trim(), mode: 'insensitive' } },
});
if (existing) {
return reply.code(409).send({ message: 'Word already exists in filter list' });
}
const entry = await prisma.moderationWordList.create({
data: {
word: word.trim().toLowerCase(),
level,
createdBy: request.user!.id,
},
});
invalidateWordListCache();
return reply.code(201).send(entry);
} catch (error) {
console.error('Failed to add word filter:', error);
return reply.code(500).send({ message: 'Failed to add word filter' });
}
}
);
/**
* DELETE /admin/word-filters/:id
* Remove word from filter list
*/
fastify.delete(
'/admin/word-filters/:id',
async (
request: FastifyRequest<{ Params: WordFilterIdParams }>,
reply
) => {
try {
const id = parseInt(request.params.id, 10);
if (isNaN(id)) {
return reply.code(400).send({ message: 'Invalid word filter ID' });
}
await prisma.moderationWordList.delete({ where: { id } });
invalidateWordListCache();
return reply.send({ message: 'Word filter removed' });
} catch (error) {
console.error('Failed to delete word filter:', error);
return reply.code(500).send({ message: 'Failed to delete word filter' });
}
}
);
}

View File

@ -1,9 +1,10 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { PrismaClient } from '@prisma/client';
import { FastifyInstance, FastifyRequest } from 'fastify';
import { randomUUID } from 'crypto';
import { prisma } from '../../../config/database';
import { broadcastCommentToVideo } from './chat-stream.routes.js';
import { v4 as uuidv4 } from 'uuid';
const prisma = new PrismaClient();
import { optionalAuth } from '../middleware/auth';
import { checkContent } from '../services/word-filter.service';
import { notifyUser } from './chat-notifications.routes';
// Rate limiting map: userId/sessionId -> array of timestamps
const commentRateLimitMap = new Map<string, number[]>();
@ -31,7 +32,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
Params: { id: string };
Querystring: GetCommentsQuery;
}>,
reply: FastifyReply
reply
) => {
try {
const videoId = parseInt(request.params.id, 10);
@ -103,8 +104,11 @@ export async function commentsRoutes(fastify: FastifyInstance) {
Params: { id: string };
Body: CreateCommentBody;
}>,
reply: FastifyReply
reply
) => {
// Optionally authenticate (attaches request.user if Bearer token present)
await optionalAuth(request, reply);
try {
const videoId = parseInt(request.params.id, 10);
const { content } = request.body;
@ -123,28 +127,31 @@ export async function commentsRoutes(fastify: FastifyInstance) {
});
}
// Get or create session
let sessionId = request.session?.sessionId;
// Get session ID from X-Session-ID header (set by frontend)
let sessionId = request.headers['x-session-id'] as string | undefined;
let userId: string | null = null;
// Check if user is authenticated (from JWT or session)
// Check if user is authenticated (from optionalAuth preHandler)
if (request.user) {
userId = request.user.id;
}
// If no session exists, create one
// If no session ID from header, generate one
if (!sessionId) {
sessionId = uuidv4();
// Create a minimal session record
await prisma.session.create({
data: {
id: sessionId,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || '',
},
});
sessionId = randomUUID();
}
// Ensure session record exists
await prisma.session.upsert({
where: { id: sessionId },
update: {},
create: {
id: sessionId,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || '',
},
});
// Rate limiting check
const rateLimitKey = userId || sessionId;
const now = Date.now();
@ -162,6 +169,31 @@ export async function commentsRoutes(fastify: FastifyInstance) {
recentTimestamps.push(now);
commentRateLimitMap.set(rateLimitKey, recentTimestamps);
// Run word filter check
const filterResult = await checkContent(content.trim());
// High-severity words: block submission entirely
if (filterResult.blocked) {
return reply.code(400).send({
message: 'Your comment contains content that is not allowed.',
});
}
// Determine safety status and hidden state based on filter result
let safetyStatus = 'pending';
let isHidden = false;
let hiddenReason: string | null = null;
if (filterResult.autoHide) {
// Medium-severity: save but auto-hide
safetyStatus = 'flagged';
isHidden = true;
hiddenReason = 'word_filter';
} else if (filterResult.flagged) {
// Low-severity: visible but flagged for review
safetyStatus = 'flagged';
}
// Create comment
const newComment = await prisma.comment.create({
data: {
@ -169,7 +201,13 @@ export async function commentsRoutes(fastify: FastifyInstance) {
sessionId,
userId,
content: content.trim(),
safetyStatus: 'pending', // Will be checked by moderation system
safetyStatus,
isHidden,
hiddenReason,
safetyReasoning: filterResult.reason || null,
safetyCategories: filterResult.matchedWords.length > 0
? (filterResult.matchedWords as any)
: undefined,
},
include: {
user: {
@ -182,7 +220,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
},
});
// Broadcast to SSE subscribers
// Broadcast to SSE subscribers (only if not hidden)
const broadcastData = {
id: newComment.id,
content: newComment.content,
@ -196,7 +234,47 @@ export async function commentsRoutes(fastify: FastifyInstance) {
: null,
};
broadcastCommentToVideo(videoId, broadcastData);
if (!isHidden) {
broadcastCommentToVideo(videoId, broadcastData);
// Notify other users who commented on this video
try {
const otherCommenters = await prisma.comment.findMany({
where: {
mediaId: videoId,
userId: { not: null, ...(userId ? { not: userId } : {}) },
},
select: { userId: true },
distinct: ['userId'],
});
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { filename: true },
});
const commenterName = newComment.user?.name || newComment.user?.email || 'Someone';
const contentPreview = content.trim().length > 80
? content.trim().substring(0, 80) + '...'
: content.trim();
for (const { userId: targetUserId } of otherCommenters) {
if (targetUserId) {
notifyUser(targetUserId, {
type: 'chat_reply',
videoId,
videoTitle: video?.filename || `Video #${videoId}`,
commentId: newComment.id,
commenterName,
contentPreview,
});
}
}
} catch (notifyErr) {
// Non-critical: don't fail the comment creation
console.error('Failed to send chat notifications:', notifyErr);
}
}
return reply.code(201).send(broadcastData);
} catch (error) {

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