136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|