feat: rewrite image picker (#6556)

* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
This commit is contained in:
Thomas authored and GitHub committed 2026-08-11 14:49:52 +02:00
1 parent 742bde1d27
commit ff2cdecc86
19 files changed
+1005 -331

No files matched your search

@@ -3,17 +3,15 @@
import type { ChangeEventHandler } from "react";
import { useEffect, useImperativeHandle, useRef } from "react";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { appManageSchema } from "@homarr/validation/app";
import { IconPicker } from "../icon-picker/icon-picker";
import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof appManageSchema>;
@@ -81,9 +79,6 @@ export const AppForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValuesKey]);
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200);
const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current;
@@ -130,29 +125,13 @@ export const AppForm = ({
}
};
// Auto-select icon based on app name with debounced search
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
{
searchText: debouncedName,
},
{
enabled: debouncedName.length > 3,
},
);
useEffect(() => {
if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
if (bestMatch) {
form.setFieldValue("iconUrl", bestMatch);
}
}
}, [debouncedName, iconsData]);
const formFields = (
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Textarea
{...form.getInputProps("description")}
label={t("app.field.description.label")}
@@ -1,30 +0,0 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@homarr/api";
type RouterOutput = inferRouterOutputs<AppRouter>;
type IconGroupsOutput = RouterOutput["icon"]["findIcons"]["icons"];
export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroupsOutput): string | null => {
const nameLower = searchTerm.toLowerCase();
const allIcons = iconGroups.flatMap((group) => group.icons);
const getIconPriority = (iconUrl: string) => {
const fileName = iconUrl.toLowerCase().split("/").pop()?.split(".")[0];
if (!fileName) return -1;
const isSvg = iconUrl.endsWith(".svg");
const isExactMatch = fileName === nameLower;
if (isExactMatch) return isSvg ? 0 : 1;
if (fileName.includes(nameLower)) return isSvg ? 2 : 3;
return -1;
};
for (let priority = 0; priority <= 3; priority++) {
const match = allIcons.find((icon) => getIconPriority(icon.url) === priority);
if (match) return match.url;
}
return null;
};