Initial scaffold: React+TS+Vite frontend, FastAPI backend, config system
This commit is contained in:
72
frontend/src/components/alerts/AlertPopup.tsx
Normal file
72
frontend/src/components/alerts/AlertPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/grid/CameraGrid.tsx
Normal file
35
frontend/src/components/grid/CameraGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/grid/CameraGridCell.tsx
Normal file
67
frontend/src/components/grid/CameraGridCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/layout/AppShell.tsx
Normal file
37
frontend/src/components/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/layout/Header.tsx
Normal file
33
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/player/CameraPlayer.tsx
Normal file
57
frontend/src/components/player/CameraPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/player/FullscreenView.tsx
Normal file
79
frontend/src/components/player/FullscreenView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/settings/AlertSettings.tsx
Normal file
84
frontend/src/components/settings/AlertSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/settings/CameraSettings.tsx
Normal file
135
frontend/src/components/settings/CameraSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/settings/GeneralSettings.tsx
Normal file
108
frontend/src/components/settings/GeneralSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/settings/SettingsPage.tsx
Normal file
39
frontend/src/components/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user