diff --git a/apps/docs/docs/advanced/icons/index.mdx b/apps/docs/docs/advanced/icons/index.mdx
index b73d49e02..0931c6e43 100644
--- a/apps/docs/docs/advanced/icons/index.mdx
+++ b/apps/docs/docs/advanced/icons/index.mdx
@@ -9,17 +9,19 @@ tags:
---
## Icon picker
+
Icons in Homarr are automatically requested from multiple sources:
- - [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
- - [selfh.st icons](https://github.com/selfhst/icons)
- - [Simple icons](https://github.com/simple-icons/simple-icons)
- - [Tabler icons](https://tabler-icons.io/)
- - [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
- - [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
- - **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
+
+- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
+- [selfh.st icons](https://github.com/selfhst/icons)
+- [Simple icons](https://github.com/simple-icons/simple-icons)
+- [Tabler icons](https://tabler-icons.io/)
+- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
+- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
+- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
-The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
+The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
### Finding Missing Icons
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
+You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
+
### Using external icons
+
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
-Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
+Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
+
+### Uploading a local image
+
+Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
diff --git a/apps/docs/docs/management/apps/index.mdx b/apps/docs/docs/management/apps/index.mdx
index 7c84df588..efd42277d 100644
--- a/apps/docs/docs/management/apps/index.mdx
+++ b/apps/docs/docs/management/apps/index.mdx
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
-You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
+You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.

diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
index c2820757f..446658f80 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
-
+
diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
index 8da0a45b8..d0f62310c 100644
--- a/apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
-
+
{
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
- const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
+ const loginNavigation = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
+ timeout: 60_000,
+ waitUntil: "commit",
+ });
await page.locator("css=button[type='submit']").click();
await loginNavigation;
diff --git a/packages/api/src/router/icons.ts b/packages/api/src/router/icons.ts
index 6758dd012..fcd4c8c3f 100644
--- a/packages/api/src/router/icons.ts
+++ b/packages/api/src/router/icons.ts
@@ -1,4 +1,4 @@
-import { and, like } from "@homarr/db";
+import { getImageMatchRank, normalizeImageName } from "@homarr/common";
import { icons } from "@homarr/db/schema";
import { iconsFindSchema } from "@homarr/validation/icons";
@@ -15,38 +15,41 @@ export const iconsRouter = createTRPCRouter({
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
- const term = input.searchText?.toLowerCase().trim() ?? "";
- const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
-
- let whereCondition = undefined;
- if (term.length > 0) {
- whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
- }
+ const term = normalizeImageName(input.searchText ?? "");
+ const repositories = await ctx.db.query.iconRepositories.findMany({
+ orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
+ with: {
+ icons: {
+ columns: { id: true, name: true, url: true },
+ orderBy: (table, { asc, sql }) => [
+ sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
+ asc(table.name),
+ ],
+ limit: term.length === 0 ? input.limitPerGroup : undefined,
+ },
+ },
+ });
return {
- icons: await ctx.db.query.iconRepositories.findMany({
- with: {
- icons: {
- columns: { id: true, name: true, url: true },
- where: whereCondition,
- orderBy: (table, { asc, sql }) => {
- const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
- const nameAsc = asc(table.name);
- if (term.length === 0) {
- return [svgFirst, nameAsc];
- }
- const tier = sql`CASE
- WHEN LOWER(${table.name}) = ${term} THEN 0
- WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
- WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
- ELSE 3
- END`;
- return [tier, svgFirst, nameAsc];
- },
- limit: input.limitPerGroup,
- },
- },
- }),
+ icons:
+ term.length === 0
+ ? repositories
+ : repositories.map((repository) => ({
+ ...repository,
+ icons: repository.icons
+ .flatMap((icon) => {
+ const rank = getImageMatchRank(term, icon.name || icon.url);
+ return rank === null ? [] : [{ icon, rank }];
+ })
+ .toSorted((a, b) =>
+ a.rank !== b.rank
+ ? a.rank - b.rank
+ : Number(b.icon.name.toLowerCase().endsWith(".svg")) -
+ Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name),
+ )
+ .slice(0, input.limitPerGroup)
+ .map(({ icon }) => icon),
+ })),
countIcons: await ctx.db.$count(icons),
};
}),
diff --git a/packages/api/src/router/test/icons.spec.ts b/packages/api/src/router/test/icons.spec.ts
new file mode 100644
index 000000000..d4b85f6f1
--- /dev/null
+++ b/packages/api/src/router/test/icons.spec.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "vitest";
+
+import { iconRepositories, icons } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { iconsRouter } from "../icons";
+
+describe("findIcons", () => {
+ test.each(["homeassistant", "home_assistant", "home assistant.png"])(
+ "matches normalized search %s before applying the group limit",
+ async (searchText) => {
+ const db = createDb();
+ const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
+
+ await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
+ await db.insert(icons).values([
+ {
+ id: "irrelevant",
+ name: "000-unrelated.svg",
+ url: "https://example.com/000-unrelated.svg",
+ checksum: "irrelevant",
+ iconRepositoryId: "repository",
+ },
+ {
+ id: "home-assistant",
+ name: "home-assistant.svg",
+ url: "https://example.com/home-assistant.svg",
+ checksum: "home-assistant",
+ iconRepositoryId: "repository",
+ },
+ ]);
+
+ const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
+
+ expect(result.icons[0]?.icons).toEqual([
+ {
+ id: "home-assistant",
+ name: "home-assistant.svg",
+ url: "https://example.com/home-assistant.svg",
+ },
+ ]);
+ },
+ );
+});
diff --git a/packages/common/src/string.ts b/packages/common/src/string.ts
index 178cb9a47..5f15c5945 100644
--- a/packages/common/src/string.ts
+++ b/packages/common/src/string.ts
@@ -28,3 +28,34 @@ export const bestMatch = >(
return best;
}, null);
};
+
+export const normalizeImageName = (value: string) => {
+ const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
+ const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
+
+ return withoutExtension
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/&/g, " and ")
+ .replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
+ .trim()
+ .toLowerCase();
+};
+
+export const getImageMatchRank = (normalizedSearch: string, candidateValue: string): number | null => {
+ const candidate = normalizeImageName(candidateValue);
+ if (!candidate) return null;
+
+ const compactSearch = normalizedSearch.replaceAll(" ", "");
+ const compactCandidate = candidate.replaceAll(" ", "");
+ if (candidate === normalizedSearch) return 0;
+ if (compactCandidate === compactSearch) return 1;
+ if (candidate.startsWith(`${normalizedSearch} `)) return 2;
+
+ const searchTokens = normalizedSearch.split(" ");
+ const candidateTokens = candidate.split(" ");
+ if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
+ if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
+ if (candidate.includes(normalizedSearch)) return 5;
+ return null;
+};
diff --git a/packages/common/src/test/string.spec.ts b/packages/common/src/test/string.spec.ts
index d1bc1f9d0..b32254281 100644
--- a/packages/common/src/test/string.spec.ts
+++ b/packages/common/src/test/string.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
-import { bestMatch, capitalize } from "../string";
+import { bestMatch, capitalize, getImageMatchRank, normalizeImageName } from "../string";
const capitalizeTestCases = [
["hello", "Hello"],
@@ -54,3 +54,27 @@ describe("bestMatch should find the best match in an array of options", () => {
expect(result).toBeNull();
});
});
+
+describe("image name matching", () => {
+ test.each([
+ ["home-assistant.svg", "home assistant"],
+ ["https://example.com/Home_Assistant.PNG?v=1", "home assistant"],
+ ["jelly.fin.webp", "jelly fin"],
+ ["Caf\u00e9.svg", "cafe"],
+ ["\u6771\u4eac.svg", "\u6771\u4eac"],
+ ["\u0939\u093f\u0928\u094d\u0926\u0940.svg", "\u0939\u093f\u0928\u094d\u0926\u0940"],
+ ])("normalizes %s", (value, expected) => {
+ expect(normalizeImageName(value)).toBe(expected);
+ });
+
+ test.each([
+ ["homeassistant", "home-assistant.svg", 1],
+ ["home assistant", "home_assistant.png", 0],
+ ["home assistant", "Home Assistant.webp", 0],
+ ["\u6771\u4eac", "\u6771\u4eac.svg", 0],
+ ["\u0939\u093f\u0928\u094d\u0926\u0940", "\u0939\u093f\u0928\u094d\u0926\u0940.png", 0],
+ ["home assistant", "unrelated.svg", null],
+ ])("ranks %s against %s", (search, candidate, expected) => {
+ expect(getImageMatchRank(normalizeImageName(search), candidate)).toBe(expected);
+ });
+});
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.module.css b/packages/forms-collection/src/icon-picker/icon-picker.module.css
index aa52af4b0..43b8d5ca2 100644
--- a/packages/forms-collection/src/icon-picker/icon-picker.module.css
+++ b/packages/forms-collection/src/icon-picker/icon-picker.module.css
@@ -1,3 +1,89 @@
-[data-combobox-selected="true"] .iconCard {
- border-color: var(--mantine-primary-color-6);
+.dropdown {
+ min-width: min(32rem, calc(100vw - 2rem));
+}
+
+.options {
+ max-height: min(28rem, 60vh);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+.iconGrid {
+ grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
+}
+
+.iconOption {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ height: 3.25rem;
+ padding: var(--mantine-spacing-xs);
+ border: 1px solid var(--mantine-color-default-border);
+ border-radius: var(--mantine-radius-md);
+ background: var(--mantine-color-body);
+ transition:
+ border-color 160ms ease,
+ background-color 160ms ease,
+ transform 160ms ease;
+}
+
+.iconOption:hover,
+.iconOption[data-combobox-selected="true"] {
+ border-color: var(--mantine-primary-color-6);
+ background: var(--mantine-primary-color-light);
+}
+
+.iconOption:active {
+ transform: scale(0.96);
+}
+
+.formatBadge,
+.sectionBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--mantine-radius-xs);
+ background: var(--mantine-color-red-9);
+ color: var(--mantine-color-white);
+ font-size: 0.625rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.formatBadge {
+ position: absolute;
+ top: 0.2rem;
+ right: 0.2rem;
+ padding: 0.15rem 0.2rem;
+}
+
+.sectionBadge {
+ padding: 0.2rem 0.3rem;
+}
+
+.urlPreview {
+ background: var(--mantine-color-default-hover);
+}
+
+.previewFrame {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 3rem;
+ height: 3rem;
+ flex: 0 0 3rem;
+}
+
+.previewLoader {
+ position: absolute;
+ z-index: 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .iconOption {
+ transition: none;
+ }
}
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.spec.ts b/packages/forms-collection/src/icon-picker/icon-picker.spec.ts
index 1046b341a..87ef7a6a8 100644
--- a/packages/forms-collection/src/icon-picker/icon-picker.spec.ts
+++ b/packages/forms-collection/src/icon-picker/icon-picker.spec.ts
@@ -4,31 +4,84 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi
import { MantineProvider } from "@mantine/core";
import type { Root } from "react-dom/client";
+import type { IconGroup } from "./icon-picker.utils";
-const { findIconsQuery } = vi.hoisted(() => ({
- findIconsQuery: vi.fn((_query: { searchText: string }) => ({
- data: { icons: [], countIcons: 0 },
- isLoading: false,
- isError: false,
- refetch: vi.fn(),
- })),
+const { findIconsQuery, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({
+ findIconsQuery: vi.fn(),
+ refetch: vi.fn(),
+ uploadSelection: vi.fn(),
+ sessionPermissions: { current: [] as string[] },
}));
+interface QueryData {
+ icons: IconGroup[];
+ countIcons: number;
+}
+
+const emptyResult: QueryData = { icons: [], countIcons: 11_000 };
+let queryResult: {
+ data?: QueryData;
+ isLoading: boolean;
+ isFetching: boolean;
+ isError: boolean;
+};
+
vi.mock("@homarr/api/client", () => ({
- clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
+ clientApi: {
+ icon: {
+ findIcons: {
+ useQuery: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
+ findIconsQuery(input, options);
+ return { ...queryResult, refetch };
+ },
+ },
+ },
+ },
}));
vi.mock("@homarr/auth/client", () => ({
- useSession: () => ({ data: { user: { permissions: [] } } }),
+ useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }),
}));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({
- useScopedI18n: () => (key: string) => key,
+ useScopedI18n: () => (key: string, params?: Record) =>
+ params ? `${key}:${Object.values(params).join(",")}` : key,
+}));
+vi.mock("../upload-media/upload-media", () => ({
+ UploadMedia: ({
+ children,
+ onSuccess,
+ }: {
+ children: (props: object) => unknown;
+ onSuccess: (medias: unknown) => void;
+ }) =>
+ children({
+ loading: false,
+ onClick: () => {
+ uploadSelection();
+ onSuccess([{ id: "uploaded", url: "/api/user-medias/uploaded" }]);
+ },
+ }),
}));
-vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
import { IconPicker } from "./icon-picker";
-const directIconUrl = "https://cdn.example.com/shopify.svg";
+const directImageUrl = "https://cdn.example.com/shopify.svg";
+
+const iconGroups = [
+ {
+ id: "remote",
+ slug: "dashboard-icons",
+ icons: [
+ { id: "png", name: "shopify.png", url: "https://cdn.example.com/shopify.png" },
+ { id: "svg", name: "shopify.svg", url: "https://cdn.example.com/shopify.svg" },
+ ],
+ },
+ {
+ id: "local",
+ slug: "local",
+ icons: [{ id: "local-icon", name: "my-shop.png", url: "/api/user-medias/local-icon" }],
+ },
+];
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -37,6 +90,18 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
input.dispatchEvent(new Event("input", { bubbles: true }));
};
+const pressKey = (input: HTMLInputElement, key: string, init?: KeyboardEventInit) => {
+ input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
+};
+
+const focusInput = async (input: HTMLInputElement) => {
+ await act(async () => input.focus());
+};
+
+const advanceDebounce = async () => {
+ await act(async () => vi.advanceTimersByTimeAsync(300));
+};
+
describe("IconPicker", () => {
let container: HTMLDivElement;
let root: Root;
@@ -45,9 +110,9 @@ describe("IconPicker", () => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
- value: (query: string) => ({
+ value: (media: string) => ({
matches: false,
- media: query,
+ media,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
@@ -56,54 +121,186 @@ describe("IconPicker", () => {
dispatchEvent: () => false,
}),
});
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: () => undefined, writable: true });
});
beforeEach(() => {
+ vi.useFakeTimers();
findIconsQuery.mockClear();
+ refetch.mockClear();
+ uploadSelection.mockClear();
+ sessionPermissions.current = [];
+ queryResult = { data: emptyResult, isLoading: false, isFetching: false, isError: false };
});
afterEach(() => {
- root.unmount();
+ act(() => root.unmount());
container.remove();
+ document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
+ vi.useRealTimers();
});
- const renderPicker = async (onChange: (value: string) => void) => {
+ const renderPicker = async (
+ onChange: (value: string) => void,
+ props: Partial> = {},
+ ) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
- root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
+ root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props })));
});
const input = container.querySelector("input");
if (!input) throw new Error("Icon picker input was not rendered");
return input;
};
- test("keeps a direct URL visible without searching the icon repositories", async () => {
+ test("clears an existing value without restoring it", async () => {
const onChange = vi.fn();
- const input = await renderPicker(onChange);
+ const input = await renderPicker(onChange, { value: directImageUrl });
- await act(async () => {
- setInputValue(input, directIconUrl);
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ await act(async () => setInputValue(input, ""));
- expect(onChange).toHaveBeenCalledWith(directIconUrl);
- expect(input.value).toBe(directIconUrl);
- expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
+ expect(onChange).toHaveBeenCalledWith("");
+ expect(input.value).toBe("");
});
- test("keeps icon names in repository search mode", async () => {
- const onChange = vi.fn();
- const input = await renderPicker(onChange);
+ test.each([{ metaKey: true }, { ctrlKey: true }])(
+ "clears the entire value with a line-delete shortcut",
+ async (modifier) => {
+ const onChange = vi.fn();
+ const input = await renderPicker(onChange, { value: directImageUrl });
- await act(async () => {
- setInputValue(input, "shopify");
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ await act(async () => pressKey(input, "Backspace", modifier));
- expect(onChange).not.toHaveBeenCalled();
- expect(input.value).toBe("shopify");
+ expect(onChange).toHaveBeenCalledWith("");
+ expect(input.value).toBe("");
+ },
+ );
+
+ test("debounces repository searches", async () => {
+ const input = await renderPicker(vi.fn());
+ findIconsQuery.mockClear();
+
+ await act(async () => setInputValue(input, "shopify"));
+ expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(false);
+
+ await advanceDebounce();
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
});
+
+ test("pre-searches the suggested app name when opened", async () => {
+ const input = await renderPicker(vi.fn(), { suggestedSearch: "Jellyfin" });
+
+ await focusInput(input);
+ expect(input.value).toBe("Jellyfin");
+ await advanceDebounce();
+
+ expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "Jellyfin")).toBe(true);
+ });
+
+ test("shows local images before SVG and raster library images", async () => {
+ queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
+ const input = await renderPicker(vi.fn());
+
+ await focusInput(input);
+ const content = document.body.textContent ?? "";
+
+ expect(document.body.querySelector('[role="listbox"]')?.getAttribute("aria-label")).toBe("iconPicker.results");
+ expect(content.indexOf("iconPicker.localImages")).toBeLessThan(content.indexOf("iconPicker.svgIcons"));
+ expect(content.indexOf("iconPicker.svgIcons")).toBeLessThan(content.indexOf("iconPicker.otherImages"));
+ });
+
+ test("selects a library image with a pointer", async () => {
+ queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
+ const onChange = vi.fn();
+ const input = await renderPicker(onChange);
+ await focusInput(input);
+
+ const option = document.body.querySelector(
+ '[data-combobox-option][value="/api/user-medias/local-icon"]',
+ );
+ if (!option) throw new Error("Local image option was not rendered");
+ await act(async () => option.click());
+
+ expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
+ expect(input.value).toBe("/api/user-medias/local-icon");
+ });
+
+ test("selects a library image with the keyboard", async () => {
+ queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
+ const onChange = vi.fn();
+ const input = await renderPicker(onChange);
+ await focusInput(input);
+
+ await act(async () => pressKey(input, "ArrowDown"));
+ await act(async () => pressKey(input, "Enter"));
+
+ expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
+ });
+
+ test("keeps a direct URL visible and reports a successful preview", async () => {
+ const onChange = vi.fn();
+ const input = await renderPicker(onChange);
+
+ await act(async () => setInputValue(input, directImageUrl));
+
+ expect(onChange).toHaveBeenCalledWith(directImageUrl);
+ expect(input.value).toBe(directImageUrl);
+ expect(findIconsQuery.mock.calls.at(-1)?.[1]).toMatchObject({ enabled: false });
+ expect(document.body.textContent).toContain("iconPicker.urlLoading");
+
+ const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
+ if (!preview) throw new Error("Direct URL preview was not rendered");
+ await act(async () => preview.dispatchEvent(new Event("load")));
+ expect(document.body.textContent).toContain("iconPicker.urlReady");
+ });
+
+ test("keeps a failed direct URL editable and shows an error", async () => {
+ const input = await renderPicker(vi.fn());
+ await act(async () => setInputValue(input, "https://invalid.example/image.png"));
+
+ const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
+ if (!preview) throw new Error("Direct URL preview was not rendered");
+ await act(async () => preview.dispatchEvent(new Event("error")));
+
+ expect(input.value).toBe("https://invalid.example/image.png");
+ expect(document.body.textContent).toContain("iconPicker.urlError");
+ });
+
+ test("restores the committed value when an unfinished search is cancelled", async () => {
+ const input = await renderPicker(vi.fn(), { value: directImageUrl });
+
+ await act(async () => setInputValue(input, "unfinished search"));
+ await act(async () => pressKey(input, "Escape"));
+
+ expect(input.value).toBe(directImageUrl);
+ });
+
+ test("offers retry when the repository search fails", async () => {
+ queryResult = { data: undefined, isLoading: false, isFetching: false, isError: true };
+ const input = await renderPicker(vi.fn());
+ await focusInput(input);
+
+ const retry = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent === "action.tryAgain",
+ );
+ if (!retry) throw new Error("Retry button was not rendered");
+ await act(async () => retry.click());
+
+ expect(refetch).toHaveBeenCalledOnce();
+ });
+
+ test("selects an uploaded local image when the user has permission", async () => {
+ sessionPermissions.current = ["media-upload"];
+ const onChange = vi.fn();
+ await renderPicker(onChange);
+
+ const upload = container.querySelector('[aria-label="iconPicker.uploadImage"]');
+ if (!upload) throw new Error("Upload button was not rendered");
+ await act(async () => upload.click());
+
+ expect(uploadSelection).toHaveBeenCalledOnce();
+ expect(onChange).toHaveBeenCalledWith("/api/user-medias/uploaded");
+ });
});
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx
index e3e31c5be..b0d43f0ae 100644
--- a/packages/forms-collection/src/icon-picker/icon-picker.tsx
+++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx
@@ -1,27 +1,36 @@
"use client";
-import type { FocusEventHandler } from "react";
-import { startTransition, useEffect, useState } from "react";
+import type { FocusEventHandler, KeyboardEventHandler } from "react";
+import { startTransition, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Box,
Button,
- Card,
+ CloseButton,
Combobox,
- Flex,
Group,
Image,
- Indicator,
InputBase,
+ Loader,
Paper,
+ SimpleGrid,
Skeleton,
Stack,
Text,
- UnstyledButton,
+ ThemeIcon,
+ Tooltip,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
-import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
+import {
+ IconCheck,
+ IconCloud,
+ IconMoodEmpty,
+ IconPhoto,
+ IconPhotoOff,
+ IconSearchOff,
+ IconUpload,
+} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -29,6 +38,7 @@ import { supportedLanguages } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "../upload-media/upload-media";
+import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
import classes from "./icon-picker.module.css";
interface IconPickerProps {
@@ -39,9 +49,12 @@ interface IconPickerProps {
onBlur?: FocusEventHandler;
label?: string;
placeholder?: string;
+ suggestedSearch?: string | null;
withAsterisk?: boolean;
}
+type PreviewState = "idle" | "loading" | "ready" | "error";
+
export const IconPicker = ({
value: propsValue,
onChange,
@@ -51,188 +64,403 @@ export const IconPicker = ({
withAsterisk = true,
label,
placeholder,
+ suggestedSearch,
}: IconPickerProps) => {
- const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
- const [query, setQuery] = useState("");
-
- useEffect(() => {
- setQuery("");
- }, [value]);
-
+ const [value, setValue] = useUncontrolled({ value: propsValue, onChange, defaultValue: "" });
+ const [draft, setDraft] = useState(propsValue ?? "");
+ const [hasEdited, setHasEdited] = useState(false);
+ const [keyboardIndex, setKeyboardIndex] = useState(-1);
+ const [previewState, setPreviewState] = useState("idle");
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
- const [debouncedQuery] = useDebouncedValue(query, 100);
+ const [debouncedDraft] = useDebouncedValue(draft, 250);
- const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
+ const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
+ const isCommittedValueDraft = debouncedDraft === (value ?? "");
+ const searchText = directUrl !== null || isCommittedValueDraft ? "" : debouncedDraft.trim();
+ const canUpload = session?.user.permissions.includes("media-upload") ?? false;
- const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
- { searchText },
- { placeholderData: (prev) => prev },
+ const query = clientApi.icon.findIcons.useQuery(
+ { searchText, limitPerGroup: 24 },
+ { enabled: directUrl === null, placeholderData: (previous) => previous },
);
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
+ onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
- const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
- const groups = data?.icons.map((group) => {
- const options = group.icons.map((item) => (
-
- {
- startTransition(() => {
- setValue(item.url);
- setQuery("");
- combobox.closeDropdown();
- });
- }}
- >
-
-
-
-
-
-
-
-
-
- ));
+ useEffect(() => {
+ setDraft(value ?? "");
+ setHasEdited(false);
+ }, [value]);
- return (
-
-
- {group.slug}
-
-
- {options}
-
-
- );
- });
+ useEffect(() => {
+ setPreviewState(directUrl ? "loading" : "idle");
+ }, [directUrl]);
- const inputValue = query || value || "";
- const leftSection = shouldShowPreview(value) && ;
- const placeholderText =
- placeholder ||
- (data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
- tCommon("iconPicker.headerLoading");
+ const sections = useMemo(
+ () => arrangeIconPickerSections(query.data?.icons ?? [], searchText),
+ [query.data?.icons, searchText],
+ );
+ const orderedOptions = useMemo(
+ () => [...sections.local, ...sections.svg, ...sections.other],
+ [sections.local, sections.svg, sections.other],
+ );
+ const totalOptions = sections.local.length + sections.svg.length + sections.other.length;
- const renderDropdown = () => {
- if (isError) {
+ const commitValue = (nextValue: string) => {
+ startTransition(() => {
+ setValue(nextValue);
+ setDraft(nextValue);
+ setHasEdited(false);
+ setKeyboardIndex(-1);
+ });
+ };
+
+ const clearValue = () => {
+ commitValue("");
+ combobox.openDropdown("keyboard");
+ };
+
+ const openWithSuggestedSearch = () => {
+ if (!draft && !value && !hasEdited && suggestedSearch?.trim()) {
+ setDraft(suggestedSearch.trim());
+ }
+ combobox.openDropdown();
+ };
+
+ const handleKeyDown: KeyboardEventHandler = (event) => {
+ if ((event.metaKey || event.ctrlKey) && event.key === "Backspace") {
+ event.preventDefault();
+ clearValue();
+ return;
+ }
+
+ if (event.key === "Escape") {
+ setDraft(value ?? "");
+ setHasEdited(false);
+ combobox.closeDropdown("keyboard");
+ return;
+ }
+
+ if (!directUrl && orderedOptions.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
+ event.preventDefault();
+ combobox.openDropdown("keyboard");
+ setKeyboardIndex((current) => {
+ const next =
+ event.key === "ArrowDown"
+ ? current >= orderedOptions.length - 1
+ ? 0
+ : current + 1
+ : current <= 0
+ ? orderedOptions.length - 1
+ : current - 1;
+ combobox.selectOption(next);
+ return next;
+ });
+ return;
+ }
+
+ if (event.key === "Enter") {
+ if (keyboardIndex >= 0 && orderedOptions[keyboardIndex]) {
+ event.preventDefault();
+ commitValue(orderedOptions[keyboardIndex].url);
+ combobox.closeDropdown("keyboard");
+ } else if (isImageSource(draft)) {
+ event.preventDefault();
+ commitValue(draft.trim());
+ combobox.closeDropdown("keyboard");
+ }
+ }
+ };
+
+ const renderIconOptions = (icons: typeof sections.local, offset: number) => (
+
+ {icons.map((icon, index) => (
+
+
+
+ {isSvgImage(icon.url) && SVG }
+
+
+ ))}
+
+ );
+
+ const renderSearchResults = () => {
+ if (query.isError) {
return (
-
-
- {tCommon("iconPicker.error")}
- refetch()}>
- {tCommon("action.tryAgain")}
-
-
+ }
+ title={tCommon("iconPicker.error")}
+ action={
+ query.refetch()}>
+ {tCommon("action.tryAgain")}
+
+ }
+ />
);
}
- if (isLoading) {
- return Array(15)
- .fill(0)
- .map((_, index) => (
-
-
-
- ));
+
+ if (query.isLoading) {
+ return (
+
+ {Array.from({ length: 12 }, (_, index) => (
+
+ ))}
+
+ );
}
- if (totalOptions > 0) {
- return {groups} ;
+
+ if (totalOptions === 0) {
+ return (
+ }
+ title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
+ />
+ );
}
+
return (
-
-
- {tCommon("iconPicker.noResults", { search: debouncedQuery })}
+
+ {sections.local.length > 0 && (
+ } label={tCommon("iconPicker.localImages")}>
+ {renderIconOptions(sections.local, 0)}
+
+ )}
+ {sections.svg.length > 0 && (
+
+ {renderIconOptions(sections.svg, sections.local.length)}
+
+ )}
+ {sections.other.length > 0 && (
+ } label={tCommon("iconPicker.otherImages")}>
+ {renderIconOptions(sections.other, sections.local.length + sections.svg.length)}
+
+ )}
);
};
+ const leftSection = (() => {
+ if (directUrl && previewState === "loading") return ;
+ if (directUrl && previewState === "error") return ;
+ if (shouldShowPreview(value)) {
+ return (
+ directUrl && setPreviewState("ready")}
+ onError={() => directUrl && setPreviewState("error")}
+ />
+ );
+ }
+ return ;
+ })();
+
+ const placeholderText =
+ placeholder ??
+ (query.data
+ ? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
+ : tCommon("iconPicker.headerLoading"));
+
return (
-
-
-
- }
- leftSection={leftSection}
- value={inputValue}
- onChange={(event) => {
- const nextValue = event.currentTarget.value;
- const trimmedValue = nextValue.trim();
- setQuery(nextValue);
- if (isDirectIconUrl(trimmedValue)) {
- setValue(trimmedValue);
- combobox.closeDropdown();
- } else {
- combobox.openDropdown();
- }
- }}
- onClick={() => combobox.openDropdown()}
- onFocus={(event) => {
- onFocus?.(event);
- combobox.openDropdown();
- }}
- onBlur={(event) => {
- onBlur?.(event);
- combobox.closeDropdown();
- setQuery("");
- }}
- rightSectionPointerEvents="none"
- withAsterisk={withAsterisk}
- error={error}
- label={label ?? tCommon("iconPicker.label")}
- placeholder={placeholderText}
- />
- {session?.user.permissions.includes("media-upload") && (
- {
- const first = medias.at(0);
- if (!first) return;
- startTransition(() => {
- setValue(first.url);
- });
- }}
- >
- {({ onClick, loading }) => (
-
-
-
+ {
+ commitValue(url);
+ combobox.closeDropdown();
+ }}
+ >
+
+
+ {canUpload && (
+ {
+ const first = medias.at(0);
+ if (!first) return;
+ commitValue(first.url);
+ combobox.closeDropdown();
+ }}
+ >
+ {({ onClick, loading }) => (
+
+ {
+ event.stopPropagation();
+ onClick();
+ }}
+ loading={loading}
+ size="sm"
+ variant="subtle"
+ aria-label={tCommon("iconPicker.uploadImage")}
+ >
+
+
+
+ )}
+
)}
-
- )}
-
+ {(draft || value) && (
+ event.preventDefault()}
+ onClick={clearValue}
+ />
+ )}
+ {query.isFetching && !directUrl ? : }
+
+ }
+ rightSectionWidth={canUpload ? 86 : 56}
+ rightSectionPointerEvents="all"
+ value={draft}
+ onChange={(event) => {
+ const nextValue = event.currentTarget.value;
+ setDraft(nextValue);
+ setHasEdited(true);
+ setKeyboardIndex(-1);
+ combobox.openDropdown("keyboard");
+ combobox.updateSelectedOptionIndex();
+
+ if (!nextValue) {
+ setValue("");
+ } else if (isImageSource(nextValue)) {
+ setValue(nextValue.trim());
+ }
+ }}
+ onKeyDown={handleKeyDown}
+ onClick={openWithSuggestedSearch}
+ onFocus={(event) => {
+ onFocus?.(event);
+ openWithSuggestedSearch();
+ }}
+ onBlur={(event) => {
+ onBlur?.(event);
+ combobox.closeDropdown();
+ setDraft(value ?? "");
+ setHasEdited(false);
+ }}
+ withAsterisk={withAsterisk}
+ error={error}
+ label={label ?? tCommon("iconPicker.label")}
+ description={tCommon("iconPicker.description")}
+ styles={{
+ description: { color: "var(--mantine-color-text)" },
+ input: { color: "var(--mantine-color-text)" },
+ error: { color: "light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2))" },
+ }}
+ placeholder={placeholderText}
+ autoComplete="off"
+ spellCheck={false}
+ />
-
-
- {renderDropdown()}
+
+
+ {directUrl ? (
+
+
+
+ {previewState === "loading" && }
+ {previewState === "error" ? (
+
+
+
+ ) : (
+ setPreviewState("ready")}
+ onError={() => setPreviewState("error")}
+ />
+ )}
+
+
+
+ {previewState === "error"
+ ? tCommon("iconPicker.urlError")
+ : previewState === "ready"
+ ? tCommon("iconPicker.urlReady")
+ : tCommon("iconPicker.urlLoading")}
+
+
+ {directUrl}
+
+
+ {previewState === "ready" && (
+
+
+
+ )}
+
+
+ ) : (
+ renderSearchResults()
+ )}
);
};
-// This regex is used to prevent loading a preview like en or /en which would trigger a language change
-// See https://github.com/homarr-labs/homarr/issues/3070
+interface PickerSectionProps {
+ label: string;
+ icon?: React.ReactNode;
+ badge?: string;
+ children: React.ReactNode;
+}
+
+const PickerSection = ({ label, icon, badge, children }: PickerSectionProps) => (
+
+
+ {icon}
+
+ {label}
+
+ {badge && {badge} }
+
+ {children}
+
+);
+
+interface PickerMessageProps {
+ icon: React.ReactNode;
+ title: string;
+ action?: React.ReactNode;
+}
+
+const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
+
+
+ {icon}
+
+ {title}
+ {action}
+
+);
+
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null | undefined): value is string => {
if (!value) return false;
return !localizationPathRegex.test(value);
};
-
-const isDirectIconUrl = (value: string) => {
- try {
- const url = new URL(value.trim());
- return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
- } catch {
- return false;
- }
-};
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.utils.spec.ts b/packages/forms-collection/src/icon-picker/icon-picker.utils.spec.ts
new file mode 100644
index 000000000..562b43cba
--- /dev/null
+++ b/packages/forms-collection/src/icon-picker/icon-picker.utils.spec.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "vitest";
+
+import type { IconGroup } from "./icon-picker.utils";
+import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
+
+const groups = [
+ {
+ id: "remote",
+ slug: "dashboard-icons",
+ icons: [
+ { id: "long", name: "home-assistant-alt.svg", url: "https://cdn.example.com/home-assistant-alt.svg" },
+ { id: "raster", name: "Home Assistant.png", url: "https://cdn.example.com/home-assistant.png" },
+ { id: "svg", name: "home-assistant.svg", url: "https://cdn.example.com/home-assistant.svg?v=1" },
+ ],
+ },
+ {
+ id: "local",
+ slug: "local",
+ icons: [{ id: "local", name: "Home Assistant.png", url: "/api/user-medias/home-assistant" }],
+ },
+] as IconGroup[];
+
+describe("icon picker utilities", () => {
+ test("arranges local images before remote SVG and raster images", () => {
+ const result = arrangeIconPickerSections(groups, "home assistant");
+
+ expect(result.local.map((icon) => icon.id)).toEqual(["local"]);
+ expect(result.svg.map((icon) => icon.id)).toEqual(["svg", "long"]);
+ expect(result.other.map((icon) => icon.id)).toEqual(["raster"]);
+ });
+
+ test.each([
+ ["https://example.com/icon.png", true],
+ ["http://localhost:3000/icon", true],
+ ["ftp://example.com/icon.png", false],
+ ["javascript:alert(1)", false],
+ ["example.com/icon.png", false],
+ ])("validates direct image URLs", (value, expected) => {
+ expect(isDirectImageUrl(value)).toBe(expected);
+ });
+
+ test("accepts internal image paths without treating search text as a source", () => {
+ expect(isImageSource("/api/user-medias/123")).toBe(true);
+ expect(isImageSource("shopify")).toBe(false);
+ });
+
+ test("recognizes SVG URLs with query strings case-insensitively", () => {
+ expect(isSvgImage("https://example.com/ICON.SVG?raw=1")).toBe(true);
+ });
+});
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.utils.ts b/packages/forms-collection/src/icon-picker/icon-picker.utils.ts
new file mode 100644
index 000000000..e71558f8e
--- /dev/null
+++ b/packages/forms-collection/src/icon-picker/icon-picker.utils.ts
@@ -0,0 +1,67 @@
+import type { inferRouterOutputs } from "@trpc/server";
+
+import type { AppRouter } from "@homarr/api";
+import { getImageMatchRank, normalizeImageName } from "@homarr/common";
+
+type RouterOutput = inferRouterOutputs;
+export type IconGroup = RouterOutput["icon"]["findIcons"]["icons"][number];
+export type PickerIcon = IconGroup["icons"][number] & { repositorySlug: string };
+
+export interface IconPickerSections {
+ local: PickerIcon[];
+ svg: PickerIcon[];
+ other: PickerIcon[];
+}
+
+export const arrangeIconPickerSections = (iconGroups: IconGroup[], searchTerm = ""): IconPickerSections => {
+ const sections: IconPickerSections = { local: [], svg: [], other: [] };
+
+ for (const group of iconGroups) {
+ for (const icon of group.icons) {
+ const pickerIcon = { ...icon, repositorySlug: group.slug };
+ if (group.slug === "local") {
+ sections.local.push(pickerIcon);
+ } else if (isSvgImage(icon.url)) {
+ sections.svg.push(pickerIcon);
+ } else {
+ sections.other.push(pickerIcon);
+ }
+ }
+ }
+
+ const normalizedSearch = normalizeImageName(searchTerm);
+ if (normalizedSearch) {
+ const compareMatches = (a: PickerIcon, b: PickerIcon) => {
+ const aRank = getImageMatchRank(normalizedSearch, a.name || a.url) ?? Number.MAX_SAFE_INTEGER;
+ const bRank = getImageMatchRank(normalizedSearch, b.name || b.url) ?? Number.MAX_SAFE_INTEGER;
+ return aRank - bRank || a.name.localeCompare(b.name);
+ };
+ return {
+ local: sections.local.toSorted(compareMatches),
+ svg: sections.svg.toSorted(compareMatches),
+ other: sections.other.toSorted(compareMatches),
+ };
+ }
+
+ return sections;
+};
+
+export const isDirectImageUrl = (value: string) => {
+ try {
+ const url = new URL(value.trim());
+ return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
+ } catch {
+ return false;
+ }
+};
+
+export const isImageSource = (value: string) => isDirectImageUrl(value) || value.trim().startsWith("/");
+
+export const isSvgImage = (value: string) => {
+ try {
+ const url = new URL(value, "http://homarr.local");
+ return url.pathname.toLowerCase().endsWith(".svg");
+ } catch {
+ return value.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".svg") ?? false;
+ }
+};
diff --git a/packages/forms-collection/src/index.tsx b/packages/forms-collection/src/index.tsx
index 7621bed43..0dfaf90ca 100644
--- a/packages/forms-collection/src/index.tsx
+++ b/packages/forms-collection/src/index.tsx
@@ -2,7 +2,6 @@ export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./icon-picker/icon-picker";
-export * from "./new-app/icon-matcher";
export * from "./upload-media/upload-media";
diff --git a/packages/forms-collection/src/new-app/_form.tsx b/packages/forms-collection/src/new-app/_form.tsx
index 92f3a4c2f..40f68d68e 100644
--- a/packages/forms-collection/src/new-app/_form.tsx
+++ b/packages/forms-collection/src/new-app/_form.tsx
@@ -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;
@@ -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 = (
-
+