feat: implement auto-select icon (#2679)

This commit is contained in:
Thomas Camlong
2025-03-27 22:18:11 +01:00
committed by GitHub
parent 8bf893b392
commit 1a3a55934d
2 changed files with 56 additions and 2 deletions

View File

@@ -1,17 +1,19 @@
"use client"; "use client";
import type { ChangeEventHandler } from "react"; import type { ChangeEventHandler } from "react";
import { useRef } from "react"; import { useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import type { z } from "zod"; import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IconPicker } from "../icon-picker/icon-picker"; import { IconPicker } from "../icon-picker/icon-picker";
import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof validation.app.manage>; type FormType = z.infer<typeof validation.app.manage>;
@@ -45,6 +47,9 @@ export const AppForm = ({
}, },
}); });
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200);
const shouldCreateAnother = useRef(false); const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => { const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current; const redirect = !shouldCreateAnother.current;
@@ -68,6 +73,25 @@ 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]);
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>

View File

@@ -0,0 +1,30 @@
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;
};