Initial scaffold: React+TS+Vite frontend, FastAPI backend, config system

This commit is contained in:
root
2026-02-25 21:57:36 -06:00
commit 11eab66e9d
45 changed files with 4508 additions and 0 deletions

13
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useConfigStore } from '@/stores/configStore';
import { AppShell } from '@/components/layout/AppShell';
export default function App() {
const loadConfig = useConfigStore((s) => s.loadConfig);
useEffect(() => {
loadConfig();
}, [loadConfig]);
return <AppShell />;
}

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { useAlertStore } from '@/stores/alertStore';
import { useConfigStore } from '@/stores/configStore';
import { CameraPlayer } from '@/components/player/CameraPlayer';
export function AlertPopup() {
const { activeAlert, dismissAlert } = useAlertStore();
const config = useConfigStore((s) => s.config);
const cameras = useConfigStore((s) => s.enabledCameras());
const [countdown, setCountdown] = useState(30);
const autoDismiss = config?.alerts.auto_dismiss_seconds ?? 30;
const camera = activeAlert ? cameras.find((c) => c.name === activeAlert.camera) : null;
useEffect(() => {
if (!activeAlert) return;
setCountdown(autoDismiss);
const dismissTimer = setTimeout(dismissAlert, autoDismiss * 1000);
const countdownInterval = setInterval(() => {
setCountdown((p) => Math.max(0, p - 1));
}, 1000);
return () => {
clearTimeout(dismissTimer);
clearInterval(countdownInterval);
};
}, [activeAlert, autoDismiss, dismissAlert]);
if (!activeAlert || !camera) return null;
return (
<div className="fixed inset-0 z-[100] bg-dark-primary flex flex-col">
{/* Alert header */}
<div className="h-14 bg-status-error/10 border-b-2 border-status-error flex items-center justify-between px-5 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-status-error flex items-center justify-center animate-pulse">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-base font-semibold text-white">Person Detected</h2>
<p className="text-status-error text-sm">{camera.display_name}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-xs text-gray-400">Auto-dismiss</div>
<div className="text-lg font-semibold text-white">{countdown}s</div>
</div>
<button
onClick={dismissAlert}
className="px-5 py-2 bg-accent hover:bg-accent/80 text-white text-sm font-medium rounded-lg transition-colors"
>
Dismiss
</button>
</div>
</div>
{/* Camera feed */}
<div className="flex-1 flex items-center justify-center p-4">
<CameraPlayer
camera={camera}
className="w-full max-w-5xl aspect-video ring-2 ring-status-error rounded-xl"
showLabel={false}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { useConfigStore } from '@/stores/configStore';
import { CameraGridCell } from './CameraGridCell';
const STAGGER_MS = 200;
export function CameraGrid() {
const cameras = useConfigStore((s) => s.enabledCameras());
const gridConfig = useConfigStore((s) => s.config?.grid);
const count = cameras.length;
const cols = gridConfig?.columns ?? (
count <= 4 ? 2 :
count <= 9 ? 3 :
4
);
const rows = Math.ceil(count / cols);
const gap = gridConfig?.gap ?? 4;
return (
<div
className="h-full p-2"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: `${gap}px`,
}}
>
{cameras.map((cam, i) => (
<CameraGridCell key={cam.name} camera={cam} delayMs={i * STAGGER_MS} />
))}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { useStream } from '@/hooks/useStream';
import { useConfigStore } from '@/stores/configStore';
import { useUIStore } from '@/stores/uiStore';
import type { CameraConfig } from '@/types/config';
interface CameraGridCellProps {
camera: CameraConfig;
delayMs: number;
}
export function CameraGridCell({ camera, delayMs }: CameraGridCellProps) {
const go2rtcUrl = useConfigStore((s) => s.config?.go2rtc.url ?? '');
const setFullscreen = useUIStore((s) => s.setFullscreenCamera);
const { videoRef, isConnecting, error, retry } = useStream({
streamName: camera.name,
go2rtcUrl,
delayMs,
});
return (
<div
className="relative bg-dark-tertiary rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-accent/50 transition-all"
onClick={() => setFullscreen(camera.name)}
>
<video
ref={videoRef}
autoPlay
muted
playsInline
className="w-full h-full object-cover"
/>
{/* Loading */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
<div className="flex flex-col items-center gap-2">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-gray-500">Connecting...</span>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
<div className="flex flex-col items-center gap-2 p-2">
<svg className="w-6 h-6 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-xs text-gray-500">Offline</span>
<button onClick={(e) => { e.stopPropagation(); retry(); }} className="text-xs text-accent hover:underline">
Retry
</button>
</div>
</div>
)}
{/* Label */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-2 py-1.5">
<span className="text-xs font-medium text-white/90 truncate block">
{camera.display_name}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useConfigStore } from '@/stores/configStore';
import { useUIStore } from '@/stores/uiStore';
import { useAlerts } from '@/hooks/useAlerts';
import { Header } from './Header';
import { CameraGrid } from '@/components/grid/CameraGrid';
import { FullscreenView } from '@/components/player/FullscreenView';
import { AlertPopup } from '@/components/alerts/AlertPopup';
import { SettingsPage } from '@/components/settings/SettingsPage';
export function AppShell() {
const { loading, error } = useConfigStore();
const { view, fullscreenCamera } = useUIStore();
useAlerts();
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-dark-primary">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<span className="text-gray-400">Loading...</span>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-dark-primary overflow-hidden">
<Header />
<main className="flex-1 overflow-hidden">
{view === 'settings' ? <SettingsPage /> : <CameraGrid />}
</main>
{fullscreenCamera && <FullscreenView />}
<AlertPopup />
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useConfigStore } from '@/stores/configStore';
import { useUIStore } from '@/stores/uiStore';
export function Header() {
const title = useConfigStore((s) => s.config?.title ?? 'Camera Viewer');
const { view, setView } = useUIStore();
return (
<header className="h-12 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-4 shrink-0">
<h1
className="text-lg font-semibold text-white cursor-pointer"
onClick={() => setView('grid')}
>
{title}
</h1>
<div className="flex items-center gap-2">
<button
onClick={() => setView(view === 'settings' ? 'grid' : 'settings')}
className={`p-2 rounded-lg transition-colors ${
view === 'settings' ? 'bg-accent text-white' : 'text-gray-400 hover:text-white hover:bg-dark-hover'
}`}
aria-label="Settings"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,57 @@
import { useStream } from '@/hooks/useStream';
import { useConfigStore } from '@/stores/configStore';
import type { CameraConfig } from '@/types/config';
interface CameraPlayerProps {
camera: CameraConfig;
className?: string;
showLabel?: boolean;
}
export function CameraPlayer({ camera, className = '', showLabel = true }: CameraPlayerProps) {
const go2rtcUrl = useConfigStore((s) => s.config?.go2rtc.url ?? '');
const { videoRef, isConnecting, error, retry } = useStream({
streamName: camera.name,
go2rtcUrl,
});
return (
<div className={`relative bg-dark-tertiary overflow-hidden ${className}`}>
<video
ref={videoRef}
autoPlay
muted
playsInline
className="w-full h-full object-contain"
/>
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-400">Connecting...</span>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-primary/80">
<div className="flex flex-col items-center gap-3">
<svg className="w-10 h-10 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm text-gray-400">{error}</span>
<button onClick={retry} className="text-sm text-accent hover:underline">Retry</button>
</div>
</div>
)}
{showLabel && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<span className="text-sm font-medium">{camera.display_name}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useCallback } from 'react';
import { useUIStore } from '@/stores/uiStore';
import { useConfigStore } from '@/stores/configStore';
import { CameraPlayer } from './CameraPlayer';
export function FullscreenView() {
const { fullscreenCamera, setFullscreenCamera } = useUIStore();
const cameras = useConfigStore((s) => s.enabledCameras());
const currentIdx = cameras.findIndex((c) => c.name === fullscreenCamera);
const camera = currentIdx >= 0 ? cameras[currentIdx] : null;
const navigate = useCallback((dir: 1 | -1) => {
if (cameras.length === 0) return;
const next = (currentIdx + dir + cameras.length) % cameras.length;
setFullscreenCamera(cameras[next].name);
}, [cameras, currentIdx, setFullscreenCamera]);
const close = useCallback(() => setFullscreenCamera(null), [setFullscreenCamera]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') navigate(1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') navigate(-1);
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [close, navigate]);
if (!camera) return null;
return (
<div className="fixed inset-0 z-50 bg-dark-primary flex flex-col">
{/* Header */}
<div className="h-12 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-1.5 text-gray-400 hover:text-white hover:bg-dark-hover rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h2 className="text-base font-semibold text-white">{camera.display_name}</h2>
<button
onClick={() => navigate(1)}
className="p-1.5 text-gray-400 hover:text-white hover:bg-dark-hover rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<span className="text-xs text-gray-500 ml-2">{currentIdx + 1} / {cameras.length}</span>
</div>
<button
onClick={close}
className="p-2 text-gray-400 hover:text-white hover:bg-dark-hover rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Video */}
<div className="flex-1 flex items-center justify-center p-4">
<CameraPlayer
key={camera.name}
camera={camera}
className="w-full h-full rounded-lg"
showLabel={false}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useState } from 'react';
import { useConfigStore } from '@/stores/configStore';
export function AlertSettings() {
const config = useConfigStore((s) => s.config);
const saveConfig = useConfigStore((s) => s.saveConfig);
const [saving, setSaving] = useState(false);
const [enabled, setEnabled] = useState(config?.alerts.enabled ?? false);
const [autoDismiss, setAutoDismiss] = useState(config?.alerts.auto_dismiss_seconds ?? 30);
const [suppression, setSuppression] = useState(config?.alerts.suppression_seconds ?? 60);
const [detectionTypes, setDetectionTypes] = useState(config?.alerts.detection_types.join(', ') ?? 'person');
const [alertCameras, setAlertCameras] = useState(config?.alerts.cameras.join(', ') ?? '');
if (!config) return null;
const handleSave = async () => {
setSaving(true);
await saveConfig({
...config,
alerts: {
enabled,
auto_dismiss_seconds: autoDismiss,
suppression_seconds: suppression,
detection_types: detectionTypes.split(',').map((s) => s.trim()).filter(Boolean),
cameras: alertCameras ? alertCameras.split(',').map((s) => s.trim()).filter(Boolean) : [],
},
});
setSaving(false);
};
const inputClass = 'w-full bg-dark-tertiary border border-dark-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent';
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
return (
<div className="max-w-xl space-y-6">
{/* Enable toggle */}
<div className="flex items-center gap-3">
<button
onClick={() => setEnabled(!enabled)}
className={`w-10 h-6 rounded-full transition-colors flex items-center ${enabled ? 'bg-accent justify-end' : 'bg-dark-hover justify-start'}`}
>
<div className="w-5 h-5 bg-white rounded-full mx-0.5" />
</button>
<span className="text-sm text-white">Enable person detection alerts</span>
</div>
{enabled && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Auto-dismiss (seconds)</label>
<input className={inputClass} type="number" value={autoDismiss} onChange={(e) => setAutoDismiss(Number(e.target.value))} min={5} max={300} />
</div>
<div>
<label className={labelClass}>Suppression (seconds)</label>
<input className={inputClass} type="number" value={suppression} onChange={(e) => setSuppression(Number(e.target.value))} min={0} max={600} />
<p className="text-xs text-gray-500 mt-1">Cooldown after dismissing an alert</p>
</div>
</div>
<div>
<label className={labelClass}>Detection types</label>
<input className={inputClass} value={detectionTypes} onChange={(e) => setDetectionTypes(e.target.value)} placeholder="person, car, dog" />
<p className="text-xs text-gray-500 mt-1">Comma-separated Frigate detection labels</p>
</div>
<div>
<label className={labelClass}>Alert cameras (optional)</label>
<input className={inputClass} value={alertCameras} onChange={(e) => setAlertCameras(e.target.value)} placeholder="Leave empty for all cameras" />
<p className="text-xs text-gray-500 mt-1">Comma-separated camera names, or empty for all</p>
</div>
</>
)}
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 bg-accent hover:bg-accent/80 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{saving ? 'Saving...' : 'Save Alerts'}
</button>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import { useConfigStore } from '@/stores/configStore';
import { discoverCameras } from '@/services/api';
import type { CameraConfig } from '@/types/config';
export function CameraSettings() {
const config = useConfigStore((s) => s.config);
const saveConfig = useConfigStore((s) => s.saveConfig);
const [cameras, setCameras] = useState<CameraConfig[]>(config?.cameras ?? []);
const [saving, setSaving] = useState(false);
const [discovering, setDiscovering] = useState(false);
if (!config) return null;
const handleToggle = (idx: number) => {
const updated = [...cameras];
updated[idx] = { ...updated[idx], enabled: !updated[idx].enabled };
setCameras(updated);
};
const handleNameChange = (idx: number, display_name: string) => {
const updated = [...cameras];
updated[idx] = { ...updated[idx], display_name };
setCameras(updated);
};
const handleMoveUp = (idx: number) => {
if (idx === 0) return;
const updated = [...cameras];
[updated[idx - 1], updated[idx]] = [updated[idx], updated[idx - 1]];
updated.forEach((c, i) => c.order = i);
setCameras(updated);
};
const handleMoveDown = (idx: number) => {
if (idx >= cameras.length - 1) return;
const updated = [...cameras];
[updated[idx], updated[idx + 1]] = [updated[idx + 1], updated[idx]];
updated.forEach((c, i) => c.order = i);
setCameras(updated);
};
const handleRemove = (idx: number) => {
setCameras(cameras.filter((_, i) => i !== idx));
};
const handleDiscover = async () => {
setDiscovering(true);
const streams = await discoverCameras(config.go2rtc.url);
const existing = new Set(cameras.map((c) => c.name));
const newCams = streams
.filter((s) => !existing.has(s))
.map((name, i) => ({
name,
display_name: name.replace(/_/g, ' '),
enabled: true,
order: cameras.length + i,
}));
if (newCams.length > 0) {
setCameras([...cameras, ...newCams]);
}
setDiscovering(false);
};
const handleSave = async () => {
setSaving(true);
await saveConfig({ ...config, cameras });
setSaving(false);
};
return (
<div className="max-w-2xl space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">{cameras.length} Cameras</h3>
<div className="flex gap-2">
<button
onClick={handleDiscover}
disabled={discovering}
className="px-3 py-1.5 bg-dark-tertiary border border-dark-border hover:border-accent text-sm text-gray-300 rounded-lg transition-colors"
>
{discovering ? 'Discovering...' : 'Auto-Discover'}
</button>
</div>
</div>
<div className="space-y-1">
{cameras.map((cam, idx) => (
<div
key={cam.name}
className={`flex items-center gap-3 p-2 rounded-lg ${cam.enabled ? 'bg-dark-secondary' : 'bg-dark-secondary/50 opacity-60'}`}
>
{/* Toggle */}
<button
onClick={() => handleToggle(idx)}
className={`w-8 h-5 rounded-full transition-colors flex items-center ${cam.enabled ? 'bg-accent justify-end' : 'bg-dark-hover justify-start'}`}
>
<div className="w-4 h-4 bg-white rounded-full mx-0.5" />
</button>
{/* Stream name */}
<span className="text-xs text-gray-500 font-mono w-32 truncate">{cam.name}</span>
{/* Display name */}
<input
className="flex-1 bg-dark-tertiary border border-dark-border rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-accent"
value={cam.display_name}
onChange={(e) => handleNameChange(idx, e.target.value)}
/>
{/* Reorder */}
<button onClick={() => handleMoveUp(idx)} className="text-gray-500 hover:text-white p-1" title="Move up">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" /></svg>
</button>
<button onClick={() => handleMoveDown(idx)} className="text-gray-500 hover:text-white p-1" title="Move down">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{/* Remove */}
<button onClick={() => handleRemove(idx)} className="text-gray-500 hover:text-status-error p-1" title="Remove">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
</div>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 bg-accent hover:bg-accent/80 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{saving ? 'Saving...' : 'Save Cameras'}
</button>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useState } from 'react';
import { useConfigStore } from '@/stores/configStore';
export function GeneralSettings() {
const config = useConfigStore((s) => s.config);
const saveConfig = useConfigStore((s) => s.saveConfig);
const [saving, setSaving] = useState(false);
const [title, setTitle] = useState(config?.title ?? '');
const [go2rtcUrl, setGo2rtcUrl] = useState(config?.go2rtc.url ?? '');
const [frigateUrl, setFrigateUrl] = useState(config?.frigate.url ?? '');
const [mqttHost, setMqttHost] = useState(config?.mqtt.host ?? '');
const [mqttPort, setMqttPort] = useState(config?.mqtt.port ?? 1883);
const [mqttUser, setMqttUser] = useState(config?.mqtt.username ?? '');
const [mqttPass, setMqttPass] = useState(config?.mqtt.password ?? '');
const [gridCols, setGridCols] = useState<string>(config?.grid.columns?.toString() ?? 'auto');
const [gridGap, setGridGap] = useState(config?.grid.gap ?? 4);
if (!config) return null;
const handleSave = async () => {
setSaving(true);
await saveConfig({
...config,
title,
go2rtc: { url: go2rtcUrl },
frigate: { url: frigateUrl },
mqtt: { ...config.mqtt, host: mqttHost, port: mqttPort, username: mqttUser, password: mqttPass },
grid: { ...config.grid, columns: gridCols === 'auto' ? null : parseInt(gridCols), gap: gridGap },
});
setSaving(false);
};
const inputClass = 'w-full bg-dark-tertiary border border-dark-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent';
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
return (
<div className="max-w-xl space-y-6">
<div>
<label className={labelClass}>Title</label>
<input className={inputClass} value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="border-t border-dark-border pt-4">
<h3 className="text-sm font-semibold text-white mb-3">Streaming</h3>
<div className="space-y-3">
<div>
<label className={labelClass}>go2rtc URL</label>
<input className={inputClass} value={go2rtcUrl} onChange={(e) => setGo2rtcUrl(e.target.value)} placeholder="http://host:1985" />
</div>
<div>
<label className={labelClass}>Frigate URL</label>
<input className={inputClass} value={frigateUrl} onChange={(e) => setFrigateUrl(e.target.value)} placeholder="http://host:5000" />
</div>
</div>
</div>
<div className="border-t border-dark-border pt-4">
<h3 className="text-sm font-semibold text-white mb-3">MQTT</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Host</label>
<input className={inputClass} value={mqttHost} onChange={(e) => setMqttHost(e.target.value)} />
</div>
<div>
<label className={labelClass}>Port</label>
<input className={inputClass} type="number" value={mqttPort} onChange={(e) => setMqttPort(Number(e.target.value))} />
</div>
<div>
<label className={labelClass}>Username</label>
<input className={inputClass} value={mqttUser} onChange={(e) => setMqttUser(e.target.value)} />
</div>
<div>
<label className={labelClass}>Password</label>
<input className={inputClass} type="password" value={mqttPass} onChange={(e) => setMqttPass(e.target.value)} />
</div>
</div>
</div>
<div className="border-t border-dark-border pt-4">
<h3 className="text-sm font-semibold text-white mb-3">Grid Layout</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Columns</label>
<select className={inputClass} value={gridCols} onChange={(e) => setGridCols(e.target.value)}>
<option value="auto">Auto</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
<div>
<label className={labelClass}>Gap (px)</label>
<input className={inputClass} type="number" value={gridGap} onChange={(e) => setGridGap(Number(e.target.value))} min={0} max={20} />
</div>
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 bg-accent hover:bg-accent/80 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { GeneralSettings } from './GeneralSettings';
import { CameraSettings } from './CameraSettings';
import { AlertSettings } from './AlertSettings';
const TABS = ['General', 'Cameras', 'Alerts'] as const;
type Tab = typeof TABS[number];
export function SettingsPage() {
const [tab, setTab] = useState<Tab>('General');
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex border-b border-dark-border bg-dark-secondary px-4 shrink-0">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
tab === t
? 'border-accent text-accent'
: 'border-transparent text-gray-400 hover:text-white'
}`}
>
{t}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 overflow-auto p-4">
{tab === 'General' && <GeneralSettings />}
{tab === 'Cameras' && <CameraSettings />}
{tab === 'Alerts' && <AlertSettings />}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
import { AlertWebSocket } from '@/services/alerts';
import { useAlertStore } from '@/stores/alertStore';
import { useConfigStore } from '@/stores/configStore';
export function useAlerts() {
const wsRef = useRef<AlertWebSocket | null>(null);
const config = useConfigStore((s) => s.config);
const { showAlert, isSuppressed, activeAlert } = useAlertStore();
useEffect(() => {
if (!config?.alerts.enabled) return;
const ws = new AlertWebSocket();
wsRef.current = ws;
const unsub = ws.subscribe((event) => {
// Don't show if there's already an active alert
if (useAlertStore.getState().activeAlert) return;
// Check suppression
if (isSuppressed(event.camera, config.alerts.suppression_seconds)) return;
showAlert(event);
});
ws.connect();
return () => {
unsub();
ws.disconnect();
wsRef.current = null;
};
}, [config?.alerts.enabled]);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Go2RTCWebRTC, Go2RTCMSE } from '@/services/go2rtc';
interface UseStreamOptions {
streamName: string;
go2rtcUrl: string;
delayMs?: number;
enabled?: boolean;
}
interface UseStreamResult {
videoRef: React.RefObject<HTMLVideoElement>;
isConnecting: boolean;
error: string | null;
retry: () => void;
}
export function useStream({ streamName, go2rtcUrl, delayMs = 0, enabled = true }: UseStreamOptions): UseStreamResult {
const videoRef = useRef<HTMLVideoElement>(null!);
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
const mseRef = useRef<Go2RTCMSE | null>(null);
const [isConnecting, setIsConnecting] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const retry = useCallback(() => {
setRetryCount((c) => c + 1);
}, []);
useEffect(() => {
if (!enabled || !streamName || !go2rtcUrl) return;
let mounted = true;
let timer: ReturnType<typeof setTimeout>;
const connectWebRTC = async () => {
try {
setIsConnecting(true);
setError(null);
const webrtc = new Go2RTCWebRTC(streamName, go2rtcUrl);
webrtcRef.current = webrtc;
await webrtc.connect((stream) => {
if (mounted && videoRef.current) {
videoRef.current.srcObject = stream;
setIsConnecting(false);
}
});
} catch (err) {
if (!mounted) return;
console.warn(`WebRTC failed for ${streamName}, trying MSE...`);
await connectMSE();
}
};
const connectMSE = async () => {
try {
if (!mounted || !videoRef.current) return;
const mse = new Go2RTCMSE(streamName, go2rtcUrl);
mseRef.current = mse;
await mse.connect(videoRef.current);
if (mounted) setIsConnecting(false);
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : 'Connection failed');
setIsConnecting(false);
}
}
};
if (delayMs > 0) {
timer = setTimeout(connectWebRTC, delayMs);
} else {
connectWebRTC();
}
return () => {
mounted = false;
if (timer) clearTimeout(timer);
webrtcRef.current?.disconnect();
webrtcRef.current = null;
mseRef.current?.disconnect();
mseRef.current = null;
};
}, [streamName, go2rtcUrl, delayMs, enabled, retryCount]);
return { videoRef, isConnecting, error, retry };
}

16
frontend/src/index.css Normal file
View File

@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Hide scrollbar but allow scroll */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,70 @@
export interface AlertEvent {
type: 'alert';
camera: string;
label: string;
event_id: string;
has_snapshot: boolean;
has_clip: boolean;
timestamp: number;
}
export type EventHandler = (event: AlertEvent) => void;
export class AlertWebSocket {
private ws: WebSocket | null = null;
private handlers: Set<EventHandler> = new Set();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private url: string;
constructor() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
this.url = `${proto}//${location.host}/api/ws/events`;
}
connect(): void {
try {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as AlertEvent;
if (data.type === 'alert') {
this.handlers.forEach((h) => h(data));
}
} catch {}
};
this.ws.onclose = () => {
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
} catch {
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, 5000);
}
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.ws?.close();
this.ws = null;
}
subscribe(handler: EventHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
}

View File

@@ -0,0 +1,29 @@
import type { AppConfig } from '@/types/config';
const BASE = '/api';
export async function fetchConfig(): Promise<AppConfig> {
const res = await fetch(`${BASE}/config`);
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`);
return res.json();
}
export async function saveConfig(config: AppConfig): Promise<void> {
const res = await fetch(`${BASE}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) throw new Error(`Failed to save config: ${res.status}`);
}
export async function discoverCameras(go2rtcUrl: string): Promise<string[]> {
try {
const res = await fetch(`${go2rtcUrl}/api/streams`);
if (!res.ok) return [];
const data = await res.json();
return Object.keys(data);
} catch {
return [];
}
}

View File

@@ -0,0 +1,137 @@
export class Go2RTCWebRTC {
private pc: RTCPeerConnection | null = null;
private mediaStream: MediaStream | null = null;
private streamName: string;
private go2rtcUrl: string;
private onTrackCb: ((stream: MediaStream) => void) | null = null;
constructor(streamName: string, go2rtcUrl: string) {
this.streamName = streamName;
this.go2rtcUrl = go2rtcUrl;
}
async connect(onTrack: (stream: MediaStream) => void): Promise<void> {
this.onTrackCb = onTrack;
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
this.pc.ontrack = (event) => {
if (event.streams?.[0]) {
this.mediaStream = event.streams[0];
this.onTrackCb?.(this.mediaStream);
}
};
this.pc.onicecandidate = () => {
// go2rtc handles ICE internally via the initial SDP exchange
};
this.pc.addTransceiver('video', { direction: 'recvonly' });
this.pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
const url = `${this.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp,
});
if (!res.ok) {
throw new Error(`WebRTC offer failed: ${res.status}`);
}
const answerSdp = await res.text();
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
}
disconnect(): void {
this.mediaStream?.getTracks().forEach((t) => t.stop());
this.mediaStream = null;
this.pc?.close();
this.pc = null;
this.onTrackCb = null;
}
isConnected(): boolean {
return this.pc?.connectionState === 'connected';
}
}
export class Go2RTCMSE {
private mediaSource: MediaSource | null = null;
private sourceBuffer: SourceBuffer | null = null;
private ws: WebSocket | null = null;
private streamName: string;
private go2rtcUrl: string;
private videoElement: HTMLVideoElement | null = null;
private queue: ArrayBuffer[] = [];
constructor(streamName: string, go2rtcUrl: string) {
this.streamName = streamName;
this.go2rtcUrl = go2rtcUrl;
}
async connect(videoElement: HTMLVideoElement): Promise<void> {
this.videoElement = videoElement;
this.mediaSource = new MediaSource();
videoElement.src = URL.createObjectURL(this.mediaSource);
await new Promise<void>((resolve) => {
this.mediaSource!.addEventListener('sourceopen', () => resolve(), { once: true });
});
const wsUrl = `${this.go2rtcUrl.replace('http', 'ws')}/api/ws?src=${encodeURIComponent(this.streamName)}`;
this.ws = new WebSocket(wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onmessage = (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
if (msg.type === 'mse' && msg.value) {
this.initSourceBuffer(msg.value);
}
} else if (this.sourceBuffer) {
if (this.sourceBuffer.updating) {
this.queue.push(event.data);
} else {
this.sourceBuffer.appendBuffer(event.data);
}
}
};
}
private initSourceBuffer(codec: string): void {
if (!this.mediaSource || this.sourceBuffer) return;
try {
this.sourceBuffer = this.mediaSource.addSourceBuffer(codec);
this.sourceBuffer.mode = 'segments';
this.sourceBuffer.addEventListener('updateend', () => {
if (this.queue.length > 0 && this.sourceBuffer && !this.sourceBuffer.updating) {
this.sourceBuffer.appendBuffer(this.queue.shift()!);
}
});
} catch (e) {
console.error(`Failed to create source buffer for ${this.streamName}:`, e);
}
}
disconnect(): void {
this.ws?.close();
this.ws = null;
if (this.mediaSource?.readyState === 'open') {
try { this.mediaSource.endOfStream(); } catch {}
}
if (this.videoElement) {
this.videoElement.src = '';
this.videoElement = null;
}
this.sourceBuffer = null;
this.mediaSource = null;
this.queue = [];
}
}

View File

@@ -0,0 +1,38 @@
import { create } from 'zustand';
import type { AlertEvent } from '@/services/alerts';
interface AlertState {
activeAlert: AlertEvent | null;
suppressions: Record<string, number>; // camera -> timestamp
showAlert: (event: AlertEvent) => void;
dismissAlert: () => void;
isSuppressed: (camera: string, suppressionSeconds: number) => boolean;
}
export const useAlertStore = create<AlertState>((set, get) => ({
activeAlert: null,
suppressions: {},
showAlert: (event) => {
set({ activeAlert: event });
},
dismissAlert: () => {
const alert = get().activeAlert;
if (alert) {
set((state) => ({
activeAlert: null,
suppressions: {
...state.suppressions,
[alert.camera]: Date.now(),
},
}));
}
},
isSuppressed: (camera, suppressionSeconds) => {
const ts = get().suppressions[camera];
if (!ts) return false;
return Date.now() - ts < suppressionSeconds * 1000;
},
}));

View File

@@ -0,0 +1,55 @@
import { create } from 'zustand';
import type { AppConfig, CameraConfig } from '@/types/config';
import { fetchConfig, saveConfig as apiSaveConfig } from '@/services/api';
interface ConfigState {
config: AppConfig | null;
loading: boolean;
error: string | null;
loadConfig: () => Promise<void>;
saveConfig: (config: AppConfig) => Promise<void>;
enabledCameras: () => CameraConfig[];
}
const defaultConfig: AppConfig = {
title: 'Camera Viewer',
go2rtc: { url: 'http://192.168.1.241:1985' },
frigate: { url: 'http://192.168.1.241:5000' },
mqtt: { host: '', port: 1883, topic_prefix: 'frigate', username: '', password: '' },
cameras: [],
alerts: { enabled: false, auto_dismiss_seconds: 30, suppression_seconds: 60, cameras: [], detection_types: ['person'] },
grid: { columns: null, aspect_ratio: '16:9', gap: 4 },
};
export const useConfigStore = create<ConfigState>((set, get) => ({
config: null,
loading: true,
error: null,
loadConfig: async () => {
set({ loading: true, error: null });
try {
const config = await fetchConfig();
set({ config, loading: false });
} catch (e) {
set({ config: defaultConfig, loading: false, error: String(e) });
}
},
saveConfig: async (config: AppConfig) => {
try {
await apiSaveConfig(config);
set({ config });
} catch (e) {
set({ error: String(e) });
}
},
enabledCameras: () => {
const config = get().config;
if (!config) return [];
return config.cameras
.filter((c) => c.enabled)
.sort((a, b) => a.order - b.order);
},
}));

View File

@@ -0,0 +1,17 @@
import { create } from 'zustand';
type View = 'grid' | 'settings';
interface UIState {
view: View;
fullscreenCamera: string | null;
setView: (view: View) => void;
setFullscreenCamera: (name: string | null) => void;
}
export const useUIStore = create<UIState>((set) => ({
view: 'grid',
fullscreenCamera: null,
setView: (view) => set({ view, fullscreenCamera: null }),
setFullscreenCamera: (name) => set({ fullscreenCamera: name }),
}));

View File

@@ -0,0 +1,46 @@
export interface Go2RTCConfig {
url: string;
}
export interface FrigateConfig {
url: string;
}
export interface MQTTConfig {
host: string;
port: number;
topic_prefix: string;
username: string;
password: string;
}
export interface CameraConfig {
name: string;
display_name: string;
enabled: boolean;
order: number;
}
export interface AlertConfig {
enabled: boolean;
auto_dismiss_seconds: number;
suppression_seconds: number;
cameras: string[];
detection_types: string[];
}
export interface GridConfig {
columns: number | null;
aspect_ratio: string;
gap: number;
}
export interface AppConfig {
title: string;
go2rtc: Go2RTCConfig;
frigate: FrigateConfig;
mqtt: MQTTConfig;
cameras: CameraConfig[];
alerts: AlertConfig;
grid: GridConfig;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />