{isMultiUnit ? (
- // Multi-unit building display
- <>
-
-
- {/* Building notes */}
- {group.buildingNotes && (
-
- }
- type="info"
- showIcon
- style={{ marginBottom: 12, fontSize: 11 }}
- />
- )}
-
- {/* Already sorted in groupAddressesByLocation helper */}
- {addresses.map((addr, i) => (
-
- ))}
- >
+ // Multi-unit building display — compact dropdown
+
onAddressClick(addresses[0]!.id)}>
diff --git a/admin/src/components/dashboard/ContainerMemoryChart.tsx b/admin/src/components/dashboard/ContainerMemoryChart.tsx
new file mode 100644
index 00000000..9eea589c
--- /dev/null
+++ b/admin/src/components/dashboard/ContainerMemoryChart.tsx
@@ -0,0 +1,49 @@
+import {
+ BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
+ ResponsiveContainer, Cell,
+} from 'recharts';
+import { Typography } from 'antd';
+import type { ContainerResource } from '@/types/api';
+
+const { Text } = Typography;
+
+interface ContainerMemoryChartProps {
+ containers: ContainerResource[];
+ height?: number;
+}
+
+function memColor(mb: number, maxMb: number): string {
+ const ratio = maxMb > 0 ? mb / maxMb : 0;
+ if (ratio > 0.7) return '#ff4d4f';
+ if (ratio > 0.4) return '#faad14';
+ return '#52c41a';
+}
+
+export default function ContainerMemoryChart({ containers, height = 180 }: ContainerMemoryChartProps) {
+ const sorted = [...containers]
+ .filter(c => c.memoryMB > 0)
+ .sort((a, b) => b.memoryMB - a.memoryMB);
+
+ if (sorted.length === 0) {
+ return
No container data;
+ }
+
+ const maxMem = sorted[0]?.memoryMB ?? 1;
+ const chartData = sorted.map(c => ({ name: c.label, memory: c.memoryMB }));
+
+ return (
+
+
+
+
+
+ `${v} MB`} contentStyle={{ fontSize: 12, borderRadius: 6 }} />
+
+ {chartData.map((entry, i) => (
+ |
+ ))}
+
+
+
+ );
+}
diff --git a/admin/src/components/dashboard/ContainerPopover.tsx b/admin/src/components/dashboard/ContainerPopover.tsx
new file mode 100644
index 00000000..68effdc9
--- /dev/null
+++ b/admin/src/components/dashboard/ContainerPopover.tsx
@@ -0,0 +1,58 @@
+import { Popover, Progress, Typography, Space, Flex } from 'antd';
+import type { ContainerResource } from '@/types/api';
+
+const { Text } = Typography;
+
+interface ContainerPopoverProps {
+ resource?: ContainerResource;
+ children: React.ReactNode;
+}
+
+export default function ContainerPopover({ resource, children }: ContainerPopoverProps) {
+ if (!resource) return <>{children}>;
+
+ const memPct = resource.memoryLimitMB > 0
+ ? Math.round((resource.memoryMB / resource.memoryLimitMB) * 100)
+ : 0;
+
+ const content = (
+
+
+ CPU
+ {resource.cpuPercent.toFixed(1)}%
+
+
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/admin/src/components/dashboard/LatencyBandsChart.tsx b/admin/src/components/dashboard/LatencyBandsChart.tsx
new file mode 100644
index 00000000..18ae6ef3
--- /dev/null
+++ b/admin/src/components/dashboard/LatencyBandsChart.tsx
@@ -0,0 +1,50 @@
+import {
+ AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
+ Legend, ResponsiveContainer,
+} from 'recharts';
+import { Typography } from 'antd';
+import type { TimeSeriesResult } from '@/types/api';
+
+const { Text } = Typography;
+
+interface LatencyBandsChartProps {
+ data: TimeSeriesResult;
+ height?: number;
+}
+
+function formatTime(ts: number): string {
+ const d = new Date(ts * 1000);
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+export default function LatencyBandsChart({ data, height = 200 }: LatencyBandsChartProps) {
+ const p50 = data.latency_p50;
+ const p95 = data.latency_p95;
+ const p99 = data.latency_p99;
+
+ if (!p50?.timestamps?.length) {
+ return
No latency data;
+ }
+
+ const chartData = p50.timestamps.map((ts, i) => ({
+ time: formatTime(ts),
+ p50: Math.round((p50.values[i] || 0) * 1000),
+ p95: Math.round((p95?.values[i] || 0) * 1000),
+ p99: Math.round((p99?.values[i] || 0) * 1000),
+ }));
+
+ return (
+
+
+
+
+
+ `${v}ms`} />
+
+
+
+
+
+
+ );
+}
diff --git a/admin/src/components/dashboard/MiniDonutChart.tsx b/admin/src/components/dashboard/MiniDonutChart.tsx
new file mode 100644
index 00000000..90fd3937
--- /dev/null
+++ b/admin/src/components/dashboard/MiniDonutChart.tsx
@@ -0,0 +1,49 @@
+import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
+
+interface DonutDatum {
+ name: string;
+ value: number;
+ color: string;
+}
+
+interface MiniDonutChartProps {
+ data: DonutDatum[];
+ height?: number;
+ innerRadius?: number;
+ outerRadius?: number;
+}
+
+export default function MiniDonutChart({
+ data,
+ height = 120,
+ innerRadius = 28,
+ outerRadius = 48,
+}: MiniDonutChartProps) {
+ const filtered = data.filter(d => d.value > 0);
+ if (filtered.length === 0) return null;
+
+ return (
+
+
+
+ {filtered.map((entry, i) => (
+ |
+ ))}
+
+ [`${value}`, `${name}`]}
+ contentStyle={{ fontSize: 12, padding: '4px 8px', borderRadius: 6 }}
+ />
+
+
+ );
+}
diff --git a/admin/src/components/dashboard/RequestTrafficChart.tsx b/admin/src/components/dashboard/RequestTrafficChart.tsx
new file mode 100644
index 00000000..42c3af1a
--- /dev/null
+++ b/admin/src/components/dashboard/RequestTrafficChart.tsx
@@ -0,0 +1,50 @@
+import {
+ AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
+ Legend, ResponsiveContainer,
+} from 'recharts';
+import { Typography } from 'antd';
+import type { TimeSeriesResult } from '@/types/api';
+
+const { Text } = Typography;
+
+interface RequestTrafficChartProps {
+ data: TimeSeriesResult;
+ height?: number;
+}
+
+function formatTime(ts: number): string {
+ const d = new Date(ts * 1000);
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+export default function RequestTrafficChart({ data, height = 200 }: RequestTrafficChartProps) {
+ const series2xx = data.request_rate_2xx;
+ const series4xx = data.request_rate_4xx;
+ const series5xx = data.request_rate_5xx;
+
+ if (!series2xx?.timestamps?.length) {
+ return
No traffic data;
+ }
+
+ const chartData = series2xx.timestamps.map((ts, i) => ({
+ time: formatTime(ts),
+ '2xx': series2xx.values[i] || 0,
+ '4xx': series4xx?.values[i] || 0,
+ '5xx': series5xx?.values[i] || 0,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/admin/src/components/dashboard/SystemGauges.tsx b/admin/src/components/dashboard/SystemGauges.tsx
new file mode 100644
index 00000000..277e33f3
--- /dev/null
+++ b/admin/src/components/dashboard/SystemGauges.tsx
@@ -0,0 +1,58 @@
+import { Progress, Flex, Typography } from 'antd';
+import type { SystemInfo } from '@/types/api';
+
+const { Text } = Typography;
+
+function gaugeColor(percent: number): string {
+ if (percent > 90) return '#ff4d4f';
+ if (percent > 70) return '#faad14';
+ return '#52c41a';
+}
+
+interface SystemGaugesProps {
+ systemInfo: SystemInfo;
+}
+
+export default function SystemGauges({ systemInfo }: SystemGaugesProps) {
+ const cpuPercent = Math.min(
+ Math.round(((systemInfo.cpu.loadAvg[0] ?? 0) / systemInfo.cpu.cores) * 100),
+ 100,
+ );
+
+ return (
+
+
+
+ {systemInfo.disk && (
+
+ )}
+
+ );
+}
diff --git a/admin/src/components/map/AreaImportWizard.tsx b/admin/src/components/map/AreaImportWizard.tsx
new file mode 100644
index 00000000..bba7a126
--- /dev/null
+++ b/admin/src/components/map/AreaImportWizard.tsx
@@ -0,0 +1,586 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import {
+ Steps,
+ Button,
+ Space,
+ Card,
+ Checkbox,
+ Select,
+ Slider,
+ InputNumber,
+ Statistic,
+ Alert,
+ Progress,
+ Tag,
+ Row,
+ Col,
+ Typography,
+ Spin,
+ Result,
+} from 'antd';
+import {
+ GlobalOutlined,
+ DatabaseOutlined,
+ CompassOutlined,
+ CheckCircleOutlined,
+ CloseCircleOutlined,
+ LoadingOutlined,
+ MinusCircleOutlined,
+} from '@ant-design/icons';
+import { api } from '@/lib/api';
+import type {
+ Cut,
+ MapSettings,
+ AreaImportPreviewResult,
+ AreaImportProgress,
+ AreaImportSourceStatus,
+} from '@/types/api';
+
+const { Text, Title } = Typography;
+
+interface AreaImportWizardProps {
+ cuts: Cut[];
+ onComplete?: () => void;
+}
+
+const SOURCE_STATUS_ICONS: Record
= {
+ pending: ,
+ running: ,
+ complete: ,
+ failed: ,
+ skipped: ,
+};
+
+export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardProps) {
+ const [currentStep, setCurrentStep] = useState(0);
+
+ // Step 0: Define area
+ const [areaType, setAreaType] = useState<'cut' | 'viewport'>('cut');
+ const [selectedCutId, setSelectedCutId] = useState();
+ const [mapSettings, setMapSettings] = useState(null);
+ const [mapSettingsLoading, setMapSettingsLoading] = useState(false);
+
+ // Step 1: Sources
+ const [osmEnabled, setOsmEnabled] = useState(true);
+ const [narEnabled, setNarEnabled] = useState(true);
+ const [narResidentialOnly, setNarResidentialOnly] = useState(true);
+ const [rgEnabled, setRgEnabled] = useState(false);
+ const [rgSpacing, setRgSpacing] = useState(100);
+ const [rgMaxPoints, setRgMaxPoints] = useState(500);
+
+ // Step 2: Preview
+ const [preview, setPreview] = useState(null);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [previewError, setPreviewError] = useState(null);
+
+ // Step 3: Progress
+ const [progress, setProgress] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const pollRef = useRef>(undefined);
+
+ // Load map settings for viewport mode
+ useEffect(() => {
+ if (areaType === 'viewport' && !mapSettings) {
+ setMapSettingsLoading(true);
+ api.get('/map/settings')
+ .then(({ data }) => setMapSettings(data))
+ .catch(() => {})
+ .finally(() => setMapSettingsLoading(false));
+ }
+ }, [areaType, mapSettings]);
+
+ // Cleanup polling on unmount
+ useEffect(() => {
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ };
+ }, []);
+
+ const buildRequestBody = useCallback(() => {
+ const sources: Record = {
+ osm: osmEnabled,
+ nar: narEnabled ? { residentialOnly: narResidentialOnly } : false,
+ reverseGeocode: rgEnabled ? { gridSpacingMeters: rgSpacing, maxPoints: rgMaxPoints } : false,
+ };
+
+ const body: Record = { sources };
+
+ if (areaType === 'cut') {
+ body.areaType = 'cut';
+ body.cutId = selectedCutId;
+ } else {
+ body.areaType = 'viewport';
+ body.center = {
+ lat: mapSettings?.latitude ? Number(mapSettings.latitude) : 53.5,
+ lng: mapSettings?.longitude ? Number(mapSettings.longitude) : -113.5,
+ };
+ body.zoom = mapSettings?.zoom ?? 13;
+ }
+
+ return body;
+ }, [areaType, selectedCutId, mapSettings, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]);
+
+ const fetchPreview = async () => {
+ setPreviewLoading(true);
+ setPreviewError(null);
+ try {
+ const { data } = await api.post('/map/area-import/preview', buildRequestBody());
+ setPreview(data);
+ } catch (err: any) {
+ setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed');
+ } finally {
+ setPreviewLoading(false);
+ }
+ };
+
+ const startImport = async () => {
+ setImporting(true);
+ try {
+ const body = { ...buildRequestBody(), deduplicateRadius: 5, batchSize: 1000 };
+ const { data } = await api.post('/map/area-import', body);
+ const currentImportId = data.importId;
+ setCurrentStep(3);
+
+ // Start polling
+ pollRef.current = setInterval(async () => {
+ try {
+ const { data: prog } = await api.get(`/map/area-import/status/${currentImportId}`);
+ setProgress(prog);
+ if (prog.status === 'complete' || prog.status === 'failed') {
+ if (pollRef.current) clearInterval(pollRef.current);
+ }
+ } catch {
+ // Ignore polling errors
+ }
+ }, 2000);
+ } catch (err: any) {
+ setPreviewError(err?.response?.data?.error?.message || 'Failed to start import');
+ setImporting(false);
+ }
+ };
+
+ const canProceedStep0 = areaType === 'cut' ? !!selectedCutId : (!!mapSettings?.latitude && !!mapSettings?.longitude);
+ const canProceedStep1 = osmEnabled || narEnabled || rgEnabled;
+
+ const steps = [
+ {
+ title: 'Define Area',
+ content: (
+
+
+ Area Source:
+
+
+ {areaType === 'cut' && (
+
+
+ Select a cut polygon to define the import area.
+
+
+ )}
+
+ {areaType === 'viewport' && (
+
+ {mapSettingsLoading ? (
+
+ ) : mapSettings?.latitude && mapSettings?.longitude ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A bounding box will be derived from the map center and zoom level.
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ ),
+ },
+ {
+ title: 'Sources',
+ content: (
+
+
setOsmEnabled(!osmEnabled)}
+ >
+ { e.stopPropagation(); setOsmEnabled(e.target.checked); }}>
+
+
+ OpenStreetMap (Overpass API)
+
+
+
+ Fetches address nodes and building footprints from OSM. Best for urban areas with good community mapping.
+
+
+
+
setNarEnabled(!narEnabled)}
+ >
+ { e.stopPropagation(); setNarEnabled(e.target.checked); }}>
+
+
+ NAR (National Address Register)
+
+
+
+ Official Canadian address data. Requires NAR files on server. Highest priority for deduplication.
+
+ {narEnabled && (
+ e.stopPropagation()}>
+ setNarResidentialOnly(e.target.checked)}>
+ Residential only
+
+
+ )}
+
+
+
setRgEnabled(!rgEnabled)}
+ >
+ { e.stopPropagation(); setRgEnabled(e.target.checked); }}>
+
+
+ Reverse Geocode Grid
+
+
+
+ Lays a grid of points and reverse geocodes each one. Slow but fills gaps not covered by other sources. Low confidence (40).
+
+ {rgEnabled && (
+ e.stopPropagation()}>
+
+
+ Grid spacing (meters):
+
+
+
+ Max points:
+ v && setRgMaxPoints(v)} size="small" />
+
+
+
+ )}
+
+
+ {!canProceedStep1 && (
+
+ )}
+
+ ),
+ },
+ {
+ title: 'Preview',
+ content: (
+
+ {previewLoading && (
+
+
+
+ Estimating import size...
+
+
+ )}
+
+ {previewError && (
+
+ )}
+
+ {preview && !previewLoading && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = 0 ? preview.estimates.osm : 0) +
+ preview.estimates.nar +
+ preview.estimates.reverseGeocode
+ }
+ valueStyle={{ fontSize: 16 }}
+ />
+
+
+
+
+
+
+ {osmEnabled && (
+
+ OSM}
+ value={preview.estimates.osm >= 0 ? preview.estimates.osm : '?'}
+ valueStyle={{ fontSize: 16 }}
+ />
+
+ )}
+ {narEnabled && (
+
+ NAR}
+ value={preview.estimates.nar}
+ suffix={preview.narProvincesDetected.length > 0 ? '' : undefined}
+ valueStyle={{ fontSize: 16 }}
+ />
+ {preview.narProvincesDetected.length > 0 && (
+
+ Provinces: {preview.narProvincesDetected.join(', ')}
+
+ )}
+ {preview.narProvincesDetected.length === 0 && (
+ No NAR data for this area
+ )}
+
+ )}
+ {rgEnabled && (
+
+ Rev. Geocode}
+ value={preview.estimates.reverseGeocode}
+ suffix="points"
+ valueStyle={{ fontSize: 16 }}
+ />
+
+ )}
+
+
+
+ {(preview.estimates.osm + preview.estimates.nar + preview.estimates.reverseGeocode) > 10000 && (
+
+ )}
+
+ {preview.areaSqKm > 100 && osmEnabled && (
+
+ )}
+
+
+ >
+ )}
+
+ ),
+ },
+ {
+ title: 'Progress',
+ content: (
+
+ {progress ? (
+ <>
+ {progress.status === 'complete' ? (
+
onComplete?.()}>
+ Done
+ ,
+ ]}
+ />
+ ) : progress.status === 'failed' ? (
+ { setCurrentStep(2); setImporting(false); }}>
+ Back to Preview
+ ,
+ ]}
+ />
+ ) : (
+ <>
+
+ {progress.status === 'initializing' ? 'Initializing...' :
+ progress.status === 'creating-records' ? 'Creating records...' : 'Running sources...'}
+
+
+
+ {(['osm', 'nar', 'reverseGeocode'] as const).map((source) => {
+ const sp = progress.sources[source];
+ const labels = { osm: 'OpenStreetMap', nar: 'NAR', reverseGeocode: 'Reverse Geocode' };
+ return (
+
+
+ {SOURCE_STATUS_ICONS[sp.status]}
+ {labels[source]}
+ {sp.status}
+ {sp.candidatesFound > 0 && (
+ {sp.candidatesFound} found
+ )}
+
+ {sp.message && (
+
+ {sp.message}
+
+ )}
+ {sp.error && (
+
+ {sp.error}
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {progress.status === 'creating-records' && progress.totalCandidates > 0 && (
+
+ )}
+ >
+ )}
+ >
+ ) : (
+
+
+
+ Starting import...
+
+
+ )}
+
+ ),
+ },
+ ];
+
+ const handleNext = () => {
+ if (currentStep === 1) {
+ // Moving to preview step — fetch preview
+ setCurrentStep(2);
+ // Fetch preview after state update
+ setTimeout(() => fetchPreview(), 0);
+ } else {
+ setCurrentStep(currentStep + 1);
+ }
+ };
+
+ return (
+
+
({ title: s.title }))}
+ />
+
+
+ {steps[currentStep]?.content}
+
+
+ {currentStep < 2 && (
+
+
+
+
+ )}
+
+ {currentStep === 2 && !previewLoading && !preview && !previewError && (
+
+
+
+
+ )}
+
+ {currentStep === 2 && (preview || previewError) && !importing && (
+
+
+
+ )}
+
+ );
+}
diff --git a/admin/src/components/media/AddToPlaylistModal.tsx b/admin/src/components/media/AddToPlaylistModal.tsx
new file mode 100644
index 00000000..7783ec6f
--- /dev/null
+++ b/admin/src/components/media/AddToPlaylistModal.tsx
@@ -0,0 +1,242 @@
+import { useState, useEffect } from 'react';
+import { Modal, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
+import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
+import { mediaApi } from '@/lib/media-api';
+import { mediaPublicApi } from '@/lib/media-public-api';
+import type { PlaylistSummary } from '@/types/media';
+
+const { Text } = Typography;
+
+interface AddToPlaylistModalProps {
+ videoId: number;
+ open: boolean;
+ onClose: () => void;
+}
+
+interface PlaylistWithSelected extends PlaylistSummary {
+ hasVideo: boolean;
+}
+
+export default function AddToPlaylistModal({
+ videoId,
+ open,
+ onClose,
+}: AddToPlaylistModalProps) {
+ const { token } = theme.useToken();
+ const [playlists, setPlaylists] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [selections, setSelections] = useState>({});
+
+ // Inline create state
+ const [showCreate, setShowCreate] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [creating, setCreating] = useState(false);
+
+ // Fetch user's playlists and check which ones contain the video
+ useEffect(() => {
+ if (!open) return;
+
+ const fetchPlaylists = async () => {
+ try {
+ setLoading(true);
+ const { data } = await mediaApi.get('/playlists/my');
+ const userPlaylists: PlaylistSummary[] = data.data || [];
+
+ // For each playlist, check if it contains the video
+ const withSelection = await Promise.all(
+ userPlaylists.map(async (p) => {
+ try {
+ const { data: detail } = await mediaPublicApi.get(
+ `/playlists/${p.id}`
+ );
+ const hasVideo = (detail.videos || []).some(
+ (v: any) => v.mediaId === videoId
+ );
+ return { ...p, hasVideo };
+ } catch {
+ return { ...p, hasVideo: false };
+ }
+ })
+ );
+
+ setPlaylists(withSelection);
+ // Initialize selections from current state
+ const initial: Record = {};
+ withSelection.forEach((p) => {
+ initial[p.id] = p.hasVideo;
+ });
+ setSelections(initial);
+ } catch {
+ message.error('Failed to load playlists');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchPlaylists();
+ }, [open, videoId]);
+
+ const handleToggle = (playlistId: number, checked: boolean) => {
+ setSelections((prev) => ({ ...prev, [playlistId]: checked }));
+ };
+
+ const handleSave = async () => {
+ try {
+ setSaving(true);
+
+ const promises: Promise[] = [];
+
+ for (const playlist of playlists) {
+ const wasInPlaylist = playlist.hasVideo;
+ const shouldBeInPlaylist = selections[playlist.id];
+
+ if (shouldBeInPlaylist && !wasInPlaylist) {
+ // Add to playlist
+ promises.push(
+ mediaApi.post(`/playlists/${playlist.id}/videos`, {
+ mediaId: videoId,
+ })
+ );
+ } else if (!shouldBeInPlaylist && wasInPlaylist) {
+ // Remove from playlist
+ promises.push(
+ mediaApi.delete(`/playlists/${playlist.id}/videos/${videoId}`)
+ );
+ }
+ }
+
+ await Promise.all(promises);
+ message.success('Playlists updated');
+ onClose();
+ } catch {
+ message.error('Failed to update playlists');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleCreateNew = async () => {
+ if (!newName.trim()) return;
+
+ try {
+ setCreating(true);
+ const { data } = await mediaApi.post('/playlists/', {
+ name: newName.trim(),
+ isPublic: false,
+ });
+
+ // Add video to the new playlist
+ await mediaApi.post(`/playlists/${data.id}/videos`, { mediaId: videoId });
+
+ message.success(`Created "${data.name}" and added video`);
+ setNewName('');
+ setShowCreate(false);
+
+ // Refresh the list
+ setPlaylists((prev) => [
+ ...prev,
+ { ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
+ ]);
+ setSelections((prev) => ({ ...prev, [data.id]: true }));
+ } catch (error: any) {
+ if (error.response?.status === 409) {
+ message.error('You already have a playlist with this name');
+ } else {
+ message.error('Failed to create playlist');
+ }
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ return (
+
+ {loading ? (
+
+
+
+ ) : (
+ <>
+ {playlists.length === 0 && !showCreate ? (
+
+
+
+ You don't have any playlists yet
+
+
+ ) : (
+
+ {playlists.map((p) => (
+
+ handleToggle(p.id, e.target.checked)}
+ >
+
+ {p.name}
+
+ ({p.videoCount} videos)
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {showCreate ? (
+
+ setNewName(e.target.value)}
+ onPressEnter={handleCreateNew}
+ maxLength={100}
+ autoFocus
+ />
+
+
+
+ ) : (
+ }
+ onClick={() => setShowCreate(true)}
+ block
+ >
+ Create New Playlist
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/admin/src/components/media/BulkAddToPlaylistModal.tsx b/admin/src/components/media/BulkAddToPlaylistModal.tsx
new file mode 100644
index 00000000..d6ade629
--- /dev/null
+++ b/admin/src/components/media/BulkAddToPlaylistModal.tsx
@@ -0,0 +1,188 @@
+import { useState, useEffect } from 'react';
+import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+import { mediaApi } from '@/lib/media-api';
+import type { PlaylistSummary } from '@/types/media';
+
+const { Text } = Typography;
+
+interface BulkAddToPlaylistModalProps {
+ videoIds: number[];
+ open: boolean;
+ onClose: () => void;
+ onSuccess?: () => void;
+}
+
+export default function BulkAddToPlaylistModal({
+ videoIds,
+ open,
+ onClose,
+ onSuccess,
+}: BulkAddToPlaylistModalProps) {
+ const [playlists, setPlaylists] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
+
+ // Inline create state
+ const [showCreate, setShowCreate] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [creating, setCreating] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const fetchPlaylists = async () => {
+ try {
+ setLoading(true);
+ const { data } = await mediaApi.get('/playlists/my');
+ setPlaylists(data.data || []);
+ } catch {
+ message.error('Failed to load playlists');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchPlaylists();
+ setSelectedPlaylistId(null);
+ setShowCreate(false);
+ setNewName('');
+ }, [open]);
+
+ const handleAdd = async () => {
+ if (!selectedPlaylistId || videoIds.length === 0) return;
+
+ try {
+ setSaving(true);
+ let added = 0;
+ let skipped = 0;
+
+ for (const mediaId of videoIds) {
+ try {
+ await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
+ added++;
+ } catch (error: any) {
+ if (error.response?.status === 409) {
+ skipped++;
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ const parts: string[] = [];
+ if (added > 0) parts.push(`${added} video${added > 1 ? 's' : ''} added`);
+ if (skipped > 0) parts.push(`${skipped} already in playlist`);
+ message.success(parts.join(', '));
+ onSuccess?.();
+ } catch {
+ message.error('Failed to add videos to playlist');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleCreateNew = async () => {
+ if (!newName.trim()) return;
+
+ try {
+ setCreating(true);
+ const { data } = await mediaApi.post('/playlists/', {
+ name: newName.trim(),
+ isPublic: false,
+ });
+
+ setPlaylists((prev) => [...prev, data]);
+ setSelectedPlaylistId(data.id);
+ setNewName('');
+ setShowCreate(false);
+ message.success(`Created "${data.name}"`);
+ } catch (error: any) {
+ if (error.response?.status === 409) {
+ message.error('You already have a playlist with this name');
+ } else {
+ message.error('Failed to create playlist');
+ }
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
+
+ return (
+ 1 ? 's' : ''} to playlist`}
+ open={open}
+ onOk={handleAdd}
+ onCancel={onClose}
+ confirmLoading={saving}
+ okText="Add"
+ okButtonProps={{ disabled: !selectedPlaylistId }}
+ >
+ {loading ? (
+
+
+
+ ) : (
+ <>
+
+ );
+}
diff --git a/admin/src/components/media/ChatNotificationToast.tsx b/admin/src/components/media/ChatNotificationToast.tsx
new file mode 100644
index 00000000..a793f6d1
--- /dev/null
+++ b/admin/src/components/media/ChatNotificationToast.tsx
@@ -0,0 +1,79 @@
+import { useEffect, useRef } from 'react';
+import { notification, Button, Space, Typography } from 'antd';
+import { MessageOutlined } from '@ant-design/icons';
+import { useNavigate } from 'react-router-dom';
+import type { ChatNotification } from '@/hooks/useChatNotifications';
+
+const { Text } = Typography;
+
+interface ChatNotificationToastProps {
+ notifications: ChatNotification[];
+ clearNotification: (id: string) => void;
+}
+
+export default function ChatNotificationToast({
+ notifications,
+ clearNotification,
+}: ChatNotificationToastProps) {
+ const [api, contextHolder] = notification.useNotification();
+ const navigate = useNavigate();
+ const shownRef = useRef>(new Set());
+
+ useEffect(() => {
+ for (const notif of notifications) {
+ if (shownRef.current.has(notif.id)) continue;
+ shownRef.current.add(notif.id);
+
+ api.info({
+ key: notif.id,
+ message: (
+
+
+ {notif.commenterName}
+ replied
+
+ ),
+ description: (
+
+
+ on {notif.videoTitle}
+
+
+ {notif.contentPreview}
+
+
+ ),
+ placement: 'bottomRight',
+ duration: 8,
+ btn: (
+
+ ),
+ onClose: () => {
+ clearNotification(notif.id);
+ },
+ });
+ }
+ }, [notifications, api, clearNotification, navigate]);
+
+ // Cleanup shown IDs when notifications are cleared
+ useEffect(() => {
+ const currentIds = new Set(notifications.map((n) => n.id));
+ for (const id of shownRef.current) {
+ if (!currentIds.has(id)) {
+ shownRef.current.delete(id);
+ }
+ }
+ }, [notifications]);
+
+ return <>{contextHolder}>;
+}
diff --git a/admin/src/components/media/CommentSection.tsx b/admin/src/components/media/CommentSection.tsx
index 4bc2e15d..af75e6f0 100644
--- a/admin/src/components/media/CommentSection.tsx
+++ b/admin/src/components/media/CommentSection.tsx
@@ -13,6 +13,7 @@ import {
} from 'antd';
import { UserOutlined, SendOutlined } from '@ant-design/icons';
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
+import { useAuthStore } from '@/stores/auth.store';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -85,8 +86,7 @@ export default function CommentSection({ videoId }: CommentSectionProps) {
}
// Check if user is logged in
- const accessToken = localStorage.getItem('accessToken');
- if (!accessToken) {
+ if (!useAuthStore.getState().isAuthenticated) {
message.warning('Please log in to comment');
return;
}
diff --git a/admin/src/components/media/CreatePlaylistModal.tsx b/admin/src/components/media/CreatePlaylistModal.tsx
new file mode 100644
index 00000000..5f64b00c
--- /dev/null
+++ b/admin/src/components/media/CreatePlaylistModal.tsx
@@ -0,0 +1,99 @@
+import { useState } from 'react';
+import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd';
+import { mediaApi } from '@/lib/media-api';
+
+interface CreatePlaylistModalProps {
+ open: boolean;
+ onClose: () => void;
+ onCreated?: (playlist: any) => void;
+}
+
+export default function CreatePlaylistModal({
+ open,
+ onClose,
+ onCreated,
+}: CreatePlaylistModalProps) {
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+ setLoading(true);
+
+ const { data } = await mediaApi.post('/playlists/', {
+ name: values.name,
+ description: values.description || undefined,
+ isPublic: values.isPublic ?? false,
+ });
+
+ message.success('Playlist created');
+ form.resetFields();
+ onCreated?.(data);
+ onClose();
+ } catch (error: any) {
+ if (error.response?.status === 409) {
+ message.error('You already have a playlist with this name');
+ } else if (!error.errorFields) {
+ message.error('Failed to create playlist');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ {
+ form.resetFields();
+ onClose();
+ }}
+ placement="right"
+ width={420}
+ style={{ top: 64 }}
+ styles={{ body: { paddingTop: 24 } }}
+ extra={
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/admin/src/components/media/EditPlaylistModal.tsx b/admin/src/components/media/EditPlaylistModal.tsx
new file mode 100644
index 00000000..779edddd
--- /dev/null
+++ b/admin/src/components/media/EditPlaylistModal.tsx
@@ -0,0 +1,260 @@
+import { useState, useEffect } from 'react';
+import { Drawer, Form, Input, Switch, Tabs, List, Button, Typography, Space, message, theme } from 'antd';
+import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
+import { mediaApi } from '@/lib/media-api';
+import { mediaPublicApi } from '@/lib/media-public-api';
+import type { PlaylistVideoItem } from '@/types/media';
+
+const { Text } = Typography;
+
+interface EditPlaylistModalProps {
+ playlistId: number | null;
+ open: boolean;
+ onClose: () => void;
+ onUpdated?: () => void;
+}
+
+function formatDuration(seconds: number | null): string {
+ if (!seconds) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+export default function EditPlaylistModal({
+ playlistId,
+ open,
+ onClose,
+ onUpdated,
+}: EditPlaylistModalProps) {
+ const [form] = Form.useForm();
+ const { token } = theme.useToken();
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [videos, setVideos] = useState([]);
+
+ useEffect(() => {
+ if (!open || !playlistId) return;
+
+ const fetchPlaylist = async () => {
+ try {
+ setLoading(true);
+ const { data } = await mediaPublicApi.get(`/playlists/${playlistId}`);
+ setVideos(data.videos || []);
+ form.setFieldsValue({
+ name: data.name,
+ description: data.description,
+ isPublic: data.isPublic,
+ });
+ } catch {
+ message.error('Failed to load playlist');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchPlaylist();
+ }, [open, playlistId]);
+
+ const handleSaveDetails = async () => {
+ try {
+ const values = await form.validateFields();
+ setSaving(true);
+
+ await mediaApi.put(`/playlists/${playlistId}`, {
+ name: values.name,
+ description: values.description || undefined,
+ isPublic: values.isPublic,
+ });
+
+ message.success('Playlist updated');
+ onUpdated?.();
+ } catch (error: any) {
+ if (error.response?.status === 409) {
+ message.error('You already have a playlist with this name');
+ } else if (!error.errorFields) {
+ message.error('Failed to update playlist');
+ }
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleRemoveVideo = async (mediaId: number) => {
+ try {
+ await mediaApi.delete(`/playlists/${playlistId}/videos/${mediaId}`);
+ setVideos((prev) => prev.filter((v) => v.mediaId !== mediaId));
+ message.success('Video removed');
+ onUpdated?.();
+ } catch {
+ message.error('Failed to remove video');
+ }
+ };
+
+ const handleMoveVideo = async (index: number, direction: 'up' | 'down') => {
+ const newVideos = [...videos];
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
+ if (targetIndex < 0 || targetIndex >= newVideos.length) return;
+
+ const temp = newVideos[index]!;
+ newVideos[index] = newVideos[targetIndex]!;
+ newVideos[targetIndex] = temp;
+
+ // Update positions
+ const reordered = newVideos.map((v, i) => ({
+ ...v,
+ position: i,
+ }));
+
+ setVideos(reordered);
+
+ try {
+ await mediaApi.put(`/playlists/${playlistId}/videos/reorder`, {
+ items: reordered.map((v) => ({ mediaId: v.mediaId, position: v.position })),
+ });
+ } catch {
+ message.error('Failed to reorder');
+ }
+ };
+
+ return (
+ {
+ form.resetFields();
+ onClose();
+ }}
+ placement="right"
+ width={520}
+ style={{ top: 64 }}
+ loading={loading}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ key: 'videos',
+ label: `Videos (${videos.length})`,
+ children: (
+ {
+ const title = item.video.title || item.video.filename.replace(/\.[^/.]+$/, '');
+ return (
+ }
+ disabled={index === 0}
+ onClick={() => handleMoveVideo(index, 'up')}
+ />,
+ }
+ disabled={index === videos.length - 1}
+ onClick={() => handleMoveVideo(index, 'down')}
+ />,
+ }
+ onClick={() => handleRemoveVideo(item.mediaId)}
+ />,
+ ]}
+ >
+
+
+ {index + 1}
+
+ {item.video.thumbnailUrl && (
+
+ )}
+
+
+ {title}
+
+
+ {formatDuration(item.video.durationSeconds)}
+
+
+
+
+ );
+ }}
+ />
+ ),
+ },
+ ]}
+ />
+
+ );
+}
diff --git a/admin/src/components/media/EditVideoModal.tsx b/admin/src/components/media/EditVideoModal.tsx
index 3434092f..a743233d 100644
--- a/admin/src/components/media/EditVideoModal.tsx
+++ b/admin/src/components/media/EditVideoModal.tsx
@@ -1,4 +1,4 @@
-import { Drawer, Form, Input, Select, Button, Space, message, Spin } from 'antd';
+import { Drawer, Form, Input, Select, Switch, Button, Space, message, Spin } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import { mediaApi } from '@/lib/media-api';
@@ -39,6 +39,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
category: v.category || undefined,
tags: Array.isArray(v.tags) ? v.tags : [],
quality: v.quality || '',
+ isShort: v.isShort ?? false,
});
})
.catch(() => {
@@ -50,6 +51,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
category: video.category || undefined,
tags: Array.isArray(video.tags) ? video.tags : [],
quality: video.quality || '',
+ isShort: video.isShort ?? false,
});
})
.finally(() => setFetching(false));
@@ -70,6 +72,7 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
payload.creator = values.creator || null;
payload.category = values.category || null;
payload.tags = values.tags && values.tags.length > 0 ? values.tags : null;
+ if (values.isShort !== undefined) payload.isShort = values.isShort;
await mediaApi.patch(`/videos/${video.id}`, payload);
message.success('Video updated successfully');
@@ -136,6 +139,10 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
/>
+
+
+
+
);
}
diff --git a/admin/src/components/media/FeaturedPlaylistCarousel.tsx b/admin/src/components/media/FeaturedPlaylistCarousel.tsx
new file mode 100644
index 00000000..8a4845f4
--- /dev/null
+++ b/admin/src/components/media/FeaturedPlaylistCarousel.tsx
@@ -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