import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { apiClient } from '../../../shared/api/client';
import { JSMpegPlayer } from '../components/JSMpegPlayer';
import { Video, LayoutGrid, ChevronDown } from 'lucide-react';
import { useAuth } from '../../../shared/auth/AuthContext';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface Camera {
id: number;
name: string;
ip_address: string;
port: number;
username?: string;
password?: string;
stream_path?: string;
transport_mode?: 'tcp' | 'udp' | 'auto';
rtsp_encoding?: boolean;
quality?: 'low' | 'medium' | 'original';
display_order?: number;
is_active?: boolean | number;
zone?: string;
}
// Wrap Camera Card with Sortable
function SortableCamera({ camera, children, disabled }: { camera: Camera, children: React.ReactNode, disabled: boolean }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: camera.id, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
{children}
);
}
export function MonitoringPage() {
const { user } = useAuth();
const [cameras, setCameras] = useState([]);
const [activeZone, setActiveZone] = useState('');
const [availableZones, setAvailableZones] = useState<{ name: string, layout: string }[]>([]);
const [showLayoutMenu, setShowLayoutMenu] = useState(false);
const fetchedRef = useRef(false);
const [streamVersions] = useState<{ [key: number]: number }>({});
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
fetchCameras();
}, []);
const fetchCameras = async () => {
try {
const res = await apiClient.get('/cameras');
setCameras(res.data);
} catch (err) {
console.error('Failed to fetch cameras', err);
}
};
const fetchZones = async () => {
try {
const res = await apiClient.get('/cameras/zones');
const zones = res.data.map((z: any) => ({
name: z.name,
layout: z.layout || '2*2'
}));
setAvailableZones(zones);
// Default to the first available zone to show video immediately
if (!activeZone && zones.length > 0) {
setActiveZone(zones[0].name);
}
} catch (err) {
console.error('Failed to fetch zones', err);
}
};
// Determine layout based on active zone's setting
const currentZoneConfig = availableZones.find(z => z.name === activeZone);
const viewLayout = currentZoneConfig ? currentZoneConfig.layout : '2*2';
const handleLayoutChange = async (newLayout: string) => {
if (!activeZone) return;
// Optimistic UI update
setAvailableZones(prev => prev.map(z =>
z.name === activeZone ? { ...z, layout: newLayout } : z
));
setShowLayoutMenu(false);
try {
await apiClient.patch(`/cameras/zones/${activeZone}/layout`, { layout: newLayout });
} catch (err) {
console.error('Failed to update layout', err);
// Revert or show alert if needed
}
};
const filteredCameras = !activeZone
? []
: cameras.filter(c => (c.zone || '기본 구역') === activeZone);
const handleToggleStatus = async (camera: Camera) => {
// Optimistic UI Update: Calculate new status
const currentStatus = camera.is_active !== 0 && camera.is_active !== false;
const newStatus = !currentStatus;
// Immediately update UI state
setCameras(prev => prev.map(c =>
c.id === camera.id ? { ...c, is_active: newStatus ? 1 : 0 } : c
));
try {
// Send request to server
await apiClient.patch(`/cameras/${camera.id}/status`, { is_active: newStatus });
// Only silent fetch to sync eventually, no alert needed
fetchCameras();
} catch (err) {
console.error('Failed to toggle status', err);
// Revert UI on failure
setCameras(prev => prev.map(c =>
c.id === camera.id ? { ...c, is_active: currentStatus ? 1 : 0 } : c
));
alert('상태 변경 실패 (되돌리기)');
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setCameras((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// Save new order
apiClient.put('/cameras/reorder', { cameras: newItems.map(c => c.id) })
.catch(e => console.error('Failed to save order', e));
return newItems;
});
}
};
const getStreamUrl = (cameraId: number) => {
// Use current host (proxied or direct)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const version = streamVersions[cameraId] || 0;
const url = `${protocol}//${window.location.host}/api/stream?cameraId=${cameraId}&v=${version}`;
console.log(`[CCTV] Connecting to: ${url}`);
return url;
};
const handlePing = async (id: number) => {
try {
const res = await apiClient.get(`/cameras/${id}/ping`);
// Critical check: If the server returns HTML (catch-all), it's old code
if (typeof res.data === 'string' && res.data.includes(' {
try {
const res = await apiClient.get('/health');
console.log('[System] Server Version:', res.data);
} catch (e) {
console.warn('[System] Could not fetch server health - might be old version.');
}
};
useEffect(() => {
fetchCameras();
fetchZones();
checkServerVersion();
}, []);
const HeaderDropdown = () => {
const portalRoot = document.getElementById('header-portal-root');
if (!portalRoot) return null;
// Tabs: [Zones...]
const zoneTabs = availableZones.map(z => z.name);
const layoutOptions = [
{ id: '1', label: '1개', icon: },
{ id: '1*2', label: '1*2', icon: },
{ id: '2*2', label: '2*2', icon: },
{ id: '3*3', label: '3*3', icon: },
{ id: '4*4', label: '4*4', icon: },
{ id: '5*5', label: '5*5', icon: },
{ id: '6*6', label: '6*6', icon: }
];
return createPortal(
{/* Left Area: Zone Tabs */}
{zoneTabs.map(name => (
))}
{/* Spacer to push everything else to the right */}
{/* Right Area: Layout Selector */}
{showLayoutMenu && activeZone && (
Monitor Arrangement
{layoutOptions.map(opt => (
))}
)}
,
portalRoot
);
};
const getLayoutInfo = () => {
if (viewLayout === '1') return { cols: 'grid-cols-1', total: 1 };
if (viewLayout === '1*2') return { cols: 'grid-cols-2', total: 2 };
// Multi-grid like 2*2, 3*3, 4*4
const parts = viewLayout.split('*');
if (parts.length === 2) {
const n = parseInt(parts[0]);
return { cols: `grid-cols-${n}`, total: n * n };
}
return { cols: 'grid-cols-2', total: 4 };
};
const layoutInfo = getLayoutInfo();
const totalSlots = Math.max(layoutInfo.total, filteredCameras.length);
// Fill slots: actual cameras + empty placeholders if needed to complete the grid rows
const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null);
return (
c.id)}
strategy={rectSortingStrategy}
disabled={true}
>
layoutInfo.total ? 'overflow-y-auto' : ''}`}
style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}>
{slots.map((camera, index) => (
{camera ? (
{/* VMS Slot Header */}
{(user?.role === 'admin' || user?.role === 'supervisor') && (
e.stopPropagation()}>
)}
{/* Streaming Video */}
{/* Small persistent ID or Name overlay */}
CH {index + 1} | {camera.ip_address}
) : (
No Buffer
)}
))}
);
}