"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 (
);
};
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 (
{
handleChange({ name: event.currentTarget.value });
if (event.currentTarget.value) setAutoSetIcon(true);
}}
error={formErrors[`${innerProps.fieldPath}.name`]}
style={{ flex: 1, flexBasis: "40%" }}
/>
{
if (url === "") {
setAutoSetIcon(false);
handleChange({ iconUrl: undefined });
} else {
handleChange({ iconUrl: url });
}
}}
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
/>
);
}).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,
};
};