Initial scaffold: React+TS+Vite frontend, FastAPI backend, config system
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user