teg/frontend/src/pages/devices/CameraDetailsModal.tsx
2026-06-22 13:57:42 +02:00

751 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend\src\pages\devices\CameraDetailsModal.tsx
import { useState } from 'react'
import type { ComponentProps } from 'react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
CheckIcon,
VideoCameraIcon,
Cog6ToothIcon,
PlayCircleIcon,
AdjustmentsHorizontalIcon,
FilmIcon,
} 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 Tabs, { type TabItem } from '../../components/Tabs'
import type {
Device,
MilestoneCameraDetail,
MilestoneApiStream,
MilestoneCameraSettings,
MilestoneStreamDetail,
} from '../../components/types'
// ── Option lists ──────────────────────────────────────────────────────────────
const CODEC_OPTIONS = ['h264', 'h265', 'mjpeg', 'mpeg4']
const STREAMING_MODE_OPTIONS = ['TCP', 'UDP', 'Multicast', 'HTTPS']
const ZIPSTREAM_STRENGTH_OPTIONS = ['aus', 'niedrig', 'mittel', 'hoch', 'Höher', 'Extrem']
function withCurrentValue(options: string[], current: string): string[] {
return Array.from(
new Set([current, ...options].map((option) => option.trim()).filter(Boolean)),
)
}
// ── Shared primitives ─────────────────────────────────────────────────────────
type CameraTabId =
| 'general'
| 'stream'
| 'ptz'
| 'image'
| 'osd'
| 'videostreams'
function cls() {
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',
].join(' ')
}
function Label({ children }: { children: string }) {
return (
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
{children}
</label>
)
}
function SubHeading({ children }: { children: string }) {
return (
<h5 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{children}
</h5>
)
}
function Divider() {
return <hr className="border-gray-200 dark:border-white/10" />
}
function EmptyState({ message }: { message: string }) {
return <p className="text-sm text-gray-500 dark:text-gray-400">{message}</p>
}
// ── Props ─────────────────────────────────────────────────────────────────────
type CameraDetailsModalProps = {
open: boolean
setOpen: (open: boolean) => void
device: Device | null
childDevice: NonNullable<Device['milestoneChildDevices']>[number] | null
camera: MilestoneCameraDetail | null
apiStreams?: MilestoneApiStream[]
cameraSettings?: MilestoneCameraSettings | null
isLoading: boolean
isSaving: boolean
error: string | null
onSubmit: NonNullable<ComponentProps<'form'>['onSubmit']>
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function CameraDetailsModal({
open,
setOpen,
device,
childDevice,
camera,
apiStreams = [],
cameraSettings = null,
isLoading,
isSaving,
error,
onSubmit,
}: CameraDetailsModalProps) {
const [activeTab, setActiveTab] = useState<CameraTabId>('general')
const [activeStreamIndex, setActiveStreamIndex] = useState(0)
const title = childDevice?.displayName || childDevice?.name || 'Kamera bearbeiten'
// ── Derived data ────────────────────────────────────────────────────────────
const selectedStorageLabel = camera?.recordingStorage
? (camera.recordingStorage.displayName || camera.recordingStorage.name || camera.recordingStorage.id)
: ''
const storageOptions = (() => {
const map = new Map<string, string>()
if (camera?.recordingStorage?.id) map.set(camera.recordingStorage.id, selectedStorageLabel)
for (const s of camera?.archiveStorages ?? []) map.set(s.id, s.displayName || s.name || s.id)
return Array.from(map.entries()).map(([value, label]) => ({ value, label }))
})()
const settingsStreams = cameraSettings?.stream ?? []
const gs = cameraSettings?.generalSettings?.[0]
const ptz = cameraSettings?.ptz
const allApiSettings = apiStreams.flatMap((g) => g.stream)
const cameraResolutionOptions = (camera?.streams ?? [])
.flatMap((stream) => [
stream.resolution,
...(stream.availableResolutions ?? []),
])
.filter(Boolean)
function resolutionOptionsForStream(stream: MilestoneStreamDetail, index: number) {
const activeStream = allApiSettings.find(
(setting) => setting.streamReferenceId === stream.streamReferenceId,
)
const cameraStream = camera?.streams?.[index]
const dynamicOptions = [
...(stream.availableResolutions ?? []),
...(activeStream?.availableResolutions ?? []),
...(cameraStream?.availableResolutions ?? []),
]
const uniqueDynamicOptions = withCurrentValue(dynamicOptions, stream.resolution)
return withCurrentValue(
uniqueDynamicOptions.length > 1 ? uniqueDynamicOptions : cameraResolutionOptions,
stream.resolution,
)
}
const streamOptions =
settingsStreams.length > 0
? settingsStreams.map((s) => ({
refId: s.streamReferenceId,
label: s.displayName || `Stream (${s.streamReferenceId.slice(0, 8)})`,
}))
: apiStreams.flatMap((g) =>
g.stream.map((s) => ({
refId: s.streamReferenceId,
label: g.displayName || `Stream (${s.streamReferenceId.slice(0, 8)})`,
})),
)
const liveDefaultRefId =
allApiSettings.find((s) => s.liveDefault)?.streamReferenceId ?? streamOptions[0]?.refId ?? ''
const defaultPlaybackRefId =
allApiSettings.find((s) => s.defaultPlayback)?.streamReferenceId ?? streamOptions[0]?.refId ?? ''
const currentLiveMode =
allApiSettings.find((s) => s.streamReferenceId === liveDefaultRefId)?.liveMode ?? 'WhenNeeded'
const visibleStreamIndex = settingsStreams[activeStreamIndex]
? activeStreamIndex
: 0
// ── Tabs ────────────────────────────────────────────────────────────────────
const tabItems: TabItem[] = [
{
name: 'Allgemein',
href: '#',
current: activeTab === 'general',
icon: Cog6ToothIcon,
onClick: () => setActiveTab('general'),
},
{
name: 'Stream-Auswahl',
href: '#',
current: activeTab === 'stream',
icon: PlayCircleIcon,
onClick: () => setActiveTab('stream'),
},
{
name: 'PTZ',
href: '#',
current: activeTab === 'ptz',
icon: AdjustmentsHorizontalIcon,
onClick: () => setActiveTab('ptz'),
},
{
name: 'Bildeinstellungen',
href: '#',
current: activeTab === 'image',
icon: Cog6ToothIcon,
onClick: () => setActiveTab('image'),
},
{
name: 'OSD',
href: '#',
current: activeTab === 'osd',
icon: VideoCameraIcon,
onClick: () => setActiveTab('osd'),
},
{
name: 'Video-Streams',
href: '#',
current: activeTab === 'videostreams',
icon: FilmIcon,
onClick: () => setActiveTab('videostreams'),
},
]
const videoStreamTabs: TabItem[] = settingsStreams.map((_stream, index) => ({
name: `Stream ${index + 1}`,
href: '#',
current: visibleStreamIndex === index,
onClick: () => setActiveStreamIndex(index),
}))
// ── Render ──────────────────────────────────────────────────────────────────
return (
<Modal
open={open}
setOpen={setOpen}
zIndexClassName="z-[10000]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
>
<form onSubmit={onSubmit} className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col">
{/* Header */}
<div className="flex shrink-0 items-start justify-between border-b border-gray-200 bg-gray-50 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div className="flex items-center gap-3">
<VideoCameraIcon aria-hidden="true" className="size-5 text-indigo-600 dark:text-indigo-400" />
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{device?.milestoneDisplayName || device?.inventoryNumber || 'Milestone-Kamera'}
</p>
</div>
</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>
{/* Tabs nav */}
<div className="shrink-0 bg-gray-50 dark:bg-gray-800/60">
<Tabs
fullWidth
tabs={tabItems}
variant="underline-icons"
ariaLabel="Kamera-Einstellungen"
className="overflow-x-auto px-4 sm:px-0"
desktopNavClassName="sm:px-6"
/>
</div>
{/* Tab content all panels stay in DOM so FormData is complete */}
<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading && (
<div className="flex min-h-48 items-center justify-center p-6">
<LoadingSpinner size="md" label="Kamera-Details werden geladen…" className="text-indigo-600 dark:text-indigo-400" />
</div>
)}
{error && (
<div className="m-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
{!isLoading && camera && (
<>
{/* ── Tab 1: Allgemein ─────────────────────────────────── */}
<div className={activeTab === 'general' ? 'space-y-5 px-4 py-6 sm:px-6' : 'hidden'}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 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-lg border border-gray-200 px-4 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-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<Switch id="cameraRecordKeyframesOnly" name="recordKeyframesOnly" label="Nur Keyframes aufzeichnen" defaultChecked={camera.recordKeyframesOnly} />
</div>
</div>
<Divider />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-6">
<Label>Recording-Storage</Label>
{camera.recordingStorage?.id && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Aktuell: {selectedStorageLabel}
{camera.recordingStorage.diskPath ? ` · ${camera.recordingStorage.diskPath}` : ''}
{camera.recordingStorage.mounted === false ? ' · nicht gemountet' : ''}
</p>
)}
<div className="mt-2">
<select name="recordingStorageId" defaultValue={camera.recordingStorage?.id ?? ''} className={cls()}>
{storageOptions.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
</div>
</div>
{/* ── Tab 2: Stream-Auswahl ────────────────────────────── */}
<div className={activeTab === 'stream' ? 'space-y-5 px-4 py-6 sm:px-6' : 'hidden'}>
{streamOptions.length === 0 ? (
<EmptyState message="Keine Streams verfügbar." />
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
<div>
<Label>Live-Standard</Label>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Stream für Live-Ansicht</p>
<div className="mt-2">
<select name="active-liveDefault-refId" defaultValue={liveDefaultRefId} className={cls()}>
{streamOptions.map((o) => <option key={o.refId} value={o.refId}>{o.label}</option>)}
</select>
</div>
</div>
<div>
<Label>Standard-Wiedergabe</Label>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Stream für Aufzeichnungswiedergabe</p>
<div className="mt-2">
<select name="active-defaultPlayback-refId" defaultValue={defaultPlaybackRefId} className={cls()}>
{streamOptions.map((o) => <option key={o.refId} value={o.refId}>{o.label}</option>)}
</select>
</div>
</div>
<div>
<Label>Live-Modus</Label>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Wann der Stream gesendet wird</p>
<div className="mt-2">
<select name="active-liveMode" defaultValue={currentLiveMode} className={cls()}>
<option value="WhenNeeded">Wenn benötigt</option>
<option value="Always">Immer</option>
<option value="Never">Nie</option>
</select>
</div>
</div>
</div>
)}
</div>
{/* PTZ */}
<div className={activeTab === 'ptz' ? 'space-y-4 px-4 py-6 sm:px-6' : 'hidden'}>
{!ptz ? (
<EmptyState message="Keine PTZ-Einstellungen verfügbar." />
) : (
<>
{/* PTZ */}
<div className="space-y-4">
<SubHeading>PTZ</SubHeading>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<Switch id="ptz-ptzEnabled" name="ptz-ptzEnabled" label="PTZ aktiviert" defaultChecked={ptz.ptzEnabled} />
</div>
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<Switch id="ptz-ptzHomeSupport" name="ptz-ptzHomeSupport" label="Home-Position" defaultChecked={ptz.ptzHomeSupport} />
</div>
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<Switch id="ptz-ptzCenterOnPosition" name="ptz-ptzCenterOnPosition" label="Zentrierung auf Position" defaultChecked={ptz.ptzCenterOnPositionInView} />
</div>
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<Switch id="ptz-ptzCenterAndZoom" name="ptz-ptzCenterAndZoom" label="Zoom auf Rechteck" defaultChecked={ptz.ptzCenterAndZoomToRectangle} />
</div>
<div className="sm:col-span-3">
<Label>Protokoll</Label>
<div className="mt-2">
<input name="ptz-ptzProtocol" type="text" defaultValue={ptz.ptzProtocol} className={cls()} />
</div>
</div>
<div className="sm:col-span-3">
<Label>COM-Port</Label>
<div className="mt-2">
<input name="ptz-ptzCOMPort" type="number" min="0" step="1" defaultValue={ptz.ptzCOMPort} className={cls()} />
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Bildeinstellungen */}
<div className={activeTab === 'image' ? 'space-y-4 px-4 py-6 sm:px-6' : 'hidden'}>
{!gs ? (
<EmptyState message="Keine Bildeinstellungen verfügbar." />
) : (
<>
{/* Bild */}
<div className="space-y-4">
<SubHeading>Bildeinstellungen</SubHeading>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<Label>Kameraname</Label>
<div className="mt-2">
<input name="gs-cameraName" type="text" defaultValue={gs.cameraName} className={cls()} />
</div>
</div>
<div className="sm:col-span-3">
<Label>Weißabgleich</Label>
<div className="mt-2">
<input name="gs-whiteBalance" type="text" defaultValue={gs.whiteBalance} className={cls()} />
</div>
</div>
<div className="sm:col-span-2">
<Label>Helligkeit</Label>
<div className="mt-2">
<input name="gs-brightness" type="number" min="0" max="100" defaultValue={gs.brightness} className={cls()} />
</div>
</div>
<div className="sm:col-span-2">
<Label>Kontrast</Label>
<div className="mt-2">
<input name="gs-contrast" type="number" min="0" max="100" defaultValue={gs.contrast} className={cls()} />
</div>
</div>
<div className="sm:col-span-2">
<Label>Sättigung</Label>
<div className="mt-2">
<input name="gs-saturation" type="number" min="0" max="100" defaultValue={gs.saturation} className={cls()} />
</div>
</div>
<div className="sm:col-span-2">
<Label>Schärfe</Label>
<div className="mt-2">
<input name="gs-sharpness" type="number" min="0" max="100" defaultValue={gs.sharpness} className={cls()} />
</div>
</div>
<div className="sm:col-span-2">
<Label>Bild spiegeln</Label>
<div className="mt-2">
<select name="gs-mirrorImage" defaultValue={gs.mirrorImage} className={cls()}>
<option value="No">Nein</option>
<option value="Yes">Ja</option>
</select>
</div>
</div>
<div className="sm:col-span-2">
<Label>Rotation</Label>
<div className="mt-2">
<select name="gs-rotation" defaultValue={gs.rotation} className={cls()}>
<option value="0">0°</option>
<option value="90">90°</option>
<option value="180">180°</option>
<option value="270">270°</option>
</select>
</div>
</div>
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<Switch id="gs-blackAndWhite" name="gs-blackAndWhite" label="Schwarz/Weiß" defaultChecked={gs.blackAndWhiteMode === 'Yes'} />
</div>
</div>
</div>
</>
)}
</div>
{/* OSD */}
<div className={activeTab === 'osd' ? 'space-y-6 px-4 py-6 sm:px-6' : 'hidden'}>
{!gs ? (
<EmptyState message="Keine OSD-Einstellungen verfügbar." />
) : (
<>
{/* OSD & Multicast */}
<div className="space-y-4">
<SubHeading>OSD &amp; Multicast</SubHeading>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<Switch id="gs-oSDTextEnabled" name="gs-oSDTextEnabled" label="OSD-Text anzeigen" defaultChecked={gs.oSDTextEnabled === 'Yes'} />
</div>
<div className="sm:col-span-3">
<Label>OSD-Position</Label>
<div className="mt-2">
<select name="gs-oSDPosition" defaultValue={gs.oSDPosition} className={cls()}>
<option value="Top">Oben</option>
<option value="Bottom">Unten</option>
<option value="Left">Links</option>
<option value="Right">Rechts</option>
</select>
</div>
</div>
<div className="sm:col-span-3">
<Label>OSD-Text</Label>
<div className="mt-2">
<input name="gs-oSDText" type="text" defaultValue={gs.oSDText} className={cls()} />
</div>
</div>
<div className="sm:col-span-3">
<Label>Multicast-Adresse</Label>
<div className="mt-2">
<input name="gs-multicastAddress" type="text" defaultValue={gs.multicastAddress} className={cls()} />
</div>
</div>
<div className="sm:col-span-3">
<Label>Multicast-Port</Label>
<div className="mt-2">
<input name="gs-multicastVideoPort" type="number" min="0" defaultValue={gs.multicastVideoPort} className={cls()} />
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* ── Tab 4: Video-Streams ─────────────────────────────── */}
<div className={activeTab === 'videostreams' ? 'space-y-4 px-4 py-6 sm:px-6' : 'hidden'}>
{settingsStreams.length === 0 ? (
<EmptyState message="Keine Stream-Einstellungen verfügbar." />
) : (
<>
<Tabs
tabs={videoStreamTabs}
variant="pills-brand"
background="subtle"
fullWidth
ariaLabel="Video-Streams"
className="overflow-x-auto"
/>
{settingsStreams.map((stream, index) => (
<div
key={stream.streamReferenceId || index}
className={
index === visibleStreamIndex
? 'rounded-lg border border-gray-200 dark:border-white/10'
: 'hidden'
}
>
{/* Stream header */}
<div className="flex items-center gap-2 border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<FilmIcon aria-hidden="true" className="size-4 text-indigo-500 dark:text-indigo-400" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
{stream.displayName || `Stream ${index + 1}`}
</span>
{stream.streamReferenceId && (
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">
{stream.streamReferenceId.slice(0, 8)}
</span>
)}
</div>
<div className="p-4">
<input type="hidden" name={`stream-${index}-streamReferenceId`} value={stream.streamReferenceId} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
{/* Codec / FPS / Auflösung */}
<div className="sm:col-span-2">
<Label>Codec</Label>
<div className="mt-2">
<select name={`stream-${index}-codec`} defaultValue={stream.codec} className={cls()}>
{withCurrentValue(CODEC_OPTIONS, stream.codec).map((c) => (
<option key={c} value={c}>{c.toUpperCase()}</option>
))}
</select>
</div>
</div>
<div className="sm:col-span-1">
<Label>FPS</Label>
<div className="mt-2">
<input name={`stream-${index}-FPS`} type="number" min="0" step="1" defaultValue={stream.FPS ?? ''} className={cls()} />
</div>
</div>
<div className="sm:col-span-3">
<Label>Auflösung</Label>
<div className="mt-2">
<select name={`stream-${index}-resolution`} defaultValue={stream.resolution} className={cls()}>
{resolutionOptionsForStream(stream, index).map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
</div>
{/* Bitrate / Komprimierung / GOP */}
<div className="sm:col-span-2">
<Label>Ziel-Bitrate (kbps)</Label>
<div className="mt-2">
<input name={`stream-${index}-targetBitrate`} type="number" min="0" step="1" defaultValue={stream.targetBitrate ?? ''} className={cls()} />
</div>
</div>
<div className="sm:col-span-2">
<Label>Komprimierung</Label>
<div className="mt-2">
<input name={`stream-${index}-compression`} type="number" min="0" max="100" defaultValue={stream.compression ?? ''} className={cls()} />
</div>
</div>
<div className="sm:col-span-2">
<Label>Max. Bilder zwischen Schlüsselbilder</Label>
<div className="mt-2">
<input name={`stream-${index}-maxGOPSize`} type="number" min="0" step="1" defaultValue={stream.maxGOPSize ?? ''} className={cls()} />
</div>
</div>
{/* Streaming-Modus / Steuerungsmodus */}
<div className="sm:col-span-3">
<Label>Streaming-Modus</Label>
<div className="mt-2">
<select name={`stream-${index}-streamingMode`} defaultValue={stream.streamingMode} className={cls()}>
{withCurrentValue(STREAMING_MODE_OPTIONS, stream.streamingMode).map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
</div>
<div className="sm:col-span-3">
<Label>Steuerungsmodus</Label>
<div className="mt-2">
<select name={`stream-${index}-controlMode`} defaultValue={stream.controlMode} className={cls()}>
<option value="VariableBitRate">Variable Bitrate</option>
<option value="ConstantBitRate">Konstante Bitrate</option>
{stream.controlMode && !['VariableBitRate', 'ConstantBitRate'].includes(stream.controlMode) && (
<option value={stream.controlMode}>{stream.controlMode}</option>
)}
</select>
</div>
</div>
{/* Datum / Uhrzeit */}
<div className="sm:col-span-3">
<Label>Datum einfügen</Label>
<div className="mt-2">
<select name={`stream-${index}-includeDate`} defaultValue={stream.includeDate || 'No'} className={cls()}>
<option value="No">Nein</option>
<option value="Yes">Ja</option>
</select>
</div>
</div>
<div className="sm:col-span-3">
<Label>Uhrzeit einfügen</Label>
<div className="mt-2">
<select name={`stream-${index}-includeTime`} defaultValue={stream.includeTime || 'No'} className={cls()}>
<option value="No">Nein</option>
<option value="Yes">Ja</option>
</select>
</div>
</div>
{/* Zipstream */}
<div className="sm:col-span-6">
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 dark:border-white/5 dark:bg-white/[0.02]">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Zipstream
</p>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<Label>Kompression</Label>
<div className="mt-2">
<select name={`stream-${index}-zStrength`} defaultValue={stream.zStrength || ''} className={cls()}>
<option value=""></option>
{withCurrentValue(ZIPSTREAM_STRENGTH_OPTIONS, stream.zStrength).filter(Boolean).map((z) => (
<option key={z} value={z}>{z}</option>
))}
</select>
</div>
</div>
<div>
<Label>GOP-Modus</Label>
<div className="mt-2">
<select name={`stream-${index}-zGopMode`} defaultValue={stream.zGopMode || ''} className={cls()}>
<option value=""></option>
<option value="Fixed">Fixed</option>
<option value="DynamicGOP">Dynamic GOP</option>
{stream.zGopMode && !['', 'Fixed', 'DynamicGOP'].includes(stream.zGopMode) && (
<option value={stream.zGopMode}>{stream.zGopMode}</option>
)}
</select>
</div>
</div>
<div>
<Label>FPS-Modus</Label>
<div className="mt-2">
<select name={`stream-${index}-zFpsMode`} defaultValue={stream.zFpsMode || ''} className={cls()}>
<option value=""></option>
<option value="Fixed">Fixed</option>
<option value="DynamicFPS">Dynamic FPS</option>
{stream.zFpsMode && !['', 'Fixed', 'DynamicFPS'].includes(stream.zFpsMode) && (
<option value={stream.zFpsMode}>{stream.zFpsMode}</option>
)}
</select>
</div>
</div>
<div>
<Label>GOP-Länge</Label>
<div className="mt-2">
<input name={`stream-${index}-zGopLength`} type="number" min="0" step="1" defaultValue={stream.zGopLength ?? ''} className={cls()} />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
))}
</>
)}
</div>
</>
)}
</div>
{/* Footer */}
<div className="flex shrink-0 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" />}
>
Speichern
</Button>
</div>
</form>
</Modal>
)
}