// 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 ( ) } function SubHeading({ children }: { children: string }) { return (
{children}
) } function Divider() { return
} function EmptyState({ message }: { message: string }) { return

{message}

} // ── Props ───────────────────────────────────────────────────────────────────── type CameraDetailsModalProps = { open: boolean setOpen: (open: boolean) => void device: Device | null childDevice: NonNullable[number] | null camera: MilestoneCameraDetail | null apiStreams?: MilestoneApiStream[] cameraSettings?: MilestoneCameraSettings | null isLoading: boolean isSaving: boolean error: string | null onSubmit: NonNullable['onSubmit']> } // ── Component ───────────────────────────────────────────────────────────────── export default function CameraDetailsModal({ open, setOpen, device, childDevice, camera, apiStreams = [], cameraSettings = null, isLoading, isSaving, error, onSubmit, }: CameraDetailsModalProps) { const [activeTab, setActiveTab] = useState('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() 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 (
{/* Header */}
{/* Tabs nav */}
{/* Tab content – all panels stay in DOM so FormData is complete */}
{isLoading && (
)} {error && (
{error}
)} {!isLoading && camera && ( <> {/* ── Tab 1: Allgemein ─────────────────────────────────── */}
{camera.recordingStorage?.id && (

Aktuell: {selectedStorageLabel} {camera.recordingStorage.diskPath ? ` · ${camera.recordingStorage.diskPath}` : ''} {camera.recordingStorage.mounted === false ? ' · nicht gemountet' : ''}

)}
{/* ── Tab 2: Stream-Auswahl ────────────────────────────── */}
{streamOptions.length === 0 ? ( ) : (

Stream für Live-Ansicht

Stream für Aufzeichnungswiedergabe

Wann der Stream gesendet wird

)}
{/* PTZ */}
{!ptz ? ( ) : ( <> {/* PTZ */}
PTZ
)}
{/* Bildeinstellungen */}
{!gs ? ( ) : ( <> {/* Bild */}
Bildeinstellungen
)}
{/* OSD */}
{!gs ? ( ) : ( <> {/* OSD & Multicast */}
OSD & Multicast
)}
{/* ── Tab 4: Video-Streams ─────────────────────────────── */}
{settingsStreams.length === 0 ? ( ) : ( <> {settingsStreams.map((stream, index) => (
{/* Stream header */}
{/* Codec / FPS / Auflösung */}
{/* Bitrate / Komprimierung / GOP */}
{/* Streaming-Modus / Steuerungsmodus */}
{/* Datum / Uhrzeit */}
{/* Zipstream */}

Zipstream

))} )}
)}
{/* Footer */}
) }