Bunch of stuff again
This commit is contained in:
parent
7895ce683e
commit
a7978de5a0
@ -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>
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
});
|
||||
|
||||
242
admin/src/components/AuthModal.tsx
Normal file
242
admin/src/components/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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)}>
|
||||
|
||||
49
admin/src/components/dashboard/ContainerMemoryChart.tsx
Normal file
49
admin/src/components/dashboard/ContainerMemoryChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
admin/src/components/dashboard/ContainerPopover.tsx
Normal file
58
admin/src/components/dashboard/ContainerPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
admin/src/components/dashboard/LatencyBandsChart.tsx
Normal file
50
admin/src/components/dashboard/LatencyBandsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
admin/src/components/dashboard/MiniDonutChart.tsx
Normal file
49
admin/src/components/dashboard/MiniDonutChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
admin/src/components/dashboard/RequestTrafficChart.tsx
Normal file
50
admin/src/components/dashboard/RequestTrafficChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
admin/src/components/dashboard/SystemGauges.tsx
Normal file
58
admin/src/components/dashboard/SystemGauges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
586
admin/src/components/map/AreaImportWizard.tsx
Normal file
586
admin/src/components/map/AreaImportWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
admin/src/components/media/AddToPlaylistModal.tsx
Normal file
242
admin/src/components/media/AddToPlaylistModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
admin/src/components/media/BulkAddToPlaylistModal.tsx
Normal file
188
admin/src/components/media/BulkAddToPlaylistModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
admin/src/components/media/ChatNotificationToast.tsx
Normal file
79
admin/src/components/media/ChatNotificationToast.tsx
Normal 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}</>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
99
admin/src/components/media/CreatePlaylistModal.tsx
Normal file
99
admin/src/components/media/CreatePlaylistModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
admin/src/components/media/EditPlaylistModal.tsx
Normal file
260
admin/src/components/media/EditPlaylistModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
152
admin/src/components/media/FeaturedPlaylistCarousel.tsx
Normal file
152
admin/src/components/media/FeaturedPlaylistCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
540
admin/src/components/media/FetchVideosDrawer.tsx
Normal file
540
admin/src/components/media/FetchVideosDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
178
admin/src/components/media/PlaylistCard.tsx
Normal file
178
admin/src/components/media/PlaylistCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
admin/src/components/media/PlaylistSidebarPanel.tsx
Normal file
228
admin/src/components/media/PlaylistSidebarPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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)';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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',
|
||||
}}
|
||||
|
||||
@ -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={{
|
||||
|
||||
49
admin/src/components/media/chatbar/ChatBar.tsx
Normal file
49
admin/src/components/media/chatbar/ChatBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
admin/src/components/media/chatbar/ChatBarContext.tsx
Normal file
127
admin/src/components/media/chatbar/ChatBarContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
admin/src/components/media/chatbar/MiniChatWindow.tsx
Normal file
93
admin/src/components/media/chatbar/MiniChatWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
admin/src/components/media/chatbar/MiniLiveChat.tsx
Normal file
229
admin/src/components/media/chatbar/MiniLiveChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
admin/src/components/media/chatbar/MinimizedChat.tsx
Normal file
55
admin/src/components/media/chatbar/MinimizedChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
79
admin/src/hooks/useChatNotifications.ts
Normal file
79
admin/src/hooks/useChatNotifications.ts
Normal 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 };
|
||||
}
|
||||
@ -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 · 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 · {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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
133
admin/src/pages/ResetPasswordPage.tsx
Normal file
133
admin/src/pages/ResetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
137
admin/src/pages/VerifyEmailPage.tsx
Normal file
137
admin/src/pages/VerifyEmailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
419
admin/src/pages/influence/CampaignModerationPage.tsx
Normal file
419
admin/src/pages/influence/CampaignModerationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
582
admin/src/pages/media/CommentModerationPage.tsx
Normal file
582
admin/src/pages/media/CommentModerationPage.tsx
Normal 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 ·{' '}
|
||||
<strong>Medium</strong>: saved but auto-hidden for review ·{' '}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
629
admin/src/pages/media/PlaylistManagementPage.tsx
Normal file
629
admin/src/pages/media/PlaylistManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
266
admin/src/pages/public/CreateCampaignPage.tsx
Normal file
266
admin/src/pages/public/CreateCampaignPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 }}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
152
admin/src/pages/public/MyCampaignsPage.tsx
Normal file
152
admin/src/pages/public/MyCampaignsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
394
admin/src/pages/public/MySettingsPage.tsx
Normal file
394
admin/src/pages/public/MySettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
471
admin/src/pages/public/MyStatsPage.tsx
Normal file
471
admin/src/pages/public/MyStatsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
355
admin/src/pages/public/PlaylistBrowsePage.tsx
Normal file
355
admin/src/pages/public/PlaylistBrowsePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
381
admin/src/pages/public/PlaylistViewerPage.tsx
Normal file
381
admin/src/pages/public/PlaylistViewerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1265
admin/src/pages/public/ShortsPage.tsx
Normal file
1265
admin/src/pages/public/ShortsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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
6
admin/src/utils/color.ts
Normal 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
23
admin/src/utils/roles.ts
Normal 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
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)');
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
43
api/src/modules/auth/auth.rate-limits.ts
Normal file
43
api/src/modules/auth/auth.rate-limits.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
124
api/src/modules/dashboard/dashboard.routes.ts
Normal file
124
api/src/modules/dashboard/dashboard.routes.ts
Normal 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;
|
||||
621
api/src/modules/dashboard/dashboard.service.ts
Normal file
621
api/src/modules/dashboard/dashboard.service.ts
Normal 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}¤t=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;
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
77
api/src/modules/influence/campaigns/campaigns-user.routes.ts
Normal file
77
api/src/modules/influence/campaigns/campaigns-user.routes.ts
Normal 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 };
|
||||
@ -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>;
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
89
api/src/modules/map/locations/area-import.routes.ts
Normal file
89
api/src/modules/map/locations/area-import.routes.ts
Normal 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 };
|
||||
57
api/src/modules/map/locations/area-import.schemas.ts
Normal file
57
api/src/modules/map/locations/area-import.schemas.ts
Normal 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>;
|
||||
671
api/src/modules/map/locations/area-import.service.ts
Normal file
671
api/src/modules/map/locations/area-import.service.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
211
api/src/modules/map/locations/overpass.service.ts
Normal file
211
api/src/modules/map/locations/overpass.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
117
api/src/modules/media/routes/chat-notifications.routes.ts
Normal file
117
api/src/modules/media/routes/chat-notifications.routes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
161
api/src/modules/media/routes/chat-threads.routes.ts
Normal file
161
api/src/modules/media/routes/chat-threads.routes.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
505
api/src/modules/media/routes/comment-admin.routes.ts
Normal file
505
api/src/modules/media/routes/comment-admin.routes.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user