386 lines
15 KiB
TypeScript
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>
|
|
)
|
|
} |