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

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>
);
}