teg/frontend/src/pages/devices/CameraDetailsModal.tsx
2026-06-09 14:26:35 +02:00

386 lines
15 KiB
TypeScript

// frontend\src\pages\devices\CameraDetailsModal.tsx
import { type FormEvent } from 'react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
CheckIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
import Button from '../../components/Button'
import Switch from '../../components/Switch'
import LoadingSpinner from '../../components/LoadingSpinner'
import type { Device, MilestoneCameraDetail } from '../../components/types'
type CameraDetailsModalProps = {
open: boolean
setOpen: (open: boolean) => void
device: Device | null
childDevice: NonNullable<Device['milestoneChildDevices']>[number] | null
camera: MilestoneCameraDetail | null
isLoading: boolean
isSaving: boolean
error: string | null
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}
function inputClassName(disabled = false) {
return [
'block w-full rounded-md px-3 py-1.5 text-sm outline-1 -outline-offset-1',
'bg-white text-gray-900 outline-gray-300',
'focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600',
'dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500',
disabled && 'cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400',
]
.filter(Boolean)
.join(' ')
}
export default function CameraDetailsModal({
open,
setOpen,
device,
childDevice,
camera,
isLoading,
isSaving,
error,
onSubmit,
}: CameraDetailsModalProps) {
const title =
childDevice?.displayName ||
childDevice?.name ||
'Kamera bearbeiten'
const streams = camera?.streams ?? []
const selectedRecordingStorageLabel = camera?.recordingStorage
? camera.recordingStorage.displayName ||
camera.recordingStorage.name ||
camera.recordingStorage.id
: ''
const recordingStorageOptions = (() => {
const options = new Map<string, string>()
if (camera?.recordingStorage?.id) {
options.set(camera.recordingStorage.id, selectedRecordingStorageLabel)
}
for (const storage of camera?.archiveStorages ?? []) {
options.set(
storage.id,
storage.displayName || storage.name || storage.id,
)
}
return Array.from(options.entries()).map(([value, label]) => ({
value,
label,
}))
})()
return (
<Modal
open={open}
setOpen={setOpen}
zIndexClassName="z-[10000]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-5xl bg-white dark:bg-gray-900"
>
<form
onSubmit={onSubmit}
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
>
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<h3 className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-white">
<VideoCameraIcon
aria-hidden="true"
className="size-5 text-indigo-600 dark:text-indigo-400"
/>
<span>{title}</span>
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{device?.milestoneDisplayName || device?.inventoryNumber || 'Milestone-Kamera'}
</p>
</div>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isSaving}
onClick={() => setOpen(false)}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
{isLoading && (
<div className="flex min-h-32 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
<LoadingSpinner
size="md"
label="Kamera-Details werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
/>
</div>
)}
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
{!isLoading && camera && (
<div className="space-y-6">
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Allgemein
</h4>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Basisdaten und Aufzeichnungsoptionen der Kamera.
</p>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-3 rounded-md border border-gray-200 px-3 py-3 dark:border-white/10">
<Switch
id="cameraEnabled"
name="enabled"
label="Kamera aktiviert"
defaultChecked={camera.enabled}
/>
</div>
<div className="sm:col-span-3 rounded-md border border-gray-200 px-3 py-3 dark:border-white/10">
<Switch
id="cameraRecordingEnabled"
name="recordingEnabled"
label="Aufzeichnung aktiviert"
defaultChecked={camera.recordingEnabled}
/>
</div>
<div className="sm:col-span-3 rounded-md border border-gray-200 px-3 py-3 dark:border-white/10">
<Switch
id="cameraRecordKeyframesOnly"
name="recordKeyframesOnly"
label="Nur Keyframes aufzeichnen"
defaultChecked={camera.recordKeyframesOnly}
/>
</div>
<div className="sm:col-span-3">
<label
htmlFor="recordingFramerate"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Aufnahme-FPS
</label>
<div className="mt-2">
<input
id="recordingFramerate"
name="recordingFramerate"
type="number"
min="0"
step="1"
defaultValue={camera.recordingFramerate ?? ''}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-6">
<label
htmlFor="recordingStorageId"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Recording-Storage
</label>
{camera.recordingStorage?.id && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Gewählt: {selectedRecordingStorageLabel}
{camera.recordingStorage.diskPath
? ` · ${camera.recordingStorage.diskPath}`
: ''}
{camera.recordingStorage.mounted === false
? ' · nicht gemountet'
: ''}
</p>
)}
<div className="mt-2">
<select
id="recordingStorageId"
name="recordingStorageId"
defaultValue={camera.recordingStorage?.id ?? ''}
className={inputClassName()}
>
<option value="">Kein Storage gewählt</option>
{recordingStorageOptions.map((storage) => (
<option key={storage.value} value={storage.value}>
{storage.label}
</option>
))}
</select>
</div>
</div>
</div>
</section>
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Streams
</h4>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Codec, FPS, Auflösung und Qualität je Stream.
</p>
</div>
<div className="mt-4 space-y-4">
{streams.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Keine Streams gefunden.
</p>
) : (
streams.map((stream, index) => (
<div
key={stream.index}
className="rounded-lg border border-gray-200 p-4 dark:border-white/10"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h5 className="text-sm font-semibold text-gray-900 dark:text-white">
{`Stream ${index + 1}`}
</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">
Index: {stream.index}
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<input
type="hidden"
name={`streamIndex-${index}`}
value={stream.index}
/>
<div className="sm:col-span-2">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Codec
</label>
<div className="mt-2">
<input
name={`streamCodec-${index}`}
type="text"
defaultValue={stream.codec}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-1">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
FPS
</label>
<div className="mt-2">
<input
name={`streamFps-${index}`}
type="number"
min="0"
step="1"
defaultValue={stream.fps ?? ''}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-1">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
JPEG-Qualität
</label>
<div className="mt-2">
<input
name={`streamJPEGQuality-${index}`}
type="number"
min="0"
step="1"
defaultValue={stream.jPEGQuality ?? ''}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-1">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Qualität
</label>
<div className="mt-2">
<input
name={`streamQuality-${index}`}
type="number"
min="0"
step="1"
defaultValue={stream.quality ?? ''}
className={inputClassName()}
/>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Auflösung
</label>
<div className="mt-2">
<input
name={`streamResolution-${index}`}
type="text"
defaultValue={stream.resolution}
className={inputClassName()}
/>
</div>
</div>
</div>
</div>
))
)}
</div>
</section>
</div>
)}
</div>
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<Button
type="button"
variant="secondary"
color="gray"
disabled={isSaving}
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
disabled={isLoading || !camera}
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
>
Kamera speichern
</Button>
</div>
</form>
</Modal>
)
}