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 LibraryPage from '@/pages/media/LibraryPage';
|
||||||
import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage';
|
import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage';
|
||||||
import MediaJobsPage from '@/pages/media/MediaJobsPage';
|
import MediaJobsPage from '@/pages/media/MediaJobsPage';
|
||||||
|
import CommentModerationPage from '@/pages/media/CommentModerationPage';
|
||||||
|
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
|
||||||
import PublicLandingPage from '@/pages/public/LandingPage';
|
import PublicLandingPage from '@/pages/public/LandingPage';
|
||||||
import CampaignsListPage from '@/pages/public/CampaignsListPage';
|
import CampaignsListPage from '@/pages/public/CampaignsListPage';
|
||||||
import CampaignPage from '@/pages/public/CampaignPage';
|
import CampaignPage from '@/pages/public/CampaignPage';
|
||||||
|
import CreateCampaignPage from '@/pages/public/CreateCampaignPage';
|
||||||
|
import MyCampaignsPage from '@/pages/public/MyCampaignsPage';
|
||||||
import ResponseWallPage from '@/pages/public/ResponseWallPage';
|
import ResponseWallPage from '@/pages/public/ResponseWallPage';
|
||||||
import MapPage from '@/pages/public/MapPage';
|
import MapPage from '@/pages/public/MapPage';
|
||||||
import PublicShiftsPage from '@/pages/public/ShiftsPage';
|
import PublicShiftsPage from '@/pages/public/ShiftsPage';
|
||||||
import MediaGalleryPage from '@/pages/public/MediaGalleryPage';
|
import MediaGalleryPage from '@/pages/public/MediaGalleryPage';
|
||||||
|
import ShortsPage from '@/pages/public/ShortsPage';
|
||||||
import MediaViewerPage from '@/pages/public/MediaViewerPage';
|
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 MyActivityPage from '@/pages/volunteer/MyActivityPage';
|
||||||
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
|
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
|
||||||
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
|
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
|
||||||
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
|
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
|
||||||
import { ADMIN_ROLES } from '@/types/api';
|
import { ADMIN_ROLES } from '@/types/api';
|
||||||
|
import { isAdmin } from '@/utils/roles';
|
||||||
|
import VerifyEmailPage from '@/pages/VerifyEmailPage';
|
||||||
|
import ResetPasswordPage from '@/pages/ResetPasswordPage';
|
||||||
|
|
||||||
function RoleAwareRedirect() {
|
function RoleAwareRedirect() {
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
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 />;
|
return <Navigate to="/volunteer" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,6 +139,24 @@ export default function App() {
|
|||||||
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<CampaignsListPage />} />
|
<Route index element={<CampaignsListPage />} />
|
||||||
</Route>
|
</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="/campaign" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
||||||
<Route path=":slug" element={<CampaignPage />} />
|
<Route path=":slug" element={<CampaignPage />} />
|
||||||
<Route path=":slug/responses" element={<ResponseWallPage />} />
|
<Route path=":slug/responses" element={<ResponseWallPage />} />
|
||||||
@ -139,8 +170,20 @@ export default function App() {
|
|||||||
{/* Public Media Gallery (purple theme) — feature-gated */}
|
{/* Public Media Gallery (purple theme) — feature-gated */}
|
||||||
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<MediaGalleryPage />} />
|
<Route index element={<MediaGalleryPage />} />
|
||||||
|
<Route path="shorts" element={<ShortsPage />} />
|
||||||
<Route path=":category" element={<MediaGalleryPage />} />
|
<Route path=":category" element={<MediaGalleryPage />} />
|
||||||
</Route>
|
</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>} />
|
<Route path="/gallery/watch/:id" element={<FeatureGate feature="enableMediaFeatures"><MediaViewerPage /></FeatureGate>} />
|
||||||
{/* Email link alias for video viewer */}
|
{/* Email link alias for video viewer */}
|
||||||
<Route path="/media/:id" element={<MediaViewerPage />} />
|
<Route path="/media/:id" element={<MediaViewerPage />} />
|
||||||
@ -175,6 +218,8 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/app"
|
path="/app"
|
||||||
element={
|
element={
|
||||||
@ -232,6 +277,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="campaign-moderation"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||||
|
<CampaignModerationPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="listmonk"
|
path="listmonk"
|
||||||
element={
|
element={
|
||||||
@ -424,6 +477,22 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</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>
|
||||||
<Route path="*" element={<RoleAwareRedirect />} />
|
<Route path="*" element={<RoleAwareRedirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import {
|
|||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
SoundOutlined,
|
SoundOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
OrderedListOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
@ -63,6 +64,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
label: 'Influence',
|
label: 'Influence',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
|
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
|
||||||
|
{ key: '/app/campaign-moderation', icon: <FileTextOutlined />, label: 'Campaign Review' },
|
||||||
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
|
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
|
||||||
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
|
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
|
||||||
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
|
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
|
||||||
@ -120,6 +122,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me
|
|||||||
label: 'Media Library',
|
label: 'Media Library',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/app/media/library', icon: <FolderOutlined />, label: 'Videos' },
|
{ 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' },
|
{ 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 { Outlet } from 'react-router-dom';
|
||||||
import MediaSidebar from '@/components/media/MediaSidebar';
|
import MediaSidebar from '@/components/media/MediaSidebar';
|
||||||
import MediaBottomNav from '@/components/media/MediaBottomNav';
|
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;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
export default function MediaPublicLayout() {
|
export default function MediaPublicLayout() {
|
||||||
// Purple theme tokens matching media-manager aesthetic
|
const { settings } = useSettingsStore();
|
||||||
const colorPrimary = '#9333ea'; // purple-600
|
const { notifications, clearNotification } = useChatNotifications();
|
||||||
const colorBgBase = '#0d0d12'; // nearly black
|
|
||||||
const colorBgContainer = '#18181b'; // zinc-900
|
// Read colors from site settings (same source as PublicLayout)
|
||||||
const colorBgElevated = '#27272a'; // zinc-800
|
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 screens = useBreakpoint();
|
||||||
const isMobile = !screens.md; // < 768px
|
const isMobile = !screens.md; // < 768px
|
||||||
@ -43,8 +52,8 @@ export default function MediaPublicLayout() {
|
|||||||
|
|
||||||
// Set document title for media pages
|
// Set document title for media pages
|
||||||
useEffect(() => {
|
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
|
// Calculate main content left margin based on sidebar state and screen size
|
||||||
const mainContentMarginLeft = isMobile ? 0 : sidebarCollapsed ? 64 : 256;
|
const mainContentMarginLeft = isMobile ? 0 : sidebarCollapsed ? 64 : 256;
|
||||||
@ -57,18 +66,18 @@ export default function MediaPublicLayout() {
|
|||||||
colorPrimary,
|
colorPrimary,
|
||||||
colorBgBase,
|
colorBgBase,
|
||||||
colorBgContainer,
|
colorBgContainer,
|
||||||
colorBgElevated,
|
colorBgElevated: colorBgContainer,
|
||||||
colorBorder: 'rgba(147, 51, 234, 0.2)', // purple border
|
colorBorder: hexToRgba(colorPrimary, 0.2),
|
||||||
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
colorLink: '#a855f7', // purple-500
|
colorLink: colorPrimary,
|
||||||
colorLinkHover: '#c084fc', // purple-400
|
|
||||||
colorText: 'rgba(255, 255, 255, 0.85)',
|
colorText: 'rgba(255, 255, 255, 0.85)',
|
||||||
colorTextSecondary: 'rgba(255, 255, 255, 0.65)',
|
colorTextSecondary: 'rgba(255, 255, 255, 0.65)',
|
||||||
colorTextTertiary: 'rgba(255, 255, 255, 0.45)',
|
colorTextTertiary: 'rgba(255, 255, 255, 0.45)',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<ChatBarProvider>
|
||||||
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
|
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
|
||||||
{/* Desktop: Show sidebar, Mobile: Hide */}
|
{/* Desktop: Show sidebar, Mobile: Hide */}
|
||||||
{!isMobile && <MediaSidebar />}
|
{!isMobile && <MediaSidebar />}
|
||||||
@ -79,7 +88,7 @@ export default function MediaPublicLayout() {
|
|||||||
marginLeft: mainContentMarginLeft,
|
marginLeft: mainContentMarginLeft,
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
paddingBottom: isMobile ? 56 : 0, // Space for mobile bottom nav
|
paddingBottom: 48, // Space for bottom search bar
|
||||||
transition: 'margin-left 0.3s ease',
|
transition: 'margin-left 0.3s ease',
|
||||||
background: colorBgBase,
|
background: colorBgBase,
|
||||||
}}
|
}}
|
||||||
@ -88,8 +97,7 @@ export default function MediaPublicLayout() {
|
|||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
padding: isMobile ? '16px 12px' : '24px 32px',
|
padding: isMobile ? '8px 8px' : '12px 12px',
|
||||||
maxWidth: 1400, // Wider for video grid
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@ -98,7 +106,17 @@ export default function MediaPublicLayout() {
|
|||||||
|
|
||||||
{/* Mobile: Show bottom nav, Desktop: Hide */}
|
{/* Mobile: Show bottom nav, Desktop: Hide */}
|
||||||
<MediaBottomNav />
|
<MediaBottomNav />
|
||||||
|
|
||||||
|
{/* Chat reply notifications */}
|
||||||
|
<ChatNotificationToast
|
||||||
|
notifications={notifications}
|
||||||
|
clearNotification={clearNotification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Messenger-style chat bar */}
|
||||||
|
<ChatBar />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</ChatBarProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { Spin, Result } from 'antd';
|
import { Spin, Result } from 'antd';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { hasAnyRole } from '@/utils/roles';
|
||||||
import type { UserRole } from '@/types/api';
|
import type { UserRole } from '@/types/api';
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
@ -33,7 +34,7 @@ export default function ProtectedRoute({
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
|
if (requiredRoles && user && !hasAnyRole(user, requiredRoles)) {
|
||||||
return (
|
return (
|
||||||
<Result
|
<Result
|
||||||
status="403"
|
status="403"
|
||||||
|
|||||||
@ -1,13 +1,65 @@
|
|||||||
import { useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ConfigProvider, Layout, Typography, theme, Space } from 'antd';
|
import { ConfigProvider, Layout, Typography, theme, Space } from 'antd';
|
||||||
import { Outlet, Link } from 'react-router-dom';
|
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||||
import { PlayCircleOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined, PlusCircleOutlined, FileTextOutlined, LoginOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import AuthModal from '@/components/AuthModal';
|
||||||
|
|
||||||
const { Header, Content, Footer } = Layout;
|
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() {
|
export default function PublicLayout() {
|
||||||
const { settings } = useSettingsStore();
|
const { settings } = useSettingsStore();
|
||||||
|
const { isAuthenticated, logout } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
|
|
||||||
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
|
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
|
||||||
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
||||||
@ -75,28 +127,20 @@ export default function PublicLayout() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Right: Navigation */}
|
{/* Right: Navigation */}
|
||||||
<Space size={24}>
|
<Space size={16} wrap>
|
||||||
<Link
|
{isAuthenticated ? (
|
||||||
to="/gallery"
|
<>
|
||||||
style={{
|
<NavLink to="/campaigns/create" icon={<PlusCircleOutlined />} label="Create Campaign" />
|
||||||
color: 'rgba(255, 255, 255, 0.85)',
|
<NavLink to="/campaigns/mine" icon={<FileTextOutlined />} label="My Campaigns" />
|
||||||
textDecoration: 'none',
|
<NavButton onClick={() => logout()} icon={<LogoutOutlined />} label="Logout" />
|
||||||
display: 'flex',
|
</>
|
||||||
alignItems: 'center',
|
) : (
|
||||||
gap: 6,
|
<>
|
||||||
fontSize: 14,
|
<NavButton onClick={() => setAuthModalOpen(true)} icon={<PlusCircleOutlined />} label="Create Campaign" />
|
||||||
transition: 'color 0.2s',
|
<NavButton onClick={() => setAuthModalOpen(true)} icon={<LoginOutlined />} label="Sign In" />
|
||||||
}}
|
</>
|
||||||
onMouseEnter={(e) => {
|
)}
|
||||||
e.currentTarget.style.color = '#fff';
|
<NavLink to="/gallery" icon={<PlayCircleOutlined />} label="Media Gallery" />
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlayCircleOutlined />
|
|
||||||
<span>Media Gallery</span>
|
|
||||||
</Link>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Header>
|
</Header>
|
||||||
<Content
|
<Content
|
||||||
@ -124,11 +168,26 @@ export default function PublicLayout() {
|
|||||||
Campaigns
|
Campaigns
|
||||||
</Link>
|
</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 }}>
|
<Link to="/gallery" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
||||||
Media Gallery
|
Media Gallery
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</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>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Marker, Popup } from 'react-leaflet';
|
import { Marker, Popup } from 'react-leaflet';
|
||||||
import { Alert, theme } from 'antd';
|
import { Alert, theme } from 'antd';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
@ -76,6 +76,150 @@ function apartmentSvg(color: string, size: number, selected: boolean): string {
|
|||||||
</svg>`;
|
</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) {
|
function CanvassMarkerGroup({ group, selectedAddressId, onAddressClick }: CanvassMarkerGroupProps) {
|
||||||
const addresses = group.addresses;
|
const addresses = group.addresses;
|
||||||
const isMultiUnit = group.isMultiUnit;
|
const isMultiUnit = group.isMultiUnit;
|
||||||
@ -115,107 +259,14 @@ function CanvassMarkerGroup({ group, selectedAddressId, onAddressClick }: Canvas
|
|||||||
<Popup maxWidth={350} minWidth={250}>
|
<Popup maxWidth={350} minWidth={250}>
|
||||||
<div style={{ minWidth: 230, maxWidth: 330 }}>
|
<div style={{ minWidth: 230, maxWidth: 330 }}>
|
||||||
{isMultiUnit ? (
|
{isMultiUnit ? (
|
||||||
// Multi-unit building display
|
// Multi-unit building display — compact dropdown
|
||||||
<>
|
<MultiUnitPopup
|
||||||
<div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: `2px solid ${token.colorPrimary}` }}>
|
group={group}
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: token.colorPrimary }}>
|
addresses={addresses}
|
||||||
🏢 {group.baseAddress}
|
selectedAddressId={selectedAddressId}
|
||||||
</div>
|
onAddressClick={onAddressClick}
|
||||||
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
|
token={token}
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
// Single unit display
|
// Single unit display
|
||||||
<div style={{ cursor: 'pointer' }} onClick={() => onAddressClick(addresses[0]!.id)}>
|
<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';
|
} from 'antd';
|
||||||
import { UserOutlined, SendOutlined } from '@ant-design/icons';
|
import { UserOutlined, SendOutlined } from '@ant-design/icons';
|
||||||
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
|
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
|
||||||
@ -85,8 +86,7 @@ export default function CommentSection({ videoId }: CommentSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
if (!useAuthStore.getState().isAuthenticated) {
|
||||||
if (!accessToken) {
|
|
||||||
message.warning('Please log in to comment');
|
message.warning('Please log in to comment');
|
||||||
return;
|
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 { EditOutlined } from '@ant-design/icons';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
@ -39,6 +39,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
|
|||||||
category: v.category || undefined,
|
category: v.category || undefined,
|
||||||
tags: Array.isArray(v.tags) ? v.tags : [],
|
tags: Array.isArray(v.tags) ? v.tags : [],
|
||||||
quality: v.quality || '',
|
quality: v.quality || '',
|
||||||
|
isShort: v.isShort ?? false,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@ -50,6 +51,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
|
|||||||
category: video.category || undefined,
|
category: video.category || undefined,
|
||||||
tags: Array.isArray(video.tags) ? video.tags : [],
|
tags: Array.isArray(video.tags) ? video.tags : [],
|
||||||
quality: video.quality || '',
|
quality: video.quality || '',
|
||||||
|
isShort: video.isShort ?? false,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setFetching(false));
|
.finally(() => setFetching(false));
|
||||||
@ -70,6 +72,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
|
|||||||
payload.creator = values.creator || null;
|
payload.creator = values.creator || null;
|
||||||
payload.category = values.category || null;
|
payload.category = values.category || null;
|
||||||
payload.tags = values.tags && values.tags.length > 0 ? values.tags : 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);
|
await mediaApi.patch(`/videos/${video.id}`, payload);
|
||||||
message.success('Video updated successfully');
|
message.success('Video updated successfully');
|
||||||
@ -136,6 +139,10 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="isShort" label="Short Video" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="tags" label="Tags">
|
<Form.Item name="tags" label="Tags">
|
||||||
<Select
|
<Select
|
||||||
mode="tags"
|
mode="tags"
|
||||||
|
|||||||
@ -6,13 +6,16 @@ import {
|
|||||||
LikeFilled,
|
LikeFilled,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
CommentOutlined,
|
CommentOutlined,
|
||||||
|
OrderedListOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useExpandedVideo, type VideoData } from '@/contexts/ExpandedVideoContext';
|
import { useExpandedVideo, type VideoData } from '@/contexts/ExpandedVideoContext';
|
||||||
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
|
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import VideoPlayer, { VideoPlayerRef } from './VideoPlayer';
|
import VideoPlayer, { VideoPlayerRef } from './VideoPlayer';
|
||||||
import LiveChat from './LiveChat';
|
import LiveChat from './LiveChat';
|
||||||
import ProgressBarMarkers from './ProgressBarMarkers';
|
import ProgressBarMarkers from './ProgressBarMarkers';
|
||||||
import ReactionButtons from './ReactionButtons';
|
import ReactionButtons from './ReactionButtons';
|
||||||
|
import AddToPlaylistModal from './AddToPlaylistModal';
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||||
|
|
||||||
@ -35,30 +38,14 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
|||||||
const [upvoteCount, setUpvoteCount] = useState(video.upvoteCount);
|
const [upvoteCount, setUpvoteCount] = useState(video.upvoteCount);
|
||||||
const [upvoting, setUpvoting] = useState(false);
|
const [upvoting, setUpvoting] = useState(false);
|
||||||
const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
|
const [isMobileChatOpen, setIsMobileChatOpen] = useState(false);
|
||||||
|
const [addToPlaylistOpen, setAddToPlaylistOpen] = useState(false);
|
||||||
const [videoHeight, setVideoHeight] = useState<number>(0);
|
const [videoHeight, setVideoHeight] = useState<number>(0);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [isExpanding, setIsExpanding] = useState(true);
|
const [isExpanding, setIsExpanding] = useState(true);
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
// Read sidebar collapse state for full-width calculation
|
// Parent padding to break out of (matches MediaPublicLayout)
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
const pad = isMobile ? 8 : 12;
|
||||||
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;
|
|
||||||
|
|
||||||
// Extract title from filename
|
// Extract title from filename
|
||||||
const title = video.filename.replace(/\.[^/.]+$/, '');
|
const title = video.filename.replace(/\.[^/.]+$/, '');
|
||||||
@ -153,10 +140,6 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
|||||||
return count.toString();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@ -168,12 +151,10 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
|||||||
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
|
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
|
||||||
maxHeight: isExpanding ? 0 : 3000,
|
maxHeight: isExpanding ? 0 : 3000,
|
||||||
opacity: isExpanding ? 0 : 1,
|
opacity: isExpanding ? 0 : 1,
|
||||||
// Break out of parent padding + maxWidth to fill full content area
|
// Break out of parent padding to fill full content area
|
||||||
width: fullWidth,
|
marginLeft: -pad,
|
||||||
position: 'relative',
|
marginRight: -pad,
|
||||||
left: '50%',
|
width: `calc(100% + ${pad * 2}px)`,
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
marginLeft: `calc(-50% - ${sidebarWidth / 2}px + 50%)`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Main content: video (left) + chat (right) */}
|
{/* Main content: video (left) + chat (right) */}
|
||||||
@ -192,6 +173,8 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: video.orientation === 'V' ? '9/16' : '16/9',
|
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',
|
background: '#000',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -306,6 +289,18 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
|||||||
{formatCount(upvoteCount)}
|
{formatCount(upvoteCount)}
|
||||||
</Button>
|
</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 */}
|
{/* Mobile chat toggle */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Button
|
<Button
|
||||||
@ -337,6 +332,13 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
|||||||
</MediaAuthProvider>
|
</MediaAuthProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add to Playlist Modal */}
|
||||||
|
<AddToPlaylistModal
|
||||||
|
videoId={video.id}
|
||||||
|
open={addToPlaylistOpen}
|
||||||
|
onClose={() => setAddToPlaylistOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</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';
|
} from '@ant-design/icons';
|
||||||
import { useMediaAuth } from '@/contexts/MediaAuthContext';
|
import { useMediaAuth } from '@/contexts/MediaAuthContext';
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@ -163,7 +164,7 @@ export default function LiveChat({
|
|||||||
if (!isOpen || eventSourceRef.current) return;
|
if (!isOpen || eventSourceRef.current) return;
|
||||||
|
|
||||||
// Use relative URL to go through nginx proxy
|
// 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);
|
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
|
// Fetch timeline and setup SSE when component opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchInitialTimeline();
|
fetchInitialTimeline();
|
||||||
setupSSE();
|
setupSSE();
|
||||||
|
markAsRead();
|
||||||
}
|
}
|
||||||
}, [isOpen, videoId, setupSSE]);
|
}, [isOpen, videoId, setupSSE, markAsRead]);
|
||||||
|
|
||||||
// Handle comment submission
|
// Handle comment submission
|
||||||
const handleSubmitComment = async () => {
|
const handleSubmitComment = async () => {
|
||||||
|
|||||||
@ -1,108 +1,101 @@
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useState, useEffect } from 'react';
|
||||||
import { Typography } from 'antd';
|
import { useSearchParams, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { Input, Select, theme, Grid } from 'antd';
|
||||||
HomeOutlined,
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
ThunderboltOutlined,
|
|
||||||
VideoCameraOutlined,
|
|
||||||
AppstoreOutlined,
|
|
||||||
StarOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MediaBottomNav() {
|
export default function MediaBottomNav() {
|
||||||
const navigate = useNavigate();
|
const { token } = theme.useToken();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// Navigation items (shortened labels for mobile)
|
// Initialize from URL params
|
||||||
const navItems: NavItem[] = [
|
const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
|
||||||
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
|
const sort = (searchParams.get('sort') as 'recent' | 'popular' | 'most_viewed') || 'recent';
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Determine active nav item from current path
|
const isShorts = location.pathname === '/gallery/shorts';
|
||||||
const getActiveKey = () => {
|
|
||||||
const path = location.pathname;
|
// Debounce search → URL param (skip on shorts — search navigates to gallery on Enter)
|
||||||
if (path === '/gallery') return 'all';
|
useEffect(() => {
|
||||||
const match = navItems.find((item) => path.startsWith(item.path));
|
if (isShorts) return;
|
||||||
return match ? match.key : 'all';
|
|
||||||
|
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();
|
// On shorts page, Enter in search navigates to gallery with the search term
|
||||||
|
const handleSearchSubmit = () => {
|
||||||
const handleNavigate = (path: string) => {
|
if (isShorts && searchInput.trim()) {
|
||||||
navigate(path);
|
navigate(`/gallery?search=${encodeURIComponent(searchInput.trim())}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="md:hidden" // Hide on desktop (>= 768px), show on mobile (< 768px)
|
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: 56,
|
height: 48,
|
||||||
background: '#18181b', // zinc-900
|
background: isShorts ? 'rgba(0, 0, 0, 0.75)' : token.colorBgContainer,
|
||||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
backdropFilter: isShorts ? 'blur(12px)' : undefined,
|
||||||
|
borderTop: isShorts ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${token.colorBorderSecondary}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-around',
|
gap: 8,
|
||||||
padding: '0 4px',
|
padding: isMobile ? '0 8px' : '0 16px',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{navItems.map((item) => {
|
<Input
|
||||||
const isActive = activeKey === item.key;
|
placeholder="Search videos..."
|
||||||
return (
|
prefix={<SearchOutlined style={{ color: token.colorTextTertiary }} />}
|
||||||
<div
|
value={searchInput}
|
||||||
key={item.key}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onClick={() => handleNavigate(item.path)}
|
onPressEnter={handleSearchSubmit}
|
||||||
style={{
|
allowClear
|
||||||
flex: 1,
|
size="small"
|
||||||
display: 'flex',
|
style={{ flex: 1 }}
|
||||||
flexDirection: 'column',
|
/>
|
||||||
alignItems: 'center',
|
<Select
|
||||||
justifyContent: 'center',
|
value={sort}
|
||||||
gap: 2,
|
onChange={handleSortChange}
|
||||||
padding: '6px 4px',
|
size="small"
|
||||||
cursor: 'pointer',
|
style={{ width: isMobile ? 110 : 140, flexShrink: 0 }}
|
||||||
color: isActive ? '#9333ea' : 'rgba(255,255,255,0.65)',
|
options={[
|
||||||
transition: 'color 0.2s ease',
|
{ value: 'recent', label: 'Recent' },
|
||||||
}}
|
{ value: 'popular', label: 'Popular' },
|
||||||
>
|
{ value: 'most_viewed', label: 'Most Viewed' },
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Typography, Space, Tooltip } from 'antd';
|
import { Typography, Space, Tooltip, Badge, theme } from 'antd';
|
||||||
import {
|
import {
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
AppstoreOutlined,
|
|
||||||
StarOutlined,
|
StarOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
@ -14,12 +13,15 @@ import {
|
|||||||
LoginOutlined,
|
LoginOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
|
MessageOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { hexToRgba } from '@/utils/color';
|
||||||
|
import { mediaApi } from '@/lib/media-api';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -30,6 +32,17 @@ interface NavItem {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChatThread {
|
||||||
|
mediaId: number;
|
||||||
|
videoTitle: string;
|
||||||
|
unreadCount: number;
|
||||||
|
lastMessage: {
|
||||||
|
content: string;
|
||||||
|
userName: string;
|
||||||
|
createdAt: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SectionState {
|
interface SectionState {
|
||||||
content: boolean;
|
content: boolean;
|
||||||
activity: boolean;
|
activity: boolean;
|
||||||
@ -40,6 +53,7 @@ interface SectionState {
|
|||||||
export default function MediaSidebar() {
|
export default function MediaSidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
// Only hydrate auth if tokens exist (prevents 401 errors on public pages)
|
// Only hydrate auth if tokens exist (prevents 401 errors on public pages)
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
@ -47,11 +61,9 @@ export default function MediaSidebar() {
|
|||||||
const hydrate = useAuthStore((state) => state.hydrate);
|
const hydrate = useAuthStore((state) => state.hydrate);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if auth tokens exist before attempting to hydrate
|
// Check if auth data exists before attempting to hydrate
|
||||||
const accessToken = localStorage.getItem('access_token');
|
const authData = localStorage.getItem('cml-auth');
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
if (authData) {
|
||||||
|
|
||||||
if (accessToken || refreshToken) {
|
|
||||||
hydrate();
|
hydrate();
|
||||||
}
|
}
|
||||||
}, [hydrate]);
|
}, [hydrate]);
|
||||||
@ -70,8 +82,29 @@ export default function MediaSidebar() {
|
|||||||
: { content: true, activity: true, online: true, account: true };
|
: { content: true, activity: true, online: true, account: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock data for activity feed (currently empty)
|
// Chat threads state
|
||||||
const recentVideos: any[] = [];
|
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
|
// Save collapse state to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -82,26 +115,26 @@ export default function MediaSidebar() {
|
|||||||
localStorage.setItem('media_sidebar_sections', JSON.stringify(sections));
|
localStorage.setItem('media_sidebar_sections', JSON.stringify(sections));
|
||||||
}, [sections]);
|
}, [sections]);
|
||||||
|
|
||||||
|
// Derived hover colors
|
||||||
|
const hoverBg = hexToRgba(token.colorPrimary, 0.1);
|
||||||
|
const userInfoBg = hexToRgba(token.colorPrimary, 0.05);
|
||||||
|
|
||||||
// Navigation items
|
// Navigation items
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
|
{ key: 'all', label: 'All', icon: <HomeOutlined />, path: '/gallery' },
|
||||||
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
|
{ key: 'shorts', label: 'Shorts', icon: <ThunderboltOutlined />, path: '/gallery/shorts' },
|
||||||
{ key: 'videos', label: 'Videos', icon: <VideoCameraOutlined />, path: '/gallery/videos' },
|
{ 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' },
|
{ 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 getActiveKey = () => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
if (path === '/gallery') return 'all';
|
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';
|
return match ? match.key : 'all';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -131,7 +164,7 @@ export default function MediaSidebar() {
|
|||||||
style={{
|
style={{
|
||||||
width: sidebarWidth,
|
width: sidebarWidth,
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
background: '#18181b', // zinc-900
|
background: token.colorBgContainer,
|
||||||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@ -157,7 +190,7 @@ export default function MediaSidebar() {
|
|||||||
strong
|
strong
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: '#9333ea',
|
color: token.colorPrimary,
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -178,7 +211,7 @@ export default function MediaSidebar() {
|
|||||||
<PlayCircleOutlined
|
<PlayCircleOutlined
|
||||||
style={{
|
style={{
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
color: '#9333ea',
|
color: token.colorPrimary,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -245,7 +278,7 @@ export default function MediaSidebar() {
|
|||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
padding: collapsed ? '12px 0' : '12px 16px',
|
||||||
margin: collapsed ? '4px 0' : '2px 0',
|
margin: collapsed ? '4px 0' : '2px 0',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: isActive ? '#9333ea' : 'transparent',
|
background: isActive ? token.colorPrimary : 'transparent',
|
||||||
borderRadius: collapsed ? 0 : 8,
|
borderRadius: collapsed ? 0 : 8,
|
||||||
color: isActive
|
color: isActive
|
||||||
? '#fff'
|
? '#fff'
|
||||||
@ -255,7 +288,7 @@ export default function MediaSidebar() {
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
|
e.currentTarget.style.background = hoverBg;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
@ -306,7 +339,7 @@ export default function MediaSidebar() {
|
|||||||
letterSpacing: '1px',
|
letterSpacing: '1px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ACTIVITY
|
MY CHATS
|
||||||
</Text>
|
</Text>
|
||||||
{sections.activity ? (
|
{sections.activity ? (
|
||||||
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
<DownOutlined style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)' }} />
|
||||||
@ -323,7 +356,7 @@ export default function MediaSidebar() {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{recentVideos.length === 0 ? (
|
{chatThreads.length === 0 ? (
|
||||||
<div style={{ padding: '12px 16px' }}>
|
<div style={{ padding: '12px 16px' }}>
|
||||||
<Text
|
<Text
|
||||||
type="secondary"
|
type="secondary"
|
||||||
@ -332,30 +365,60 @@ export default function MediaSidebar() {
|
|||||||
color: 'rgba(255,255,255,0.35)',
|
color: 'rgba(255,255,255,0.35)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
No recent activity
|
{user ? 'No chat threads yet' : 'Sign in to see chats'}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
recentVideos.slice(0, 10).map((video, index) => (
|
chatThreads.map((thread) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={thread.mediaId}
|
||||||
|
onClick={() => navigate(`/gallery/watch/${thread.mediaId}`)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: 'rgba(255,255,255,0.65)',
|
color: 'rgba(255,255,255,0.65)',
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
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>
|
<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
|
<Text
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
ellipsis
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: 'rgba(255,255,255,0.35)',
|
color: 'rgba(255,255,255,0.35)',
|
||||||
|
display: 'block',
|
||||||
|
paddingLeft: 18,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{video.timestamp}
|
{thread.lastMessage.userName}: {thread.lastMessage.content}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -398,7 +461,7 @@ export default function MediaSidebar() {
|
|||||||
{sections.online && (
|
{sections.online && (
|
||||||
<div style={{ padding: '12px 16px' }}>
|
<div style={{ padding: '12px 16px' }}>
|
||||||
<Space>
|
<Space>
|
||||||
<TeamOutlined style={{ color: '#9333ea' }} />
|
<TeamOutlined style={{ color: token.colorPrimary }} />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@ -460,12 +523,12 @@ export default function MediaSidebar() {
|
|||||||
style={{
|
style={{
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
background: 'rgba(147, 51, 234, 0.05)',
|
background: userInfoBg,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space>
|
<Space>
|
||||||
<UserOutlined style={{ color: '#9333ea' }} />
|
<UserOutlined style={{ color: token.colorPrimary }} />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@ -479,9 +542,12 @@ export default function MediaSidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* My Stats */}
|
{/* My Stats */}
|
||||||
|
{(() => {
|
||||||
|
const isActive = location.pathname === '/gallery/my-stats';
|
||||||
|
return (
|
||||||
<Tooltip title={collapsed ? 'My Stats' : ''} placement="right">
|
<Tooltip title={collapsed ? 'My Stats' : ''} placement="right">
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate('/app')}
|
onClick={() => navigate('/gallery/my-stats')}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -489,27 +555,33 @@ export default function MediaSidebar() {
|
|||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
padding: collapsed ? '12px 0' : '12px 16px',
|
||||||
margin: collapsed ? '4px 0' : '2px 0',
|
margin: collapsed ? '4px 0' : '2px 0',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
background: isActive ? token.colorPrimary : 'transparent',
|
||||||
borderRadius: collapsed ? 0 : 8,
|
borderRadius: collapsed ? 0 : 8,
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
|
if (!isActive) e.currentTarget.style.background = hoverBg;
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
if (!isActive) e.currentTarget.style.background = 'transparent';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BarChartOutlined style={{ fontSize: 18 }} />
|
<BarChartOutlined style={{ fontSize: 18 }} />
|
||||||
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>My Stats</Text>}
|
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit', fontWeight: isActive ? 500 : 400 }}>My Stats</Text>}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
|
{(() => {
|
||||||
|
const isActive = location.pathname === '/gallery/my-settings';
|
||||||
|
return (
|
||||||
<Tooltip title={collapsed ? 'Settings' : ''} placement="right">
|
<Tooltip title={collapsed ? 'Settings' : ''} placement="right">
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate('/app/settings')}
|
onClick={() => navigate('/gallery/my-settings')}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -517,22 +589,25 @@ export default function MediaSidebar() {
|
|||||||
padding: collapsed ? '12px 0' : '12px 16px',
|
padding: collapsed ? '12px 0' : '12px 16px',
|
||||||
margin: collapsed ? '4px 0' : '2px 0',
|
margin: collapsed ? '4px 0' : '2px 0',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
background: isActive ? token.colorPrimary : 'transparent',
|
||||||
borderRadius: collapsed ? 0 : 8,
|
borderRadius: collapsed ? 0 : 8,
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: isActive ? '#fff' : 'rgba(255,255,255,0.85)',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
|
if (!isActive) e.currentTarget.style.background = hoverBg;
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
if (!isActive) e.currentTarget.style.background = 'transparent';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingOutlined style={{ fontSize: 18 }} />
|
<SettingOutlined style={{ fontSize: 18 }} />
|
||||||
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit' }}>Settings</Text>}
|
{!collapsed && <Text style={{ fontSize: 14, color: 'inherit', fontWeight: isActive ? 500 : 400 }}>Settings</Text>}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Sign Out */}
|
{/* Sign Out */}
|
||||||
<Tooltip title={collapsed ? 'Sign Out' : ''} placement="right">
|
<Tooltip title={collapsed ? 'Sign Out' : ''} placement="right">
|
||||||
@ -551,7 +626,7 @@ export default function MediaSidebar() {
|
|||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
|
e.currentTarget.style.background = hoverBg;
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
e.currentTarget.style.background = 'transparent';
|
||||||
@ -575,12 +650,12 @@ export default function MediaSidebar() {
|
|||||||
margin: collapsed ? '4px 0' : '2px 0',
|
margin: collapsed ? '4px 0' : '2px 0',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderRadius: collapsed ? 0 : 8,
|
borderRadius: collapsed ? 0 : 8,
|
||||||
color: '#9333ea',
|
color: token.colorPrimary,
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
|
e.currentTarget.style.background = hoverBg;
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
e.currentTarget.style.background = 'transparent';
|
||||||
@ -630,8 +705,8 @@ export default function MediaSidebar() {
|
|||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
|
e.currentTarget.style.background = hoverBg;
|
||||||
e.currentTarget.style.color = '#9333ea';
|
e.currentTarget.style.color = token.colorPrimary;
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
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 { PlayCircleOutlined, LikeOutlined, EyeOutlined, CommentOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||||
|
import { hexToRgba } from '@/utils/color';
|
||||||
|
|
||||||
interface PublicVideoCardProps {
|
interface PublicVideoCardProps {
|
||||||
video: {
|
video: {
|
||||||
@ -28,6 +29,7 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
|||||||
|
|
||||||
// Hover video preview state
|
// Hover video preview state
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
|
const [thumbnailError, setThumbnailError] = useState(false);
|
||||||
const hoverTimeout = useRef<number | null>(null);
|
const hoverTimeout = useRef<number | null>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
@ -157,10 +159,11 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
|||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : video.thumbnailPath ? (
|
) : video.thumbnailPath && !thumbnailError ? (
|
||||||
<img
|
<img
|
||||||
src={`/media/public/${video.id}/thumbnail`}
|
src={`/media/public/${video.id}/thumbnail`}
|
||||||
alt={title}
|
alt={title}
|
||||||
|
onError={() => setThumbnailError(true)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@ -231,7 +234,7 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
|||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: `rgba(147, 51, 234, 0.9)`,
|
background: hexToRgba(token.colorPrimary, 0.9),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react';
|
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 { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { hexToRgba } from '@/utils/color';
|
||||||
|
|
||||||
interface ReactionButtonsProps {
|
interface ReactionButtonsProps {
|
||||||
videoId: number;
|
videoId: number;
|
||||||
@ -24,13 +26,15 @@ interface FloatingEmoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ReactionButtons({ videoId, currentTime }: ReactionButtonsProps) {
|
export default function ReactionButtons({ videoId, currentTime }: ReactionButtonsProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const [floatingEmojis, setFloatingEmojis] = useState<FloatingEmoji[]>([]);
|
const [floatingEmojis, setFloatingEmojis] = useState<FloatingEmoji[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const hoverBg = hexToRgba(token.colorPrimary, 0.1);
|
||||||
|
|
||||||
const handleReaction = async (reactionType: string, emoji: string) => {
|
const handleReaction = async (reactionType: string, emoji: string) => {
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
if (!isAuthenticated) {
|
||||||
if (!accessToken) {
|
|
||||||
message.warning('Please log in to add reactions');
|
message.warning('Please log in to add reactions');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -91,7 +95,7 @@ export default function ReactionButtons({ videoId, currentTime }: ReactionButton
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1.2)';
|
e.currentTarget.style.transform = 'scale(1.2)';
|
||||||
e.currentTarget.style.background = 'rgba(147, 51, 234, 0.1)';
|
e.currentTarget.style.background = hoverBg;
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
|
OrderedListOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Video } from '@/types/media';
|
import type { Video } from '@/types/media';
|
||||||
@ -25,6 +26,7 @@ interface VideoActionsProps {
|
|||||||
onAnalytics?: (video: Video) => void;
|
onAnalytics?: (video: Video) => void;
|
||||||
onSchedule?: (video: Video) => void;
|
onSchedule?: (video: Video) => void;
|
||||||
onDelete?: (video: Video) => void;
|
onDelete?: (video: Video) => void;
|
||||||
|
onAddToPlaylist?: (video: Video) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,6 +37,7 @@ export default function VideoActions({
|
|||||||
onAnalytics,
|
onAnalytics,
|
||||||
onSchedule,
|
onSchedule,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onAddToPlaylist,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: VideoActionsProps) {
|
}: VideoActionsProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -162,6 +165,12 @@ export default function VideoActions({
|
|||||||
|
|
||||||
// Overflow menu items
|
// Overflow menu items
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
|
{
|
||||||
|
key: 'add-to-playlist',
|
||||||
|
label: 'Add to Playlist',
|
||||||
|
icon: <OrderedListOutlined />,
|
||||||
|
onClick: () => onAddToPlaylist?.(video),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'duplicate',
|
key: 'duplicate',
|
||||||
label: 'Duplicate',
|
label: 'Duplicate',
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
import { Card, Checkbox, Tag, Spin } from 'antd';
|
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 { useState } from 'react';
|
||||||
import type { Video } from '@/types/media';
|
import type { Video } from '@/types/media';
|
||||||
|
import { getAuthCallbacks } from '@/lib/api';
|
||||||
import VideoActions from './VideoActions';
|
import VideoActions from './VideoActions';
|
||||||
import ScheduleBadge from './ScheduleBadge';
|
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 {
|
interface VideoCardProps {
|
||||||
video: Video;
|
video: Video;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@ -15,6 +25,7 @@ interface VideoCardProps {
|
|||||||
onAnalytics?: (video: Video) => void;
|
onAnalytics?: (video: Video) => void;
|
||||||
onSchedule?: (video: Video) => void;
|
onSchedule?: (video: Video) => void;
|
||||||
onDelete?: (video: Video) => void;
|
onDelete?: (video: Video) => void;
|
||||||
|
onAddToPlaylist?: (video: Video) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onTogglePublish?: (video: Video) => void;
|
onTogglePublish?: (video: Video) => void;
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
@ -30,6 +41,7 @@ export default function VideoCard({
|
|||||||
onAnalytics,
|
onAnalytics,
|
||||||
onSchedule,
|
onSchedule,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onAddToPlaylist,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onTogglePublish,
|
onTogglePublish,
|
||||||
showActions = true,
|
showActions = true,
|
||||||
@ -67,7 +79,7 @@ export default function VideoCard({
|
|||||||
{video.thumbnailUrl && !thumbnailError ? (
|
{video.thumbnailUrl && !thumbnailError ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={video.thumbnailUrl}
|
src={getAuthenticatedUrl(video.thumbnailUrl)}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -189,6 +201,7 @@ export default function VideoCard({
|
|||||||
onAnalytics={onAnalytics}
|
onAnalytics={onAnalytics}
|
||||||
onSchedule={onSchedule}
|
onSchedule={onSchedule}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onAddToPlaylist={onAddToPlaylist}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -213,6 +226,29 @@ export default function VideoCard({
|
|||||||
{formatDuration(video.duration)}
|
{formatDuration(video.duration)}
|
||||||
</div>
|
</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 */}
|
{/* Select checkbox */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||||
import { Alert, Spin } from 'antd';
|
import { Alert, Spin } from 'antd';
|
||||||
import { PlayCircleOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { getAuthCallbacks } from '@/lib/api';
|
||||||
|
|
||||||
export interface VideoMetadata {
|
export interface VideoMetadata {
|
||||||
id: number;
|
id: number;
|
||||||
@ -26,6 +27,8 @@ export interface VideoPlayerProps {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** When true, sends auth token for metadata fetch and appends token to stream/thumbnail URLs */
|
||||||
|
isAdmin?: boolean;
|
||||||
onLoadedMetadata?: (metadata: VideoMetadata) => void;
|
onLoadedMetadata?: (metadata: VideoMetadata) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
@ -55,6 +58,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
muted = false,
|
muted = false,
|
||||||
poster,
|
poster,
|
||||||
className = '',
|
className = '',
|
||||||
|
isAdmin = false,
|
||||||
onLoadedMetadata,
|
onLoadedMetadata,
|
||||||
onError,
|
onError,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
@ -118,13 +122,31 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
fetchMetadata();
|
fetchMetadata();
|
||||||
}, [videoId]);
|
}, [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 () => {
|
const fetchMetadata = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use relative URL to go through nginx proxy
|
// 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.ok) {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
@ -134,6 +156,13 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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);
|
setMetadata(data);
|
||||||
|
|
||||||
if (onLoadedMetadata) {
|
if (onLoadedMetadata) {
|
||||||
@ -215,6 +244,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: '#000',
|
background: '#000',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -2,6 +2,16 @@ import { Modal } from 'antd';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { Video } from '@/types/media';
|
import type { Video } from '@/types/media';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
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 {
|
interface VideoViewerModalProps {
|
||||||
video: Video | null;
|
video: Video | null;
|
||||||
@ -165,7 +175,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
|
|||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={`/media/videos/${video.id}/stream`}
|
src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
style={{
|
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 { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export interface VideoData {
|
export interface VideoData {
|
||||||
id: number;
|
id: number;
|
||||||
@ -43,7 +43,6 @@ interface ExpandedVideoProviderProps {
|
|||||||
|
|
||||||
export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps) {
|
export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const [state, setState] = useState<ExpandedVideoState>({
|
const [state, setState] = useState<ExpandedVideoState>({
|
||||||
videoId: null,
|
videoId: null,
|
||||||
@ -53,20 +52,20 @@ export function ExpandedVideoProvider({ children }: ExpandedVideoProviderProps)
|
|||||||
const expandVideo = useCallback((id: number, video: VideoData) => {
|
const expandVideo = useCallback((id: number, video: VideoData) => {
|
||||||
setState({ videoId: id, video });
|
setState({ videoId: id, video });
|
||||||
|
|
||||||
// Update URL with ?expanded=id
|
// Update URL with ?expanded=id (read current params at call time)
|
||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(window.location.search);
|
||||||
newParams.set('expanded', id.toString());
|
newParams.set('expanded', id.toString());
|
||||||
navigate({ search: newParams.toString() }, { replace: true });
|
navigate({ search: newParams.toString() }, { replace: true });
|
||||||
}, [navigate, searchParams]);
|
}, [navigate]);
|
||||||
|
|
||||||
const collapseVideo = useCallback(() => {
|
const collapseVideo = useCallback(() => {
|
||||||
setState({ videoId: null, video: null });
|
setState({ videoId: null, video: null });
|
||||||
|
|
||||||
// Remove URL param
|
// Remove URL param (read current params at call time)
|
||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(window.location.search);
|
||||||
newParams.delete('expanded');
|
newParams.delete('expanded');
|
||||||
navigate({ search: newParams.toString() }, { replace: true });
|
navigate({ search: newParams.toString() }, { replace: true });
|
||||||
}, [navigate, searchParams]);
|
}, [navigate]);
|
||||||
|
|
||||||
const value: ExpandedVideoContextValue = {
|
const value: ExpandedVideoContextValue = {
|
||||||
state,
|
state,
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import { createContext, useContext, ReactNode } from 'react';
|
||||||
import { jwtDecode } from 'jwt-decode';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
import { isAdmin } from '@/utils/roles';
|
||||||
interface JwtPayload {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaAuthState {
|
interface MediaAuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@ -16,10 +10,11 @@ interface MediaAuthState {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
token: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaAuthContextValue extends MediaAuthState {
|
interface MediaAuthContextValue extends MediaAuthState {
|
||||||
checkAuth: () => void; // Re-check auth state (e.g., after login)
|
checkAuth: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaAuthContext = createContext<MediaAuthContextValue | undefined>(undefined);
|
const MediaAuthContext = createContext<MediaAuthContextValue | undefined>(undefined);
|
||||||
@ -37,81 +32,29 @@ interface MediaAuthProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MediaAuthProvider({ children }: MediaAuthProviderProps) {
|
export function MediaAuthProvider({ children }: MediaAuthProviderProps) {
|
||||||
const [authState, setAuthState] = useState<MediaAuthState>({
|
// Read auth state directly from the Zustand auth store (single source of truth)
|
||||||
isAuthenticated: false,
|
const authUser = useAuthStore((s) => s.user);
|
||||||
isApproved: false,
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
user: null,
|
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 checkAuth = () => {
|
||||||
const token = localStorage.getItem('accessToken');
|
// Re-hydrate from persisted storage
|
||||||
|
hydrate();
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 = {
|
const value: MediaAuthContextValue = {
|
||||||
...authState,
|
isAuthenticated,
|
||||||
|
isApproved,
|
||||||
|
user,
|
||||||
|
token: accessToken,
|
||||||
checkAuth,
|
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 {
|
import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
MailOutlined,
|
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';
|
} 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 { 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() {
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { user } = useAuthStore();
|
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 (
|
return (
|
||||||
|
<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 && (
|
||||||
<>
|
<>
|
||||||
<Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>
|
<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 }}>
|
{/* === Weather + Key Metrics Row === */}
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||||
<Card>
|
{weather && (
|
||||||
<Statistic
|
<Col xs={12} sm={12} lg={4}>
|
||||||
title="Total Users"
|
<Card size="small" style={{ height: '100%', borderTop: '3px solid #1890ff' }} styles={{ body: { padding: '12px 16px' } }}>
|
||||||
value="--"
|
<div style={{ fontSize: 28, lineHeight: 1 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</div>
|
||||||
prefix={<TeamOutlined />}
|
<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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
)}
|
||||||
<Card>
|
|
||||||
<Statistic
|
<Col xs={12} sm={12} lg={4}>
|
||||||
title="Active Campaigns"
|
<StatCard title="Users" value={summary?.users.total} subtitle={summary ? `${summary.users.active} active` : ''} icon={<TeamOutlined />} color="#1890ff" onClick={() => navigate('/app/users')} />
|
||||||
value="--"
|
</Col>
|
||||||
prefix={<SendOutlined />}
|
|
||||||
/>
|
{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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
)}
|
||||||
<Card>
|
|
||||||
<Statistic
|
{showMap && (
|
||||||
title="Map Locations"
|
<Col xs={24} lg={12}>
|
||||||
value="--"
|
<Card
|
||||||
prefix={<EnvironmentOutlined />}
|
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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
)}
|
||||||
<Card>
|
|
||||||
<Statistic
|
<Col xs={24} lg={12}>
|
||||||
title="Emails Sent"
|
<Card
|
||||||
value="--"
|
title={<><FileTextOutlined style={{ marginRight: 6 }} />Content</>}
|
||||||
prefix={<MailOutlined />}
|
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} 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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Alert
|
{/* === System + Docker Section (SUPER_ADMIN only) === */}
|
||||||
message="Dashboard analytics coming soon"
|
{isSuperAdmin && (
|
||||||
description="Statistics and charts will be populated as additional modules are implemented."
|
<>
|
||||||
type="info"
|
{/* === Time-Series Charts (Traffic + Latency) === */}
|
||||||
showIcon
|
{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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Button,
|
Button,
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@ -28,6 +29,7 @@ import type {
|
|||||||
EmailTemplatesListParams,
|
EmailTemplatesListParams,
|
||||||
EmailTemplateCategory,
|
EmailTemplateCategory,
|
||||||
PaginationMeta,
|
PaginationMeta,
|
||||||
|
AppOutletContext,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import TestEmailModal from '@/components/email-templates/TestEmailModal';
|
import TestEmailModal from '@/components/email-templates/TestEmailModal';
|
||||||
import VersionHistoryDrawer from '@/components/email-templates/VersionHistoryDrawer';
|
import VersionHistoryDrawer from '@/components/email-templates/VersionHistoryDrawer';
|
||||||
@ -35,7 +37,7 @@ import EmailTemplateEditor from '@/components/email-templates/EmailTemplateEdito
|
|||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
const categoryOptions: { value: EmailTemplateCategory | 'ALL'; label: string }[] = [
|
const categoryOptions: { value: EmailTemplateCategory | 'ALL'; label: string }[] = [
|
||||||
{ value: 'ALL', label: 'All Categories' },
|
{ value: 'ALL', label: 'All Categories' },
|
||||||
@ -51,6 +53,7 @@ const activeOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function EmailTemplatesPage() {
|
export default function EmailTemplatesPage() {
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
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 editing a template, show the editor instead of the list
|
||||||
if (editingTemplateId) {
|
if (editingTemplateId) {
|
||||||
return (
|
return (
|
||||||
@ -259,37 +295,7 @@ export default function EmailTemplatesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px' }}>
|
<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%' }}>
|
<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
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={templates}
|
dataSource={templates}
|
||||||
|
|||||||
@ -71,6 +71,7 @@ import {
|
|||||||
SUPPORT_LEVEL_COLORS,
|
SUPPORT_LEVEL_COLORS,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import AdminMapView from '@/components/map/AdminMapView';
|
import AdminMapView from '@/components/map/AdminMapView';
|
||||||
|
import AreaImportWizard from '@/components/map/AreaImportWizard';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@ -116,7 +117,7 @@ export default function LocationsPage() {
|
|||||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [geocodingMissing, setGeocodingMissing] = useState(false);
|
const [geocodingMissing, setGeocodingMissing] = useState(false);
|
||||||
const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server'>('standard');
|
const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server' | 'area'>('standard');
|
||||||
const [importFilterType, setImportFilterType] = useState<'none' | 'cut' | 'mapArea' | 'city' | 'province'>('none');
|
const [importFilterType, setImportFilterType] = useState<'none' | 'cut' | 'mapArea' | 'city' | 'province'>('none');
|
||||||
const [importCutId, setImportCutId] = useState<string | undefined>();
|
const [importCutId, setImportCutId] = useState<string | undefined>();
|
||||||
const [importCity, setImportCity] = useState('');
|
const [importCity, setImportCity] = useState('');
|
||||||
@ -1356,7 +1357,7 @@ export default function LocationsPage() {
|
|||||||
title="Import Locations"
|
title="Import Locations"
|
||||||
open={importModalOpen}
|
open={importModalOpen}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={620}
|
width={importFormat === 'area' ? 700 : 620}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setImportModalOpen(false);
|
setImportModalOpen(false);
|
||||||
setBulkImportResult(null);
|
setBulkImportResult(null);
|
||||||
@ -1394,8 +1395,21 @@ export default function LocationsPage() {
|
|||||||
<Radio.Button value="standard">Standard CSV</Radio.Button>
|
<Radio.Button value="standard">Standard CSV</Radio.Button>
|
||||||
<Radio.Button value="nar">NAR Upload</Radio.Button>
|
<Radio.Button value="nar">NAR Upload</Radio.Button>
|
||||||
<Radio.Button value="server">NAR Server</Radio.Button>
|
<Radio.Button value="server">NAR Server</Radio.Button>
|
||||||
|
<Radio.Button value="area">Area Import</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
|
|
||||||
|
{importFormat === 'area' && (
|
||||||
|
<AreaImportWizard
|
||||||
|
cuts={cuts}
|
||||||
|
onComplete={() => {
|
||||||
|
setImportModalOpen(false);
|
||||||
|
setImportFormat('standard');
|
||||||
|
fetchLocations();
|
||||||
|
fetchStats();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{importFormat === 'standard' && (
|
{importFormat === 'standard' && (
|
||||||
<>
|
<>
|
||||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
|||||||
@ -1,40 +1,116 @@
|
|||||||
import { useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Card, Form, Input, Button, Alert, Typography } from 'antd';
|
import { Card, Form, Input, Button, Alert, Typography, Segmented, Modal, App } from 'antd';
|
||||||
import { MailOutlined, LockOutlined } from '@ant-design/icons';
|
import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { ADMIN_ROLES, type UserRole } from '@/types/api';
|
import { isAdmin } from '@/utils/roles';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
function getPostLoginPath(role?: UserRole): string {
|
type AuthMode = 'signin' | 'register';
|
||||||
if (role && ADMIN_ROLES.includes(role)) return '/app';
|
|
||||||
return '/volunteer';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
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 { 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(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && user) {
|
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 {
|
try {
|
||||||
await login(values.email, values.password);
|
await login(values.email, values.password);
|
||||||
const updatedUser = useAuthStore.getState().user;
|
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 {
|
} catch {
|
||||||
// Error is set in store
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -54,17 +130,64 @@ export default function LoginPage() {
|
|||||||
<Text type="secondary">{settings?.loginSubtitle ?? 'Admin'}</Text>
|
<Text type="secondary">{settings?.loginSubtitle ?? 'Admin'}</Text>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<Alert
|
<Alert
|
||||||
message={error}
|
message={error}
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
closable
|
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 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Form form={form} onFinish={handleSubmit} layout="vertical" size="large">
|
{mode === 'signin' ? (
|
||||||
|
<Form form={loginForm} onFinish={handleLogin} layout="vertical" size="large">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="email"
|
name="email"
|
||||||
rules={[
|
rules={[
|
||||||
@ -82,7 +205,7 @@ export default function LoginPage() {
|
|||||||
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
|
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item style={{ marginBottom: 8 }}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
@ -92,8 +215,127 @@ export default function LoginPage() {
|
|||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</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 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>
|
</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"
|
||||||
|
block
|
||||||
|
loading={forgotLoading}
|
||||||
|
onClick={handleForgotPassword}
|
||||||
|
disabled={!forgotEmail}
|
||||||
|
>
|
||||||
|
Send Reset Link
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</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>
|
</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',
|
key: 'features',
|
||||||
label: 'Feature Toggles',
|
label: 'Feature Toggles',
|
||||||
|
|||||||
@ -15,16 +15,21 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
Modal,
|
||||||
|
Badge,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
CloseOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { getUserRoles } from '@/utils/roles';
|
||||||
import type {
|
import type {
|
||||||
User,
|
User,
|
||||||
UserRole,
|
UserRole,
|
||||||
@ -50,6 +55,8 @@ const statusColors: Record<UserStatus, string> = {
|
|||||||
INACTIVE: 'default',
|
INACTIVE: 'default',
|
||||||
SUSPENDED: 'red',
|
SUSPENDED: 'red',
|
||||||
EXPIRED: 'orange',
|
EXPIRED: 'orange',
|
||||||
|
PENDING_VERIFICATION: 'purple',
|
||||||
|
PENDING_APPROVAL: 'gold',
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleOptions: { value: UserRole; label: string }[] = [
|
const roleOptions: { value: UserRole; label: string }[] = [
|
||||||
@ -65,6 +72,8 @@ const statusOptions: { value: UserStatus; label: string }[] = [
|
|||||||
{ value: 'INACTIVE', label: 'Inactive' },
|
{ value: 'INACTIVE', label: 'Inactive' },
|
||||||
{ value: 'SUSPENDED', label: 'Suspended' },
|
{ value: 'SUSPENDED', label: 'Suspended' },
|
||||||
{ value: 'EXPIRED', label: 'Expired' },
|
{ value: 'EXPIRED', label: 'Expired' },
|
||||||
|
{ value: 'PENDING_VERIFICATION', label: 'Pending Verification' },
|
||||||
|
{ value: 'PENDING_APPROVAL', label: 'Pending Approval' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
@ -81,6 +90,9 @@ export default function UsersPage() {
|
|||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
|
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||||
|
const [rejectingUser, setRejectingUser] = useState<User | null>(null);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
const getActiveDrawerWidth = () => {
|
const getActiveDrawerWidth = () => {
|
||||||
if (createDrawerOpen) return 520;
|
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) => {
|
const openEdit = (user: User) => {
|
||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
|
const roles = getUserRoles(user);
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
roles,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
expireDays: user.expireDays,
|
expireDays: user.expireDays,
|
||||||
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
|
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
|
||||||
@ -199,6 +239,9 @@ export default function UsersPage() {
|
|||||||
setEditDrawerOpen(true);
|
setEditDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Count pending approval users
|
||||||
|
const pendingCount = users.filter(u => u.status === 'PENDING_APPROVAL').length;
|
||||||
|
|
||||||
const columns: ColumnsType<User> = [
|
const columns: ColumnsType<User> = [
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
@ -212,19 +255,25 @@ export default function UsersPage() {
|
|||||||
key: 'email',
|
key: 'email',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Role',
|
title: 'Roles',
|
||||||
dataIndex: 'role',
|
key: 'roles',
|
||||||
key: 'role',
|
render: (_: unknown, record: User) => {
|
||||||
render: (role: UserRole) => (
|
const roles = getUserRoles(record);
|
||||||
<Tag color={roleColors[role]}>{role.replace('_', ' ')}</Tag>
|
return (
|
||||||
),
|
<Space size={2} wrap>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<Tag key={r} color={roleColors[r]}>{r.replace(/_/g, ' ')}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
render: (status: UserStatus) => (
|
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',
|
key: 'actions',
|
||||||
render: (_: unknown, record: User) => (
|
render: (_: unknown, record: User) => (
|
||||||
<Space>
|
<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
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@ -278,9 +349,14 @@ export default function UsersPage() {
|
|||||||
>
|
>
|
||||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||||
<Col>
|
<Col>
|
||||||
|
<Space>
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
Users
|
Users
|
||||||
</Title>
|
</Title>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<Badge count={pendingCount} style={{ backgroundColor: '#faad14' }} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Button
|
<Button
|
||||||
@ -395,9 +471,16 @@ export default function UsersPage() {
|
|||||||
<Form.Item name="phone" label="Phone">
|
<Form.Item name="phone" label="Phone">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="roles" label="Roles" initialValue={['USER']}>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={roleOptions}
|
||||||
|
placeholder="Select roles"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Row gutter={12}>
|
<Row gutter={12}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="role" label="Role" initialValue="USER">
|
<Form.Item name="role" label="Primary Role" initialValue="USER">
|
||||||
<Select options={roleOptions} />
|
<Select options={roleOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
@ -477,9 +560,16 @@ export default function UsersPage() {
|
|||||||
<Form.Item name="phone" label="Phone">
|
<Form.Item name="phone" label="Phone">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="roles" label="Roles">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={roleOptions}
|
||||||
|
placeholder="Select roles"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Row gutter={12}>
|
<Row gutter={12}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="role" label="Role">
|
<Form.Item name="role" label="Primary Role">
|
||||||
<Select options={roleOptions} />
|
<Select options={roleOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
@ -505,6 +595,27 @@ export default function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</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,
|
BarsOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
|
CloudDownloadOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
OrderedListOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { mediaApi } from '@/lib/media-api';
|
import { mediaApi } from '@/lib/media-api';
|
||||||
@ -25,6 +28,9 @@ import QuickAnalyticsModal from '@/components/media/QuickAnalyticsModal';
|
|||||||
import SchedulePublishModal from '@/components/media/SchedulePublishModal';
|
import SchedulePublishModal from '@/components/media/SchedulePublishModal';
|
||||||
import ScheduleCalendarDrawer from '@/components/media/ScheduleCalendarDrawer';
|
import ScheduleCalendarDrawer from '@/components/media/ScheduleCalendarDrawer';
|
||||||
import EditVideoModal from '@/components/media/EditVideoModal';
|
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() {
|
export default function LibraryPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
@ -48,6 +54,11 @@ export default function LibraryPage() {
|
|||||||
const [scheduleVideo, setScheduleVideo] = useState<Video | null>(null);
|
const [scheduleVideo, setScheduleVideo] = useState<Video | null>(null);
|
||||||
const [calendarModalOpen, setCalendarModalOpen] = useState(false);
|
const [calendarModalOpen, setCalendarModalOpen] = useState(false);
|
||||||
const [editVideo, setEditVideo] = useState<Video | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setPageHeader({
|
setPageHeader({
|
||||||
@ -62,7 +73,7 @@ export default function LibraryPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
}, [debouncedSearch, orientation, selectedProducers, pagination.page, pagination.limit]);
|
}, [debouncedSearch, orientation, selectedProducers, shortsFilter, pagination.page, pagination.limit]);
|
||||||
|
|
||||||
const fetchProducers = async () => {
|
const fetchProducers = async () => {
|
||||||
try {
|
try {
|
||||||
@ -80,6 +91,7 @@ export default function LibraryPage() {
|
|||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
orientation,
|
orientation,
|
||||||
producers: selectedProducers.length > 0 ? selectedProducers : undefined,
|
producers: selectedProducers.length > 0 ? selectedProducers : undefined,
|
||||||
|
isShort: shortsFilter,
|
||||||
offset: (pagination.page - 1) * pagination.limit,
|
offset: (pagination.page - 1) * pagination.limit,
|
||||||
limit: pagination.limit,
|
limit: pagination.limit,
|
||||||
};
|
};
|
||||||
@ -91,7 +103,22 @@ export default function LibraryPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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) => {
|
const handleSelect = (id: number) => {
|
||||||
setSelectedVideoIds((prev) =>
|
setSelectedVideoIds((prev) =>
|
||||||
@ -226,7 +253,20 @@ export default function LibraryPage() {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</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>
|
<Space wrap>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@ -235,6 +275,19 @@ export default function LibraryPage() {
|
|||||||
>
|
>
|
||||||
Upload Videos
|
Upload Videos
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<CloudDownloadOutlined />}
|
||||||
|
onClick={() => setFetchDrawerOpen(true)}
|
||||||
|
>
|
||||||
|
Fetch
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
onClick={handleScanShorts}
|
||||||
|
loading={scanningShorts}
|
||||||
|
>
|
||||||
|
Scan Shorts
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
icon={<CalendarOutlined />}
|
icon={<CalendarOutlined />}
|
||||||
onClick={() => setCalendarModalOpen(true)}
|
onClick={() => setCalendarModalOpen(true)}
|
||||||
@ -282,6 +335,7 @@ export default function LibraryPage() {
|
|||||||
onAnalytics={handleAnalytics}
|
onAnalytics={handleAnalytics}
|
||||||
onSchedule={handleSchedule}
|
onSchedule={handleSchedule}
|
||||||
onDelete={handleDeleteSingle}
|
onDelete={handleDeleteSingle}
|
||||||
|
onAddToPlaylist={(v) => setPlaylistVideoId(v.id)}
|
||||||
onRefresh={fetchVideos}
|
onRefresh={fetchVideos}
|
||||||
onTogglePublish={handleTogglePublish}
|
onTogglePublish={handleTogglePublish}
|
||||||
/>
|
/>
|
||||||
@ -315,6 +369,12 @@ export default function LibraryPage() {
|
|||||||
onClick: () => setPublishModalOpen(true),
|
onClick: () => setPublishModalOpen(true),
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'add-to-playlist',
|
||||||
|
label: 'Add to Playlist',
|
||||||
|
icon: <OrderedListOutlined />,
|
||||||
|
onClick: () => setBulkPlaylistOpen(true),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
@ -381,6 +441,30 @@ export default function LibraryPage() {
|
|||||||
onClose={() => setEditVideo(null)}
|
onClose={() => setEditVideo(null)}
|
||||||
onSuccess={handleEditSuccess}
|
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>
|
</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 { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Input,
|
Input,
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Result,
|
Result,
|
||||||
Grid,
|
Grid,
|
||||||
|
Tooltip,
|
||||||
theme,
|
theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
@ -23,8 +24,11 @@ import {
|
|||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import type {
|
import type {
|
||||||
Campaign,
|
Campaign,
|
||||||
Representative,
|
Representative,
|
||||||
@ -41,9 +45,11 @@ type Step = 'info' | 'reps' | 'send';
|
|||||||
|
|
||||||
export default function CampaignPage() {
|
export default function CampaignPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -52,8 +58,8 @@ export default function CampaignPage() {
|
|||||||
// Step tracking
|
// Step tracking
|
||||||
const [currentStep, setCurrentStep] = useState<Step>('info');
|
const [currentStep, setCurrentStep] = useState<Step>('info');
|
||||||
|
|
||||||
// Step 1: User info
|
// Step 1: User info (pre-fill from query param)
|
||||||
const [postalCode, setPostalCode] = useState('');
|
const [postalCode, setPostalCode] = useState(searchParams.get('postalCode') || '');
|
||||||
const [userName, setUserName] = useState('');
|
const [userName, setUserName] = useState('');
|
||||||
const [userEmail, setUserEmail] = useState('');
|
const [userEmail, setUserEmail] = useState('');
|
||||||
|
|
||||||
@ -449,6 +455,7 @@ export default function CampaignPage() {
|
|||||||
{rep.email && !isSent && (
|
{rep.email && !isSent && (
|
||||||
<>
|
<>
|
||||||
{campaign.allowSmtpEmail && (
|
{campaign.allowSmtpEmail && (
|
||||||
|
<Tooltip title="Send the campaign email directly through our system">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@ -457,17 +464,20 @@ export default function CampaignPage() {
|
|||||||
onClick={() => handleSendSmtp(rep)}
|
onClick={() => handleSendSmtp(rep)}
|
||||||
style={{ background: '#005a9c' }}
|
style={{ background: '#005a9c' }}
|
||||||
>
|
>
|
||||||
Send
|
Send via System
|
||||||
</Button>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{campaign.allowMailtoLink && (
|
{campaign.allowMailtoLink && (
|
||||||
|
<Tooltip title="Opens your default email app with the message pre-filled">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<MailOutlined />}
|
icon={<MailOutlined />}
|
||||||
onClick={() => handleMailto(rep)}
|
onClick={() => handleMailto(rep)}
|
||||||
>
|
>
|
||||||
Email App
|
Open Your Email
|
||||||
</Button>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -556,6 +566,48 @@ export default function CampaignPage() {
|
|||||||
</Card>
|
</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 */}
|
{/* Response Wall CTA */}
|
||||||
{campaign.showResponseWall && (
|
{campaign.showResponseWall && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Card,
|
Card,
|
||||||
@ -15,8 +15,10 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Space,
|
Space,
|
||||||
Divider,
|
Divider,
|
||||||
|
Drawer,
|
||||||
Grid,
|
Grid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Popover,
|
||||||
message,
|
message,
|
||||||
theme,
|
theme,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@ -30,9 +32,15 @@ import {
|
|||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
BankOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
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 type { Campaign, GovernmentLevel, Representative, RepresentativeLookupResponse } from '@/types/api';
|
||||||
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
|
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
|
||||||
import { groupRepsByLevel, GOVERNMENT_LEVEL_DISPLAY_COLORS } from '@/utils/representatives';
|
import { groupRepsByLevel, GOVERNMENT_LEVEL_DISPLAY_COLORS } from '@/utils/representatives';
|
||||||
@ -47,6 +55,14 @@ export default function CampaignsListPage() {
|
|||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { settings: siteSettings } = useSettingsStore();
|
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
|
// Rep lookup state
|
||||||
const [postalCode, setPostalCode] = useState('');
|
const [postalCode, setPostalCode] = useState('');
|
||||||
@ -54,6 +70,9 @@ export default function CampaignsListPage() {
|
|||||||
const [representatives, setRepresentatives] = useState<Representative[] | null>(null);
|
const [representatives, setRepresentatives] = useState<Representative[] | null>(null);
|
||||||
const [lookupLocation, setLookupLocation] = useState<{ city: string | null; province: string | null } | null>(null);
|
const [lookupLocation, setLookupLocation] = useState<{ city: string | null; province: string | null } | null>(null);
|
||||||
|
|
||||||
|
// Campaign selector drawer (mobile)
|
||||||
|
const [campaignDrawerOpen, setCampaignDrawerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCampaigns();
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
@ -123,6 +155,69 @@ export default function CampaignsListPage() {
|
|||||||
? [lookupLocation.city, lookupLocation.province].filter(Boolean).join(', ')
|
? [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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Hero Banner */}
|
{/* Hero Banner */}
|
||||||
@ -131,7 +226,7 @@ export default function CampaignsListPage() {
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: isMobile ? '36px 16px 32px' : '48px 24px 40px',
|
padding: isMobile ? '36px 16px 32px' : '48px 24px 40px',
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
background: siteSettings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 50%, #0099ff 100%)',
|
background: headerGradient,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
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)' }}>
|
<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'}
|
{siteSettings?.organizationName ?? 'Changemaker Lite'}
|
||||||
</Title>
|
</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
|
Connect with your elected representatives across all levels of government
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
@ -152,24 +247,24 @@ export default function CampaignsListPage() {
|
|||||||
</Title>
|
</Title>
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(27, 40, 56, 0.8)',
|
background: colorBgContainer,
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
margin: '0 auto',
|
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:
|
Enter your postal code:
|
||||||
</Text>
|
</Text>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12, flexDirection: !screens.sm ? 'column' : 'row' }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. T5K 2M5"
|
placeholder="e.g. T5K 2M5"
|
||||||
value={postalCode}
|
value={postalCode}
|
||||||
onChange={(e) => setPostalCode(e.target.value)}
|
onChange={(e) => setPostalCode(e.target.value)}
|
||||||
onPressEnter={handleLookup}
|
onPressEnter={handleLookup}
|
||||||
size="large"
|
size="large"
|
||||||
style={{ textTransform: 'uppercase', flex: 1 }}
|
style={{ textTransform: 'uppercase', flex: screens.sm ? 1 : undefined }}
|
||||||
disabled={lookupLoading}
|
disabled={lookupLoading}
|
||||||
prefix={lookupLoading ? <Spin size="small" /> : undefined}
|
prefix={lookupLoading ? <Spin size="small" /> : undefined}
|
||||||
/>
|
/>
|
||||||
@ -179,7 +274,7 @@ export default function CampaignsListPage() {
|
|||||||
icon={<SearchOutlined />}
|
icon={<SearchOutlined />}
|
||||||
onClick={handleLookup}
|
onClick={handleLookup}
|
||||||
loading={lookupLoading}
|
loading={lookupLoading}
|
||||||
style={{ background: '#005a9c' }}
|
block={!screens.sm}
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
@ -190,9 +285,21 @@ export default function CampaignsListPage() {
|
|||||||
{repsByLevel && (
|
{repsByLevel && (
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
{locationText && (
|
{locationText && (
|
||||||
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', textAlign: 'center', marginBottom: 16 }}>
|
<Card
|
||||||
Showing representatives for {locationText}
|
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>
|
</Text>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
{representatives!.length === 0 ? (
|
{representatives!.length === 0 ? (
|
||||||
<Result
|
<Result
|
||||||
@ -202,96 +309,22 @@ export default function CampaignsListPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(repsByLevel).map(([level, reps]) => (
|
Object.entries(repsByLevel).map(([level, reps]) => (
|
||||||
<div key={level} style={{ marginBottom: 24 }}>
|
<div key={level} style={{ marginBottom: 28 }}>
|
||||||
<Title level={4} style={{ color: GOVERNMENT_LEVEL_DISPLAY_COLORS[level] || '#94a3b8', marginBottom: 12 }}>
|
<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
|
{GOVERNMENT_LEVEL_LABELS[level as GovernmentLevel] || level} Representatives
|
||||||
</Title>
|
</Title>
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
{reps.map((rep, idx) => (
|
{reps.map((rep, idx) => (
|
||||||
<Card
|
<RepCard
|
||||||
key={rep.id || idx}
|
key={rep.id || idx}
|
||||||
style={{
|
rep={rep}
|
||||||
background: '#1b2838',
|
isMobile={isMobile}
|
||||||
border: '1px solid rgba(255,255,255,0.08)',
|
token={token}
|
||||||
borderRadius: 10,
|
colorBgContainer={colorBgContainer}
|
||||||
}}
|
campaigns={campaigns}
|
||||||
>
|
onOpenDrawer={() => setCampaignDrawerOpen(true)}
|
||||||
<div style={{ display: 'flex', gap: 16, flexDirection: isMobile ? 'column' : 'row' }}>
|
campaignSelectorContent={campaignSelectorContent}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@ -301,6 +334,20 @@ export default function CampaignsListPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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)' }} />
|
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||||
|
|
||||||
{/* Featured Campaign */}
|
{/* Featured Campaign */}
|
||||||
@ -310,7 +357,7 @@ export default function CampaignsListPage() {
|
|||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
style={{
|
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)',
|
border: '1px solid rgba(255,215,0,0.3)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -322,7 +369,7 @@ export default function CampaignsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Title level={3} style={{ color: '#fff', margin: '4px 0 8px' }}>{campaign.title}</Title>
|
<Title level={3} style={{ color: '#fff', margin: '4px 0 8px' }}>{campaign.title}</Title>
|
||||||
{campaign.description && (
|
{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}
|
{campaign.description}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
)}
|
)}
|
||||||
@ -347,7 +394,7 @@ export default function CampaignsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
<ShareButtons campaign={campaign} />
|
<ShareButtons campaign={campaign} isMobile={isMobile} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -355,7 +402,7 @@ export default function CampaignsListPage() {
|
|||||||
<Title level={3} style={{ color: 'rgba(255,255,255,0.85)', textAlign: 'center', marginBottom: 8 }}>
|
<Title level={3} style={{ color: 'rgba(255,255,255,0.85)', textAlign: 'center', marginBottom: 8 }}>
|
||||||
Active Campaigns
|
Active Campaigns
|
||||||
</Title>
|
</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
|
Join ongoing campaigns to make your voice heard on important issues
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
@ -377,7 +424,7 @@ export default function CampaignsListPage() {
|
|||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
background: '#1b2838',
|
background: colorBgContainer,
|
||||||
border: '1px solid rgba(255,255,255,0.08)',
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -407,7 +454,7 @@ export default function CampaignsListPage() {
|
|||||||
style={{
|
style={{
|
||||||
height: 140,
|
height: 140,
|
||||||
margin: '-20px -20px 16px -20px',
|
margin: '-20px -20px 16px -20px',
|
||||||
background: 'linear-gradient(135deg, #1a3a5c 0%, #1b2838 100%)',
|
background: `linear-gradient(135deg, ${token.colorPrimaryBg} 0%, ${colorBgContainer} 100%)`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@ -420,7 +467,7 @@ export default function CampaignsListPage() {
|
|||||||
)}
|
)}
|
||||||
{campaign.description && (
|
{campaign.description && (
|
||||||
<Paragraph
|
<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 }}
|
ellipsis={{ rows: 2 }}
|
||||||
>
|
>
|
||||||
{campaign.description}
|
{campaign.description}
|
||||||
@ -435,13 +482,13 @@ export default function CampaignsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{campaign.showEmailCount && (
|
{campaign.showEmailCount && (
|
||||||
<span style={{ color: token.colorPrimary, fontSize: 13 }}>
|
<span style={{ color: token.colorPrimary, fontSize: isMobile ? 14 : 13 }}>
|
||||||
<MailOutlined style={{ marginRight: 4 }} />
|
<MailOutlined style={{ marginRight: 4 }} />
|
||||||
{campaign._count.emails} emails sent
|
{campaign._count.emails} emails sent
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{campaign.showResponseWall && (
|
{campaign.showResponseWall && (
|
||||||
<span style={{ color: token.colorSuccess, fontSize: 13 }}>
|
<span style={{ color: token.colorSuccess, fontSize: isMobile ? 14 : 13 }}>
|
||||||
<MessageOutlined style={{ marginRight: 4 }} />
|
<MessageOutlined style={{ marginRight: 4 }} />
|
||||||
{campaign._count.responses} responses
|
{campaign._count.responses} responses
|
||||||
</span>
|
</span>
|
||||||
@ -449,7 +496,7 @@ export default function CampaignsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', padding: '8px 20px' }}>
|
<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 }}>
|
<Link to={`/campaign/${campaign.slug}`} style={{ display: 'block', marginTop: 4 }}>
|
||||||
<Button type="link" style={{ padding: 0, color: token.colorPrimary, fontSize: 13 }}>
|
<Button type="link" style={{ padding: 0, color: token.colorPrimary, fontSize: 13 }}>
|
||||||
Learn More & Participate <ArrowRightOutlined />
|
Learn More & Participate <ArrowRightOutlined />
|
||||||
@ -462,16 +509,263 @@ export default function CampaignsListPage() {
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</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>
|
</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 ---
|
// --- 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 url = `${window.location.origin}/campaign/${campaign.slug}`;
|
||||||
const text = `${campaign.title} - Take action now!`;
|
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 = [
|
const shareLinks = [
|
||||||
{
|
{
|
||||||
label: 'Share on X',
|
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 {
|
import {
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
Empty,
|
||||||
Pagination,
|
Pagination,
|
||||||
theme,
|
|
||||||
Grid,
|
Grid,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
|
||||||
import PublicVideoCard from '@/components/media/PublicVideoCard';
|
import PublicVideoCard from '@/components/media/PublicVideoCard';
|
||||||
import ExpandedVideoCard from '@/components/media/ExpandedVideoCard';
|
import ExpandedVideoCard from '@/components/media/ExpandedVideoCard';
|
||||||
|
import FeaturedPlaylistCarousel from '@/components/media/FeaturedPlaylistCarousel';
|
||||||
import { mediaPublicApi } from '@/lib/media-public-api';
|
import { mediaPublicApi } from '@/lib/media-public-api';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { ExpandedVideoProvider, useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
import { ExpandedVideoProvider, useExpandedVideo } from '@/contexts/ExpandedVideoContext';
|
||||||
|
|
||||||
@ -35,7 +29,7 @@ interface Video {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Pagination {
|
interface PaginationInfo {
|
||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
@ -43,30 +37,26 @@ interface Pagination {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MediaGalleryContent() {
|
function MediaGalleryContent() {
|
||||||
const { token } = theme.useToken();
|
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { category: urlCategory } = useParams<{ category?: string }>();
|
const { category: urlCategory } = useParams<{ category?: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { state: expandedState, expandVideo } = useExpandedVideo();
|
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 [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pagination, setPagination] = useState<Pagination>({
|
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||||
total: 0,
|
total: 0,
|
||||||
limit: 24,
|
limit: 24,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filters
|
|
||||||
const [searchInput, setSearchInput] = useState('');
|
|
||||||
const [sort, setSort] = useState<'recent' | 'popular' | 'most_viewed'>('recent');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
// Debounce search input
|
|
||||||
const debouncedSearch = useDebounce(searchInput, 300);
|
|
||||||
|
|
||||||
// Fetch videos
|
// Fetch videos
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
try {
|
try {
|
||||||
@ -83,8 +73,8 @@ function MediaGalleryContent() {
|
|||||||
params.category = urlCategory;
|
params.category = urlCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debouncedSearch) {
|
if (search) {
|
||||||
params.search = debouncedSearch;
|
params.search = search;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await mediaPublicApi.get('/public', { params });
|
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(() => {
|
useEffect(() => {
|
||||||
fetchVideos();
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (hasRestoredRef.current) return;
|
||||||
const expandedId = searchParams.get('expanded');
|
const expandedId = searchParams.get('expanded');
|
||||||
if (expandedId && videos.length > 0 && !expandedState.videoId) {
|
if (expandedId && videos.length > 0) {
|
||||||
const videoId = parseInt(expandedId, 10);
|
const videoId = parseInt(expandedId, 10);
|
||||||
const video = videos.find(v => v.id === videoId);
|
const video = videos.find(v => v.id === videoId);
|
||||||
if (video) {
|
if (video) {
|
||||||
|
hasRestoredRef.current = true;
|
||||||
expandVideo(videoId, video);
|
expandVideo(videoId, video);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, videos, expandVideo, expandedState.videoId]);
|
}, [videos]); // Only re-check when videos load
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
@ -132,32 +120,8 @@ function MediaGalleryContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Filters Bar */}
|
{/* Featured Playlists Carousel — only on main gallery page */}
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
{!urlCategory && !search && <FeaturedPlaylistCarousel />}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
{loading && (
|
{loading && (
|
||||||
@ -170,8 +134,8 @@ function MediaGalleryContent() {
|
|||||||
{!loading && videos.length === 0 && (
|
{!loading && videos.length === 0 && (
|
||||||
<Empty
|
<Empty
|
||||||
description={
|
description={
|
||||||
debouncedSearch
|
search
|
||||||
? `No videos found for "${debouncedSearch}"`
|
? `No videos found for "${search}"`
|
||||||
: 'No videos available'
|
: 'No videos available'
|
||||||
}
|
}
|
||||||
style={{ padding: 60 }}
|
style={{ padding: 60 }}
|
||||||
|
|||||||
@ -17,12 +17,15 @@ import {
|
|||||||
LikeFilled,
|
LikeFilled,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
|
PlusOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import VideoPlayer from '@/components/media/VideoPlayer';
|
import VideoPlayer from '@/components/media/VideoPlayer';
|
||||||
import ReactionButtons from '@/components/media/ReactionButtons';
|
import ReactionButtons from '@/components/media/ReactionButtons';
|
||||||
import CommentSection from '@/components/media/CommentSection';
|
import CommentSection from '@/components/media/CommentSection';
|
||||||
import PublicVideoCard from '@/components/media/PublicVideoCard';
|
import PublicVideoCard from '@/components/media/PublicVideoCard';
|
||||||
|
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
|
||||||
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
|
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
|
||||||
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@ -52,7 +55,9 @@ export default function MediaViewerPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [upvoted, setUpvoted] = useState(false);
|
const [upvoted, setUpvoted] = useState(false);
|
||||||
const [upvoting, setUpvoting] = useState(false);
|
const [upvoting, setUpvoting] = useState(false);
|
||||||
|
const [addToPlaylistOpen, setAddToPlaylistOpen] = useState(false);
|
||||||
const currentTime = 0; // TODO: Track video playback time
|
const currentTime = 0; // TODO: Track video playback time
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
const videoId = parseInt(id || '0', 10);
|
const videoId = parseInt(id || '0', 10);
|
||||||
|
|
||||||
@ -66,8 +71,7 @@ export default function MediaViewerPage() {
|
|||||||
|
|
||||||
// Check if locked and user not authenticated
|
// Check if locked and user not authenticated
|
||||||
if (response.data.video.isLocked) {
|
if (response.data.video.isLocked) {
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
if (!useAuthStore.getState().isAuthenticated) {
|
||||||
if (!accessToken) {
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Login Required',
|
title: 'Login Required',
|
||||||
content: 'This video is locked. Please log in to watch.',
|
content: 'This video is locked. Please log in to watch.',
|
||||||
@ -255,6 +259,16 @@ export default function MediaViewerPage() {
|
|||||||
{formatCount(video.upvoteCount)}
|
{formatCount(video.upvoteCount)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
size="large"
|
||||||
|
onClick={() => setAddToPlaylistOpen(true)}
|
||||||
|
>
|
||||||
|
Playlist
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tag color="purple" style={{ fontSize: 14, padding: '4px 12px' }}>
|
<Tag color="purple" style={{ fontSize: 14, padding: '4px 12px' }}>
|
||||||
{video.category}
|
{video.category}
|
||||||
</Tag>
|
</Tag>
|
||||||
@ -299,6 +313,13 @@ export default function MediaViewerPage() {
|
|||||||
<div>
|
<div>
|
||||||
<CommentSection videoId={videoId} />
|
<CommentSection videoId={videoId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add to Playlist Modal */}
|
||||||
|
<AddToPlaylistModal
|
||||||
|
videoId={videoId}
|
||||||
|
open={addToPlaylistOpen}
|
||||||
|
onClose={() => setAddToPlaylistOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</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;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
errorCode: string | null;
|
||||||
|
registrationMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthActions {
|
interface AuthActions {
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (name: string, email: string, password: string) => Promise<{ requiresVerification?: boolean }>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
fetchMe: () => Promise<void>;
|
fetchMe: () => Promise<void>;
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState & AuthActions>()(
|
export const useAuthStore = create<AuthState & AuthActions>()(
|
||||||
@ -31,9 +35,11 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
errorCode: null,
|
||||||
|
registrationMessage: null,
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ error: null, isLoading: true });
|
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post<AuthResponse>('/auth/login', {
|
const { data } = await api.post<AuthResponse>('/auth/login', {
|
||||||
email,
|
email,
|
||||||
@ -41,16 +47,51 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
});
|
});
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
accessToken: data.accessToken,
|
accessToken: data.accessToken || null,
|
||||||
refreshToken: data.refreshToken,
|
refreshToken: data.refreshToken || null,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const resp = (err as { response?: { data?: { error?: { message?: string; code?: string } } } })?.response?.data?.error;
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
const message = resp?.message || 'Login failed';
|
||||||
?.response?.data?.error?.message || 'Login failed';
|
const code = resp?.code || null;
|
||||||
set({ error: message, isLoading: false });
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -79,8 +120,8 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
});
|
});
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
accessToken: data.accessToken,
|
accessToken: data.accessToken || null,
|
||||||
refreshToken: data.refreshToken,
|
refreshToken: data.refreshToken || null,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@ -127,8 +168,14 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
errorCode: null,
|
||||||
|
registrationMessage: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null, errorCode: null, registrationMessage: null });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'cml-auth',
|
name: 'cml-auth',
|
||||||
|
|||||||
@ -15,9 +15,9 @@ export interface AppOutletContext {
|
|||||||
|
|
||||||
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'USER' | 'TEMP';
|
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 {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -25,6 +25,7 @@ export interface User {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
roles: UserRole[];
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
permissions: Record<string, unknown> | null;
|
permissions: Record<string, unknown> | null;
|
||||||
createdVia: CreatedVia;
|
createdVia: CreatedVia;
|
||||||
@ -38,8 +39,10 @@ export interface User {
|
|||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
user: User;
|
user: User;
|
||||||
accessToken: string;
|
accessToken?: string;
|
||||||
refreshToken: string;
|
refreshToken?: string;
|
||||||
|
requiresVerification?: boolean;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginationMeta {
|
export interface PaginationMeta {
|
||||||
@ -68,6 +71,7 @@ export interface CreateUserPayload {
|
|||||||
name?: string;
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
|
roles?: UserRole[];
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
expireDays?: number;
|
expireDays?: number;
|
||||||
@ -79,6 +83,7 @@ export interface UpdateUserPayload {
|
|||||||
name?: string;
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
|
roles?: UserRole[];
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
expireDays?: number;
|
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 CampaignStatus = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'ARCHIVED';
|
||||||
|
|
||||||
|
export type CampaignModerationStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED';
|
||||||
|
|
||||||
export type GovernmentLevel = 'FEDERAL' | 'PROVINCIAL' | 'MUNICIPAL' | 'SCHOOL_BOARD';
|
export type GovernmentLevel = 'FEDERAL' | 'PROVINCIAL' | 'MUNICIPAL' | 'SCHOOL_BOARD';
|
||||||
|
|
||||||
export interface Campaign {
|
export interface Campaign {
|
||||||
@ -123,6 +130,12 @@ export interface Campaign {
|
|||||||
createdByUserId: string | null;
|
createdByUserId: string | null;
|
||||||
createdByUserEmail: string | null;
|
createdByUserEmail: string | null;
|
||||||
createdByUserName: string | null;
|
createdByUserName: string | null;
|
||||||
|
isUserGenerated: boolean;
|
||||||
|
moderationStatus: CampaignModerationStatus | null;
|
||||||
|
reviewedByUserId: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
rejectionReason: string | null;
|
||||||
|
moderationNotes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
_count: {
|
_count: {
|
||||||
@ -183,6 +196,45 @@ export interface CampaignsListParams {
|
|||||||
status?: CampaignStatus;
|
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 ---
|
// --- Representatives ---
|
||||||
|
|
||||||
export interface RepresentativeOffice {
|
export interface RepresentativeOffice {
|
||||||
@ -1010,6 +1062,9 @@ export interface SiteSettings {
|
|||||||
smtpActiveProvider?: 'mailhog' | 'production';
|
smtpActiveProvider?: 'mailhog' | 'production';
|
||||||
emailTestMode?: boolean;
|
emailTestMode?: boolean;
|
||||||
testEmailRecipient?: string;
|
testEmailRecipient?: string;
|
||||||
|
enablePublicRegistration: boolean;
|
||||||
|
enableEmailVerification: boolean;
|
||||||
|
autoApproveVerifiedUsers: boolean;
|
||||||
enableInfluence: boolean;
|
enableInfluence: boolean;
|
||||||
enableMap: boolean;
|
enableMap: boolean;
|
||||||
enableNewsletter: boolean;
|
enableNewsletter: boolean;
|
||||||
@ -1224,6 +1279,45 @@ export interface NarImportProgress {
|
|||||||
result?: NarServerImportResult;
|
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 ---
|
// --- SMTP Test Results ---
|
||||||
|
|
||||||
export interface SmtpTestResult {
|
export interface SmtpTestResult {
|
||||||
@ -1425,3 +1519,162 @@ export const EMAIL_TEMPLATE_CATEGORY_COLORS: Record<EmailTemplateCategory, strin
|
|||||||
MAP: 'green',
|
MAP: 'green',
|
||||||
SYSTEM: 'purple',
|
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;
|
scheduledUnpublishAt?: string | null;
|
||||||
isPublished?: boolean;
|
isPublished?: boolean;
|
||||||
publishedAt?: string | null;
|
publishedAt?: string | null;
|
||||||
|
isShort?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video Analytics interfaces
|
// Video Analytics interfaces
|
||||||
@ -91,6 +92,7 @@ export interface VideosListParams {
|
|||||||
subdirectories?: string;
|
subdirectories?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
isShort?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public media interfaces
|
// Public media interfaces
|
||||||
@ -145,3 +147,74 @@ export interface DirectoryType {
|
|||||||
count: number;
|
count: number;
|
||||||
subdirectories?: string[];
|
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
|
FROM node:20-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install ffmpeg for video metadata extraction
|
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@ -35,8 +35,8 @@ RUN npm run build
|
|||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install ffmpeg for video metadata extraction
|
# Install ffmpeg for video metadata extraction and yt-dlp for video fetching
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp
|
||||||
|
|
||||||
# Copy built files and node_modules
|
# Copy built files and node_modules
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|||||||
@ -24,12 +24,15 @@ enum UserStatus {
|
|||||||
INACTIVE
|
INACTIVE
|
||||||
SUSPENDED
|
SUSPENDED
|
||||||
EXPIRED
|
EXPIRED
|
||||||
|
PENDING_VERIFICATION
|
||||||
|
PENDING_APPROVAL
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserCreatedVia {
|
enum UserCreatedVia {
|
||||||
ADMIN
|
ADMIN
|
||||||
PUBLIC_SHIFT_SIGNUP
|
PUBLIC_SHIFT_SIGNUP
|
||||||
STANDARD
|
STANDARD
|
||||||
|
SELF_REGISTRATION
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@ -39,6 +42,7 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
phone String?
|
phone String?
|
||||||
role UserRole @default(USER)
|
role UserRole @default(USER)
|
||||||
|
roles Json @default("[]") // Array of UserRole strings for multi-role support
|
||||||
status UserStatus @default(ACTIVE)
|
status UserStatus @default(ACTIVE)
|
||||||
permissions Json? // Per-app granular permissions
|
permissions Json? // Per-app granular permissions
|
||||||
createdVia UserCreatedVia @default(STANDARD)
|
createdVia UserCreatedVia @default(STANDARD)
|
||||||
@ -51,6 +55,7 @@ model User {
|
|||||||
|
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
campaignsCreated Campaign[] @relation("CampaignCreator")
|
campaignsCreated Campaign[] @relation("CampaignCreator")
|
||||||
|
campaignsReviewed Campaign[] @relation("CampaignReviewer")
|
||||||
campaignEmails CampaignEmail[] @relation("CampaignEmailSender")
|
campaignEmails CampaignEmail[] @relation("CampaignEmailSender")
|
||||||
responses RepresentativeResponse[] @relation("ResponseSubmitter")
|
responses RepresentativeResponse[] @relation("ResponseSubmitter")
|
||||||
responseUpvotes ResponseUpvote[]
|
responseUpvotes ResponseUpvote[]
|
||||||
@ -154,6 +159,13 @@ enum CampaignStatus {
|
|||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CampaignModerationStatus {
|
||||||
|
PENDING_REVIEW
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
CHANGES_REQUESTED
|
||||||
|
}
|
||||||
|
|
||||||
enum GovernmentLevel {
|
enum GovernmentLevel {
|
||||||
FEDERAL
|
FEDERAL
|
||||||
PROVINCIAL
|
PROVINCIAL
|
||||||
@ -192,6 +204,15 @@ model Campaign {
|
|||||||
createdByUserEmail String?
|
createdByUserEmail String?
|
||||||
createdByUserName 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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -200,6 +221,8 @@ model Campaign {
|
|||||||
customRecipients CustomRecipient[]
|
customRecipients CustomRecipient[]
|
||||||
calls Call[]
|
calls Call[]
|
||||||
|
|
||||||
|
@@index([moderationStatus])
|
||||||
|
@@index([isUserGenerated])
|
||||||
@@map("campaigns")
|
@@map("campaigns")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -803,6 +826,11 @@ model SiteSettings {
|
|||||||
emailTestMode Boolean @default(true)
|
emailTestMode Boolean @default(true)
|
||||||
testEmailRecipient String @default("")
|
testEmailRecipient String @default("")
|
||||||
|
|
||||||
|
// Registration settings
|
||||||
|
enablePublicRegistration Boolean @default(true)
|
||||||
|
enableEmailVerification Boolean @default(true)
|
||||||
|
autoApproveVerifiedUsers Boolean @default(true)
|
||||||
|
|
||||||
// Feature toggles
|
// Feature toggles
|
||||||
enableInfluence Boolean @default(true)
|
enableInfluence Boolean @default(true)
|
||||||
enableMap Boolean @default(true)
|
enableMap Boolean @default(true)
|
||||||
@ -1350,6 +1378,7 @@ model Video {
|
|||||||
isPublished Boolean @default(false) @map("is_published")
|
isPublished Boolean @default(false) @map("is_published")
|
||||||
publishedAt DateTime? @map("published_at")
|
publishedAt DateTime? @map("published_at")
|
||||||
category String? // videos|curated|compilations|playback|highlights
|
category String? // videos|curated|compilations|playback|highlights
|
||||||
|
isShort Boolean @default(false) @map("is_short")
|
||||||
|
|
||||||
// Moderation system
|
// Moderation system
|
||||||
isLocked Boolean @default(false) @map("is_locked")
|
isLocked Boolean @default(false) @map("is_locked")
|
||||||
@ -1417,6 +1446,7 @@ model Video {
|
|||||||
@@index([directoryType, isValid, orientation], map: "idx_videos_directory_valid_orientation")
|
@@index([directoryType, isValid, orientation], map: "idx_videos_directory_valid_orientation")
|
||||||
@@index([isPublished, isLocked], map: "idx_videos_published_locked")
|
@@index([isPublished, isLocked], map: "idx_videos_published_locked")
|
||||||
@@index([category, isPublished], map: "idx_videos_category_published")
|
@@index([category, isPublished], map: "idx_videos_category_published")
|
||||||
|
@@index([isShort, isPublished, isLocked], map: "idx_videos_short_published")
|
||||||
@@index([uploaderId], map: "idx_videos_uploader")
|
@@index([uploaderId], map: "idx_videos_uploader")
|
||||||
@@map("videos")
|
@@map("videos")
|
||||||
}
|
}
|
||||||
@ -1938,7 +1968,7 @@ model PlaylistVideo {
|
|||||||
addedAt DateTime @default(now()) @map("added_at")
|
addedAt DateTime @default(now()) @map("added_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
playlist Playlist @relation(fields: [playlistId], references: [id])
|
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
||||||
media Video @relation(fields: [mediaId], references: [id])
|
media Video @relation(fields: [mediaId], references: [id])
|
||||||
|
|
||||||
@@index([playlistId], map: "idx_playlist_videos_playlist")
|
@@index([playlistId], map: "idx_playlist_videos_playlist")
|
||||||
@ -1955,7 +1985,7 @@ model FeaturedPlaylist {
|
|||||||
featuredAt DateTime? @map("featured_at")
|
featuredAt DateTime? @map("featured_at")
|
||||||
|
|
||||||
// Relations
|
// 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])
|
featurer User? @relation("FeaturedPlaylistFeaturer", fields: [featuredBy], references: [id])
|
||||||
|
|
||||||
@@index([position], map: "idx_featured_playlists_position")
|
@@index([position], map: "idx_featured_playlists_position")
|
||||||
@ -1969,7 +1999,7 @@ model PlaylistView {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
playlist Playlist @relation(fields: [playlistId], references: [id])
|
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
||||||
session Session @relation(fields: [sessionId], references: [id])
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
|
||||||
@@index([playlistId], map: "idx_playlist_views_playlist")
|
@@index([playlistId], map: "idx_playlist_views_playlist")
|
||||||
|
|||||||
@ -41,6 +41,7 @@ async function main() {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
name: 'Admin',
|
name: 'Admin',
|
||||||
role: UserRole.SUPER_ADMIN,
|
role: UserRole.SUPER_ADMIN,
|
||||||
|
roles: JSON.parse(JSON.stringify([UserRole.SUPER_ADMIN])),
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -114,6 +114,11 @@ const envSchema = z.object({
|
|||||||
// NAR (National Address Register)
|
// NAR (National Address Register)
|
||||||
NAR_DATA_DIR: z.string().default('/data'),
|
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
|
// Media Management
|
||||||
ENABLE_MEDIA_FEATURES: z.string().default('false'),
|
ENABLE_MEDIA_FEATURES: z.string().default('false'),
|
||||||
MEDIA_API_PORT: z.coerce.number().default(4100),
|
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 { reactionsRoutes } from './modules/media/routes/reactions.routes';
|
||||||
import { publicRoutes } from './modules/media/routes/public.routes';
|
import { publicRoutes } from './modules/media/routes/public.routes';
|
||||||
import { chatStreamRoutes } from './modules/media/routes/chat-stream.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 { uploadRoutes } from './modules/media/routes/upload.routes';
|
||||||
import { videoActionsRoutes } from './modules/media/routes/video-actions.routes';
|
import { videoActionsRoutes } from './modules/media/routes/video-actions.routes';
|
||||||
import { videoScheduleRoutes } from './modules/media/routes/video-schedule.routes';
|
import { videoScheduleRoutes } from './modules/media/routes/video-schedule.routes';
|
||||||
import { videoTrackingRoutes } from './modules/media/routes/video-tracking.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 { 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
|
// Add BigInt serialization support for Prisma BigInt fields
|
||||||
// This converts BigInt values to strings when JSON.stringify() is called
|
// This converts BigInt values to strings when JSON.stringify() is called
|
||||||
@ -32,6 +44,7 @@ const fastify = Fastify({
|
|||||||
process.on('SIGTERM', async () => {
|
process.on('SIGTERM', async () => {
|
||||||
logger.info('SIGTERM received, shutting down gracefully...');
|
logger.info('SIGTERM received, shutting down gracefully...');
|
||||||
await videoScheduleQueueService.close();
|
await videoScheduleQueueService.close();
|
||||||
|
await videoFetchQueueService.close();
|
||||||
fastify.close(() => {
|
fastify.close(() => {
|
||||||
logger.info('Media API server closed');
|
logger.info('Media API server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@ -99,9 +112,18 @@ const start = async () => {
|
|||||||
await fastify.register(videoTrackingRoutes, { prefix: '/api/track' });
|
await fastify.register(videoTrackingRoutes, { prefix: '/api/track' });
|
||||||
await fastify.register(reactionsRoutes, { prefix: '/api/reactions' });
|
await fastify.register(reactionsRoutes, { prefix: '/api/reactions' });
|
||||||
await fastify.register(publicRoutes, { prefix: '/api' });
|
await fastify.register(publicRoutes, { prefix: '/api' });
|
||||||
await fastify.register(chatStreamRoutes, { prefix: '/api/media' });
|
await fastify.register(commentsRoutes, { prefix: '/api' });
|
||||||
// TODO: Add more routes
|
await fastify.register(chatStreamRoutes, { prefix: '/api' });
|
||||||
// await fastify.register(jobsRoutes, { prefix: '/api/jobs' });
|
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 port = env.MEDIA_API_PORT;
|
||||||
const host = '0.0.0.0';
|
const host = '0.0.0.0';
|
||||||
@ -113,6 +135,10 @@ const start = async () => {
|
|||||||
videoScheduleQueueService.startWorker();
|
videoScheduleQueueService.startWorker();
|
||||||
logger.info('Video schedule queue worker initialized');
|
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') {
|
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
|
||||||
logger.warn('Media features are disabled (ENABLE_MEDIA_FEATURES=false)');
|
logger.warn('Media features are disabled (ENABLE_MEDIA_FEATURES=false)');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ interface TokenPayload {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
roles?: UserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authenticate(req: Request, _res: Response, next: NextFunction) {
|
export function authenticate(req: Request, _res: Response, next: NextFunction) {
|
||||||
@ -20,7 +21,12 @@ export function authenticate(req: Request, _res: Response, next: NextFunction) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
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();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
throw new AppError(401, 'Invalid or expired token', 'INVALID_TOKEN');
|
throw new AppError(401, 'Invalid or expired token', 'INVALID_TOKEN');
|
||||||
@ -38,7 +44,12 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
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 {
|
} catch {
|
||||||
// Token invalid — continue without user
|
// Token invalid — continue without user
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,11 @@ export function requireRole(...roles: UserRole[]) {
|
|||||||
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
|
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');
|
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');
|
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');
|
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 { 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 { authService } from './auth.service';
|
||||||
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { authRateLimit } from '../../middleware/rate-limit';
|
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();
|
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
|
// POST /api/auth/refresh
|
||||||
router.post(
|
router.post(
|
||||||
'/refresh',
|
'/refresh',
|
||||||
@ -73,7 +250,6 @@ router.get(
|
|||||||
authenticate,
|
authenticate,
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { prisma } = await import('../../config/database');
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: req.user!.id },
|
where: { id: req.user!.id },
|
||||||
select: {
|
select: {
|
||||||
@ -82,6 +258,7 @@ router.get(
|
|||||||
name: true,
|
name: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
roles: true,
|
||||||
status: true,
|
status: true,
|
||||||
permissions: true,
|
permissions: true,
|
||||||
createdVia: true,
|
createdVia: true,
|
||||||
|
|||||||
@ -5,12 +5,17 @@ import { prisma } from '../../config/database';
|
|||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
import { AppError } from '../../middleware/error-handler';
|
import { AppError } from '../../middleware/error-handler';
|
||||||
import { recordLoginAttempt } from '../../utils/metrics';
|
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';
|
import type { RegisterInput } from './auth.schemas';
|
||||||
|
|
||||||
interface TokenPayload {
|
interface TokenPayload {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
roles: UserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TokenPair {
|
interface TokenPair {
|
||||||
@ -18,6 +23,16 @@ interface TokenPair {
|
|||||||
refreshToken: string;
|
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 = {
|
export const authService = {
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string) {
|
||||||
const user = await prisma.user.findUnique({ where: { email } });
|
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');
|
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) {
|
if (user.status !== UserStatus.ACTIVE) {
|
||||||
recordLoginAttempt('failure');
|
recordLoginAttempt('failure');
|
||||||
throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');
|
throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');
|
||||||
@ -56,6 +82,12 @@ export const authService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async register(data: RegisterInput) {
|
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 } });
|
const existing = await prisma.user.findUnique({ where: { email: data.email } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new AppError(409, 'Email already registered', 'EMAIL_EXISTS');
|
throw new AppError(409, 'Email already registered', 'EMAIL_EXISTS');
|
||||||
@ -63,16 +95,45 @@ export const authService = {
|
|||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(data.password, 12);
|
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({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
phone: data.phone,
|
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 tokens = await this.generateTokenPair(user);
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
@ -105,12 +166,13 @@ export const authService = {
|
|||||||
const tokens = await prisma.$transaction(async (tx) => {
|
const tokens = await prisma.$transaction(async (tx) => {
|
||||||
await tx.refreshToken.delete({ where: { id: stored.id } });
|
await tx.refreshToken.delete({ where: { id: stored.id } });
|
||||||
|
|
||||||
// Generate new token pair
|
const userRoles = parseRoles(stored.user);
|
||||||
const accessToken = this.generateAccessToken(stored.user);
|
const accessToken = this.generateAccessToken(stored.user);
|
||||||
const refreshPayload: TokenPayload = {
|
const refreshPayload: TokenPayload = {
|
||||||
id: stored.user.id,
|
id: stored.user.id,
|
||||||
email: stored.user.email,
|
email: stored.user.email,
|
||||||
role: stored.user.role
|
role: getPrimaryRole(userRoles),
|
||||||
|
roles: userRoles,
|
||||||
};
|
};
|
||||||
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
|
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
|
||||||
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
|
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
|
||||||
@ -139,20 +201,31 @@ export const authService = {
|
|||||||
await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });
|
await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });
|
||||||
},
|
},
|
||||||
|
|
||||||
generateAccessToken(user: { id: string; email: string; role: UserRole }): string {
|
generateAccessToken(user: UserForToken): string {
|
||||||
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
|
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, {
|
return jwt.sign(payload, env.JWT_ACCESS_SECRET, {
|
||||||
expiresIn: env.JWT_ACCESS_EXPIRY as SignOptions['expiresIn'],
|
expiresIn: env.JWT_ACCESS_EXPIRY as SignOptions['expiresIn'],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateRefreshToken(user: { id: string; email: string; role: UserRole }): Promise<string> {
|
async generateRefreshToken(user: UserForToken): Promise<string> {
|
||||||
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
|
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, {
|
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
|
||||||
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
|
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse expiry to get a Date
|
|
||||||
const decoded = jwt.decode(token) as { exp: number };
|
const decoded = jwt.decode(token) as { exp: number };
|
||||||
const expiresAt = new Date(decoded.exp * 1000);
|
const expiresAt = new Date(decoded.exp * 1000);
|
||||||
|
|
||||||
@ -167,7 +240,7 @@ export const authService = {
|
|||||||
return token;
|
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 accessToken = this.generateAccessToken(user);
|
||||||
const refreshToken = await this.generateRefreshToken(user);
|
const refreshToken = await this.generateRefreshToken(user);
|
||||||
return { accessToken, refreshToken };
|
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 { z } from 'zod';
|
||||||
import { CampaignStatus, GovernmentLevel } from '@prisma/client';
|
import { CampaignStatus, CampaignModerationStatus, GovernmentLevel } from '@prisma/client';
|
||||||
|
|
||||||
export const createCampaignSchema = z.object({
|
export const createCampaignSchema = z.object({
|
||||||
title: z.string().min(1, 'Title is required'),
|
title: z.string().min(1, 'Title is required'),
|
||||||
@ -52,6 +52,38 @@ export const campaignIdSchema = z.object({
|
|||||||
id: z.string().min(1),
|
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 CreateCampaignInput = z.infer<typeof createCampaignSchema>;
|
||||||
export type UpdateCampaignInput = z.infer<typeof updateCampaignSchema>;
|
export type UpdateCampaignInput = z.infer<typeof updateCampaignSchema>;
|
||||||
export type ListCampaignsInput = z.infer<typeof listCampaignsSchema>;
|
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 { prisma } from '../../../config/database';
|
||||||
import { AppError } from '../../../middleware/error-handler';
|
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 = {
|
const campaignSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@ -26,6 +39,12 @@ const campaignSelect = {
|
|||||||
createdByUserId: true,
|
createdByUserId: true,
|
||||||
createdByUserEmail: true,
|
createdByUserEmail: true,
|
||||||
createdByUserName: true,
|
createdByUserName: true,
|
||||||
|
isUserGenerated: true,
|
||||||
|
moderationStatus: true,
|
||||||
|
reviewedByUserId: true,
|
||||||
|
reviewedAt: true,
|
||||||
|
rejectionReason: true,
|
||||||
|
moderationNotes: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
@ -86,8 +105,7 @@ export const campaignsService = {
|
|||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
|
|
||||||
// Non-admin users only see their own campaigns
|
// Non-admin users only see their own campaigns
|
||||||
const adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
if (user && !hasAnyRole(user, ADMIN_ROLES)) {
|
||||||
if (user && !adminRoles.includes(user.role)) {
|
|
||||||
where.createdByUserId = user.id;
|
where.createdByUserId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,4 +256,177 @@ export const campaignsService = {
|
|||||||
|
|
||||||
await prisma.campaign.delete({ where: { id } });
|
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),
|
validate(volunteerCreateLocationSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const role = req.user!.role;
|
|
||||||
const data = { ...req.body };
|
const data = { ...req.body };
|
||||||
|
|
||||||
// Strip fields based on role
|
// 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) {
|
if (!isAdmin) {
|
||||||
delete data.firstName;
|
delete data.firstName;
|
||||||
delete data.lastName;
|
delete data.lastName;
|
||||||
delete data.email;
|
delete data.email;
|
||||||
delete data.phone;
|
delete data.phone;
|
||||||
}
|
}
|
||||||
if (role === UserRole.TEMP) {
|
if (userRoles.length === 1 && userRoles[0] === UserRole.TEMP) {
|
||||||
delete data.supportLevel;
|
delete data.supportLevel;
|
||||||
delete data.sign;
|
delete data.sign;
|
||||||
delete data.signSize;
|
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 { prisma } from '../../../config/database';
|
||||||
import { AppError } from '../../../middleware/error-handler';
|
import { AppError } from '../../../middleware/error-handler';
|
||||||
import { emailService } from '../../../services/email.service';
|
import { emailService } from '../../../services/email.service';
|
||||||
import { siteSettingsService } from '../../settings/settings.service';
|
|
||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { recordShiftSignup } from '../../../utils/metrics';
|
import { recordShiftSignup } from '../../../utils/metrics';
|
||||||
@ -326,6 +325,7 @@ export const shiftsService = {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
role: 'TEMP',
|
role: 'TEMP',
|
||||||
|
roles: JSON.parse(JSON.stringify(['TEMP'])),
|
||||||
createdVia: 'PUBLIC_SHIFT_SIGNUP',
|
createdVia: 'PUBLIC_SHIFT_SIGNUP',
|
||||||
expiresAt: shiftDate,
|
expiresAt: shiftDate,
|
||||||
},
|
},
|
||||||
@ -388,32 +388,16 @@ export const shiftsService = {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
const htmlTemplate = emailService.loadTemplate('shift-signup-confirmation', 'html');
|
await emailService.sendShiftSignupConfirmation({
|
||||||
const txtTemplate = emailService.loadTemplate('shift-signup-confirmation', 'txt');
|
recipientEmail: data.email,
|
||||||
let orgName = 'Changemaker Lite';
|
recipientName: data.name,
|
||||||
try { orgName = (await siteSettingsService.get()).organizationName || orgName; } catch { /* use default */ }
|
shiftTitle: shift.title,
|
||||||
|
shiftDate: dateStr,
|
||||||
const vars: Record<string, string> = {
|
shiftTime: `${shift.startTime} — ${shift.endTime}`,
|
||||||
USER_NAME: data.name,
|
shiftLocation: shift.location || 'TBD',
|
||||||
USER_EMAIL: data.email,
|
isNewUser,
|
||||||
SHIFT_TITLE: shift.title,
|
tempPassword,
|
||||||
SHIFT_DATE: dateStr,
|
loginUrl: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to send shift signup confirmation email:', err);
|
logger.error('Failed to send shift signup confirmation email:', err);
|
||||||
@ -561,32 +545,16 @@ export const shiftsService = {
|
|||||||
const dateStr = shiftDate.toLocaleDateString('en-CA', {
|
const dateStr = shiftDate.toLocaleDateString('en-CA', {
|
||||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
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> = {
|
await emailService.sendShiftSignupConfirmation({
|
||||||
USER_NAME: user.name || user.email,
|
recipientEmail: user.email,
|
||||||
USER_EMAIL: user.email,
|
recipientName: user.name || user.email,
|
||||||
SHIFT_TITLE: shift.title,
|
shiftTitle: shift.title,
|
||||||
SHIFT_DATE: dateStr,
|
shiftDate: dateStr,
|
||||||
SHIFT_TIME: `${shift.startTime} — ${shift.endTime}`,
|
shiftTime: `${shift.startTime} — ${shift.endTime}`,
|
||||||
SHIFT_LOCATION: shift.location || 'TBD',
|
shiftLocation: shift.location || 'TBD',
|
||||||
IS_NEW_USER: '',
|
isNewUser: false,
|
||||||
TEMP_PASSWORD: '',
|
loginUrl: `${env.CORS_ORIGINS.split(',')[0].trim()}/login`,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to send volunteer shift signup confirmation email:', err);
|
logger.error('Failed to send volunteer shift signup confirmation email:', err);
|
||||||
@ -703,38 +671,23 @@ export const shiftsService = {
|
|||||||
day: 'numeric',
|
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 sent = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
for (const signup of shift.signups) {
|
for (const signup of shift.signups) {
|
||||||
try {
|
try {
|
||||||
const vars: Record<string, string> = {
|
const result = await emailService.sendShiftDetailsEmail({
|
||||||
USER_NAME: signup.userName || signup.userEmail,
|
recipientEmail: signup.userEmail,
|
||||||
SHIFT_TITLE: shift.title,
|
recipientName: signup.userName || signup.userEmail,
|
||||||
SHIFT_DATE: dateStr,
|
shiftTitle: shift.title,
|
||||||
SHIFT_START_TIME: shift.startTime,
|
shiftDate: dateStr,
|
||||||
SHIFT_END_TIME: shift.endTime,
|
shiftStartTime: shift.startTime,
|
||||||
SHIFT_LOCATION: shift.location || 'TBD',
|
shiftEndTime: shift.endTime,
|
||||||
SHIFT_DESCRIPTION: shift.description || '',
|
shiftLocation: shift.location || 'TBD',
|
||||||
CURRENT_VOLUNTEERS: shift.currentVolunteers.toString(),
|
shiftDescription: shift.description || '',
|
||||||
MAX_VOLUNTEERS: shift.maxVolunteers.toString(),
|
currentVolunteers: shift.currentVolunteers,
|
||||||
SHIFT_STATUS: shift.status,
|
maxVolunteers: shift.maxVolunteers,
|
||||||
ORGANIZATION_NAME: orgName,
|
shiftStatus: shift.status,
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { UserRole, UserStatus } from '@prisma/client';
|
import { UserRole, UserStatus } from '@prisma/client';
|
||||||
import { prisma } from '../../../config/database';
|
import { prisma } from '../../../config/database';
|
||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
|
import { hasAnyRole, ADMIN_ROLES as ADMIN_ROLE_LIST, getUserRoles } from '../../../utils/roles';
|
||||||
|
|
||||||
// Extend FastifyRequest to include user
|
// Extend FastifyRequest to include user
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@ -11,6 +12,7 @@ declare module 'fastify' {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
roles: UserRole[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -19,6 +21,7 @@ interface TokenPayload {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
roles?: UserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -58,6 +61,7 @@ export async function authenticate(
|
|||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
roles: true,
|
||||||
status: true,
|
status: true,
|
||||||
expiresAt: true,
|
expiresAt: true,
|
||||||
},
|
},
|
||||||
@ -86,10 +90,12 @@ export async function authenticate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach user to request
|
// Attach user to request
|
||||||
|
const userRoles = getUserRoles(user);
|
||||||
request.user = {
|
request.user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role as UserRole,
|
role: user.role as UserRole,
|
||||||
|
roles: userRoles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,9 +115,8 @@ export async function requireAdminRole(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check admin role (allow all admin roles)
|
// Check admin role using multi-role utility
|
||||||
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
|
if (!request.user || !hasAnyRole(request.user, ADMIN_ROLE_LIST)) {
|
||||||
if (!request.user || !ADMIN_ROLES.includes(request.user.role)) {
|
|
||||||
return reply.status(403).send({
|
return reply.status(403).send({
|
||||||
error: 'Admin access required',
|
error: 'Admin access required',
|
||||||
code: 'ADMIN_REQUIRED'
|
code: 'ADMIN_REQUIRED'
|
||||||
@ -145,15 +150,18 @@ export async function optionalAuth(
|
|||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
roles: true,
|
||||||
status: true,
|
status: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && user.status === UserStatus.ACTIVE) {
|
if (user && user.status === UserStatus.ACTIVE) {
|
||||||
|
const userRoles = getUserRoles(user);
|
||||||
request.user = {
|
request.user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role as UserRole,
|
role: user.role as UserRole,
|
||||||
|
roles: userRoles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} 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
|
* SSE endpoint for real-time chat updates
|
||||||
*/
|
*/
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/public/:id/stream',
|
'/public/:id/chat-stream',
|
||||||
async (
|
async (
|
||||||
request: FastifyRequest<{ Params: { id: string } }>,
|
request: FastifyRequest<{ Params: { id: string } }>,
|
||||||
reply: FastifyReply
|
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 { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { prisma } from '../../../config/database';
|
||||||
import { broadcastCommentToVideo } from './chat-stream.routes.js';
|
import { broadcastCommentToVideo } from './chat-stream.routes.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { optionalAuth } from '../middleware/auth';
|
||||||
|
import { checkContent } from '../services/word-filter.service';
|
||||||
const prisma = new PrismaClient();
|
import { notifyUser } from './chat-notifications.routes';
|
||||||
|
|
||||||
// Rate limiting map: userId/sessionId -> array of timestamps
|
// Rate limiting map: userId/sessionId -> array of timestamps
|
||||||
const commentRateLimitMap = new Map<string, number[]>();
|
const commentRateLimitMap = new Map<string, number[]>();
|
||||||
@ -31,7 +32,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
|||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Querystring: GetCommentsQuery;
|
Querystring: GetCommentsQuery;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply
|
reply
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const videoId = parseInt(request.params.id, 10);
|
const videoId = parseInt(request.params.id, 10);
|
||||||
@ -103,8 +104,11 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
|||||||
Params: { id: string };
|
Params: { id: string };
|
||||||
Body: CreateCommentBody;
|
Body: CreateCommentBody;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply
|
reply
|
||||||
) => {
|
) => {
|
||||||
|
// Optionally authenticate (attaches request.user if Bearer token present)
|
||||||
|
await optionalAuth(request, reply);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const videoId = parseInt(request.params.id, 10);
|
const videoId = parseInt(request.params.id, 10);
|
||||||
const { content } = request.body;
|
const { content } = request.body;
|
||||||
@ -123,27 +127,30 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create session
|
// Get session ID from X-Session-ID header (set by frontend)
|
||||||
let sessionId = request.session?.sessionId;
|
let sessionId = request.headers['x-session-id'] as string | undefined;
|
||||||
let userId: string | null = null;
|
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) {
|
if (request.user) {
|
||||||
userId = request.user.id;
|
userId = request.user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no session exists, create one
|
// If no session ID from header, generate one
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
sessionId = uuidv4();
|
sessionId = randomUUID();
|
||||||
// Create a minimal session record
|
}
|
||||||
await prisma.session.create({
|
|
||||||
data: {
|
// Ensure session record exists
|
||||||
|
await prisma.session.upsert({
|
||||||
|
where: { id: sessionId },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
ipAddress: request.ip,
|
ipAddress: request.ip,
|
||||||
userAgent: request.headers['user-agent'] || '',
|
userAgent: request.headers['user-agent'] || '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting check
|
// Rate limiting check
|
||||||
const rateLimitKey = userId || sessionId;
|
const rateLimitKey = userId || sessionId;
|
||||||
@ -162,6 +169,31 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
|||||||
recentTimestamps.push(now);
|
recentTimestamps.push(now);
|
||||||
commentRateLimitMap.set(rateLimitKey, recentTimestamps);
|
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
|
// Create comment
|
||||||
const newComment = await prisma.comment.create({
|
const newComment = await prisma.comment.create({
|
||||||
data: {
|
data: {
|
||||||
@ -169,7 +201,13 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
userId,
|
userId,
|
||||||
content: content.trim(),
|
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: {
|
include: {
|
||||||
user: {
|
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 = {
|
const broadcastData = {
|
||||||
id: newComment.id,
|
id: newComment.id,
|
||||||
content: newComment.content,
|
content: newComment.content,
|
||||||
@ -196,8 +234,48 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
|||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isHidden) {
|
||||||
broadcastCommentToVideo(videoId, broadcastData);
|
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);
|
return reply.code(201).send(broadcastData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create comment:', error);
|
console.error('Failed to create comment:', 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