import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Command, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { Check, ChevronsUpDown, Plus } from "lucide-react"; import { LuPlus, LuTrash2 } from "react-icons/lu"; import type { ConfigFormContext } from "@/types/configForm"; import get from "lodash/get"; import { isSubtreeModified } from "../utils"; type LiveStreamsData = Record; type StreamValueComboboxProps = { id: string; value: string; options: string[]; disabled?: boolean; readonly?: boolean; onChange: (next: string) => void; }; function StreamValueCombobox({ id, value, options, disabled, readonly, onChange, }: StreamValueComboboxProps) { const { t } = useTranslation(["views/settings", "common"]); const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); const trimmedSearch = searchValue.trim(); const matchesOption = useMemo( () => options.some((o) => o.toLowerCase() === trimmedSearch.toLowerCase()), [options, trimmedSearch], ); const showCustomOption = trimmedSearch.length > 0 && !matchesOption; const commit = (next: string) => { onChange(next); setSearchValue(""); setOpen(false); }; const placeholder = t("configForm.liveStreams.go2rtcStreamPlaceholder", { ns: "views/settings", }); const searchPlaceholder = t("configForm.liveStreams.go2rtcStreamSearch", { ns: "views/settings", }); const noStreams = t("configForm.liveStreams.noGo2rtcStreams", { ns: "views/settings", }); const availableHeading = t("configForm.liveStreams.availableStreams", { ns: "views/settings", }); return ( { setOpen(next); if (!next) setSearchValue(""); }} > { if (e.key === "Enter" && showCustomOption) { e.preventDefault(); commit(trimmedSearch); } }} /> {showCustomOption && ( commit(trimmedSearch)} > {t("configForm.liveStreams.useCustom", { ns: "views/settings", value: trimmedSearch, })} )} {options.length > 0 ? ( {options.map((option) => ( commit(option)} > {option} ))} ) : !showCustomOption ? (
{noStreams}
) : null}
); } export function LiveStreamsField(props: FieldProps) { const { schema, formData, onChange, idSchema, disabled, readonly } = props; const formContext = props.registry?.formContext as | ConfigFormContext | undefined; const configNamespace = formContext?.i18nNamespace ?? (formContext?.level === "camera" ? "config/cameras" : "config/global"); const { t: fallbackT } = useTranslation(["common", configNamespace]); const t = formContext?.t ?? fallbackT; const data: LiveStreamsData = useMemo(() => { if (!formData || typeof formData !== "object" || Array.isArray(formData)) { return {}; } return formData as LiveStreamsData; }, [formData]); const entries = useMemo(() => Object.entries(data), [data]); const id = idSchema?.$id ?? props.name; const sectionPrefix = formContext?.sectionI18nPrefix; const title = t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title; const description = t(`${sectionPrefix}.${id}.description`) ?? (schema as RJSFSchema).description; const go2rtcStreamNames = useMemo(() => { const streams = formContext?.fullConfig?.go2rtc?.streams; if (!streams || typeof streams !== "object") return []; return Object.keys(streams).sort(); }, [formContext?.fullConfig?.go2rtc?.streams]); const emptyPath = useMemo(() => [] as FieldPathList, []); const fieldPath = (props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ?? emptyPath; const isModified = useMemo(() => { const baselineRoot = formContext?.baselineFormData; const baselineValue = baselineRoot ? get(baselineRoot, fieldPath) : undefined; return isSubtreeModified( data, baselineValue, formContext?.overrides, fieldPath, formContext?.formData, ); }, [fieldPath, formContext, data]); const handleAddEntry = useCallback(() => { const next = { ...data, "": "" }; onChange(next, fieldPath); }, [data, fieldPath, onChange]); const handleRemoveEntry = useCallback( (key: string) => { const next = { ...data }; delete next[key]; onChange(next, fieldPath); }, [data, fieldPath, onChange], ); const handleRenameKey = useCallback( (oldKey: string, newKey: string) => { if (oldKey === newKey) return; const next: LiveStreamsData = {}; for (const [k, v] of Object.entries(data)) { if (k === oldKey) { next[newKey] = v; } else { next[k] = v; } } onChange(next, fieldPath); }, [data, fieldPath, onChange], ); const handleUpdateValue = useCallback( (key: string, value: string) => { const next = { ...data, [key]: value }; onChange(next, fieldPath); }, [data, fieldPath, onChange], ); const baseId = idSchema?.$id || "live_streams"; const deleteLabel = t("button.delete", { ns: "common", defaultValue: "Delete", }); const streamNameLabel = t("configForm.liveStreams.streamNameLabel", { ns: "views/settings", }); const streamNamePlaceholder = t( "configForm.liveStreams.streamNamePlaceholder", { ns: "views/settings" }, ); const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", { ns: "views/settings", }); const addStreamLabel = t("configForm.liveStreams.addStream", { ns: "views/settings", }); return ( {title} {description && (

{description}

)}
{entries.map(([key, value], entryIndex) => { const entryId = `${baseId}-${entryIndex}`; return (
handleRenameKey(key, e.target.value)} />
handleUpdateValue(key, next)} />
); })}
); } export default LiveStreamsField;