"use client"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Accordion, ActionIcon, Button, Checkbox, Code, Divider, Fieldset, Group, Image, Loader, Select, Stack, Text, TextInput, Title, Tooltip, } from "@mantine/core"; import type { CheckboxProps } from "@mantine/core"; import type { FormErrors } from "@mantine/form"; import { useDebouncedValue } from "@mantine/hooks"; import { IconBrandDocker, IconEdit, IconPlus, IconSquare, IconSquareCheck, IconTrash, IconTriangleFilled, } from "@tabler/icons-react"; import { escapeForRegEx } from "@tiptap/react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { findBestIconMatch, IconPicker } from "@homarr/forms-collection"; import { createModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { MaskedImage } from "@homarr/ui"; import { isProviderKey, Providers } from "../releases/releases-providers"; import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; interface FormValidation { hasErrors: boolean; errors: FormErrors; } export const WidgetMultiReleasesRepositoriesInput = ({ property, kind, }: CommonWidgetInputProps<"multiReleasesRepositories">) => { const t = useWidgetInputTranslation(kind, property); const tRepository = useScopedI18n("widget.releases.option.repositories"); const form = useFormContext(); const repositories = form.values.options[property] as ReleasesRepository[]; const { openModal: openEditModal } = useModalAction(RepositoryEditModal); const { openModal: openImportModal } = useModalAction(RepositoryImportModal); const versionFilterPrecisionOptions = useMemo( () => [tRepository("versionFilter.precision.options.none"), "#", "#.#", "#.#.#", "#.#.#.#", "#.#.#.#.#"], [tRepository], ); const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; const onRepositorySave = useCallback( (repository: ReleasesRepository, index: number): FormValidation => { form.setFieldValue(`options.${property}.${index}.providerKey`, repository.providerKey); form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier); form.setFieldValue(`options.${property}.${index}.name`, repository.name); form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter); form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl); const formValidation = form.validate(); const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => { if (key.startsWith(`options.${property}.${index}.`)) { acc[key] = value; } return acc; }, {} as FormErrors); return { hasErrors: Object.keys(fieldErrors).length > 0, errors: fieldErrors, }; }, [form, property], ); const addNewRepository = () => { const repository: ReleasesRepository = { providerKey: "DockerHub", identifier: "", }; form.setValues((previous) => { const previousValues = previous.options?.[property] as ReleasesRepository[]; return { ...previous, options: { ...previous.options, [property]: [...previousValues, repository], }, }; }); const index = repositories.length; openEditModal({ fieldPath: `options.${property}.${index}`, repository, onRepositorySave: (saved) => onRepositorySave(saved, index), onRepositoryCancel: () => onRepositoryRemove(index), versionFilterPrecisionOptions, }); }; const onRepositoryRemove = (index: number) => { form.setValues((previous) => { const previousValues = previous.options?.[property] as ReleasesRepository[]; return { ...previous, options: { ...previous.options, [property]: previousValues.filter((_, i) => i !== index), }, }; }); }; return (
{repositories.map((repository, index) => { return ( {Providers[repository.providerKey].name} {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {repository.name || repository.identifier} onRepositoryRemove(index)}> {Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && ( {tRepository("invalid")} )} ); })}
); }; const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => { if (!versionFilter) return undefined; const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : ""; const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2); const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : ""; return `^${escapedPrefix}${precision}${escapedSuffix}$`; }; const formatIdentifierName = (identifier: string) => { const unformattedName = identifier.split("/").pop(); return unformattedName?.replace(/[-_]/g, " ").replace(/(?:^\w|[A-Z]|\b\w)/g, (char) => char.toUpperCase()) ?? ""; }; interface RepositoryEditProps { fieldPath: string; repository: ReleasesRepository; onRepositorySave: (repository: ReleasesRepository) => FormValidation; onRepositoryCancel?: () => void; versionFilterPrecisionOptions: string[]; } const RepositoryEditModal = createModal(({ innerProps, actions }) => { const tRepository = useScopedI18n("widget.releases.option.repositories"); const [loading, setLoading] = useState(false); const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository })); const [formErrors, setFormErrors] = useState({}); // Allows user to not select an icon by removing the url from the input, // will only try and get an icon if the name or identifier changes const [autoSetIcon, setAutoSetIcon] = useState(false); // Debounce the name value with 200ms delay const [debouncedName] = useDebouncedValue(tempRepository.name, 800); const handleConfirm = useCallback(() => { setLoading(true); const validation = innerProps.onRepositorySave(tempRepository); setFormErrors(validation.errors); if (!validation.hasErrors) { actions.closeModal(); } setLoading(false); }, [innerProps, tempRepository, actions]); const handleCancel = useCallback(() => { if (innerProps.onRepositoryCancel) { innerProps.onRepositoryCancel(); } actions.closeModal(); }, [innerProps, actions]); const handleChange = useCallback((changedValue: Partial) => { setTempRepository((prev) => ({ ...prev, ...changedValue })); }, []); // Auto-select icon based on identifier formatted name with debounced search const { data: iconsData } = clientApi.icon.findIcons.useQuery( { searchText: debouncedName, }, { enabled: autoSetIcon && (debouncedName?.length ?? 0) > 3, }, ); useEffect(() => { if (autoSetIcon && debouncedName && !tempRepository.iconUrl && iconsData?.icons) { const bestMatch = findBestIconMatch(debouncedName, iconsData.icons); if (bestMatch) { handleChange({ iconUrl: bestMatch }); } } }, [debouncedName, iconsData, tempRepository, handleChange, autoSetIcon]); return ( ({ value: key, label: value, }))} value={tempRepository.versionFilter?.precision.toString() ?? "0"} onChange={(value) => { const precision = value ? parseInt(value) : 0; handleChange({ versionFilter: isNaN(precision) || precision <= 0 ? undefined : { ...(tempRepository.versionFilter ?? {}), precision, }, }); }} error={formErrors[`${innerProps.fieldPath}.versionFilter.precision`]} /> { handleChange({ versionFilter: { ...(tempRepository.versionFilter ?? { precision: 0 }), suffix: event.currentTarget.value, }, }); }} error={formErrors[`${innerProps.fieldPath}.versionFilter.suffix`]} disabled={!tempRepository.versionFilter} /> {tRepository("versionFilter.regex.label")}:{" "} {formatVersionFilterRegex(tempRepository.versionFilter) ?? tRepository("versionFilter.precision.options.none")} ); }).withOptions({ defaultTitle(t) { return t("widget.releases.option.repositories.editForm.title"); }, size: "xl", }); interface ReleasesRepositoryImport extends ReleasesRepository { alreadyImported: boolean; } interface ContainerImageSelectorProps { containerImage: ReleasesRepositoryImport; versionFilterPrecisionOptions: string[]; onImageSelectionChanged?: (isSelected: boolean) => void; } const ContainerImageSelector = ({ containerImage, versionFilterPrecisionOptions, onImageSelectionChanged, }: ContainerImageSelectorProps) => { const tRepository = useScopedI18n("widget.releases.option.repositories"); const checkBoxProps: CheckboxProps = !onImageSelectionChanged ? { disabled: true, checked: true, } : { onChange: (event) => onImageSelectionChanged(event.currentTarget.checked), }; return ( {containerImage.identifier} } {...checkBoxProps} /> {containerImage.versionFilter && ( {tRepository("versionFilter.label")}: {containerImage.versionFilter.prefix && containerImage.versionFilter.prefix} {versionFilterPrecisionOptions[containerImage.versionFilter.precision]} {containerImage.versionFilter.suffix && containerImage.versionFilter.suffix} )} {Providers[containerImage.providerKey].name} ); }; interface RepositoryImportProps { repositories: ReleasesRepository[]; versionFilterPrecisionOptions: string[]; onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void; isAdmin: boolean; } const RepositoryImportModal = createModal(({ innerProps, actions }) => { const tRepository = useScopedI18n("widget.releases.option.repositories"); const [loading, setLoading] = useState(false); const [selectedImages, setSelectedImages] = useState([] as ReleasesRepositoryImport[]); const docker = clientApi.docker.getContainers.useQuery(undefined, { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, enabled: innerProps.isAdmin, }); const containersImages: ReleasesRepositoryImport[] = useMemo( () => docker.data?.containers.reduce((acc, containerImage) => { const providerKey = containerImage.image.startsWith("ghcr.io/") ? "Github" : "DockerHub"; const [identifier, version] = containerImage.image.replace(/^(ghcr\.io\/|docker\.io\/)/, "").split(":"); if (!identifier) return acc; if (acc.some((item) => item.providerKey === providerKey && item.identifier === identifier)) return acc; acc.push({ providerKey, identifier, iconUrl: containerImage.iconUrl ?? undefined, name: formatIdentifierName(identifier), versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined, alreadyImported: innerProps.repositories.some( (item) => item.providerKey === providerKey && item.identifier === identifier, ), }); return acc; }, []) ?? [], [docker.data, innerProps.repositories], ); const handleConfirm = useCallback(() => { setLoading(true); innerProps.onConfirm(selectedImages); setLoading(false); actions.closeModal(); }, [innerProps, selectedImages, actions]); const allImagesImported = useMemo( () => containersImages.every((containerImage) => containerImage.alreadyImported), [containersImages], ); const anyImagesImported = useMemo( () => containersImages.some((containerImage) => containerImage.alreadyImported), [containersImages], ); return ( {docker.isPending ? ( {tRepository("importRepositories.loading")} ) : containersImages.length === 0 ? ( {tRepository("importRepositories.noImagesFound")} ) : ( }> {tRepository("importRepositories.listFoundImages")} {allImagesImported && ( {tRepository("importRepositories.allImagesAlreadyImported")} )} {!allImagesImported && containersImages .filter((containerImage) => !containerImage.alreadyImported) .map((containerImage) => { return ( isSelected ? setSelectedImages([...selectedImages, containerImage]) : setSelectedImages(selectedImages.filter((img) => img !== containerImage)) } /> ); })} }> {tRepository("importRepositories.listAlreadyImportedImages")} {anyImagesImported && containersImages .filter((containerImage) => containerImage.alreadyImported) .map((containerImage) => { return ( ); })} )} ); }).withOptions({ defaultTitle(t) { return t("widget.releases.option.repositories.importForm.title"); }, size: "xl", }); const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => { const version = /(?<=\D|^)\d+(?:\.\d+)*(?![\d.])/.exec(imageVersion)?.[0]; if (!version) return undefined; const [prefix, suffix] = imageVersion.split(version); return { prefix, precision: version.split(".").length, suffix, }; };