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

+18 -9
View File
@@ -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.
+1 -1
View File
@@ -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.
![Icon URL](./img/app-icon-url.png)
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
<IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</Stack>
</Fieldset>
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
</Grid.Col>
</Grid>
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
+4 -1
View File
@@ -45,7 +45,10 @@ describe("Custom JSX custom widgets", () => {
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;
+34 -31
View File
@@ -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),
};
}),
@@ -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",
},
]);
},
);
});
+31
View File
@@ -28,3 +28,34 @@ export const bestMatch = <TItem extends Record<string, unknown>>(
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;
};
+25 -1
View File
@@ -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);
});
});
@@ -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;
}
}
@@ -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<string, string>) =>
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<React.ComponentProps<typeof IconPicker>> = {},
) => {
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<HTMLElement>(
'[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<HTMLButtonElement>('[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");
});
});
@@ -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<PreviewState>("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) => (
<Combobox.Option
key={item.id}
value={item.url}
p={0}
h="calc(2*var(--mantine-spacing-sm)+25px)"
w="calc(2*var(--mantine-spacing-sm)+25px)"
>
<UnstyledButton
onClick={() => {
startTransition(() => {
setValue(item.url);
setQuery("");
combobox.closeDropdown();
});
}}
>
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
<Box w={25} h={25}>
<Image src={item.url} w={25} h={25} />
</Box>
</Card>
</Indicator>
</UnstyledButton>
</Combobox.Option>
));
useEffect(() => {
setDraft(value ?? "");
setHasEdited(false);
}, [value]);
return (
<Paper p="xs" key={group.slug} pt={2}>
<Text mb={8} size="sm" fw="bold">
{group.slug}
</Text>
<Flex gap={8} wrap={"wrap"}>
{options}
</Flex>
</Paper>
);
});
useEffect(() => {
setPreviewState(directUrl ? "loading" : "idle");
}, [directUrl]);
const inputValue = query || value || "";
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
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<HTMLInputElement> = (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) => (
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6}>
{icons.map((icon, index) => (
<Tooltip key={icon.id} label={`${icon.name} · ${icon.repositorySlug}`} openDelay={500}>
<Combobox.Option
value={icon.url}
aria-label={icon.name}
active={icon.url === value || keyboardIndex === offset + index}
className={classes.iconOption}
>
<Image src={icon.url} alt="" w={30} h={30} fit="contain" fallbackSrc="/logo/logo.png" />
{isSvgImage(icon.url) && <span className={classes.formatBadge}>SVG</span>}
</Combobox.Option>
</Tooltip>
))}
</SimpleGrid>
);
const renderSearchResults = () => {
if (query.isError) {
return (
<Stack align="center" gap="xs" p="md">
<IconSearchOff size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.error")}</Text>
<Button size="xs" variant="default" onClick={() => refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Stack>
<PickerMessage
icon={<IconSearchOff size={22} />}
title={tCommon("iconPicker.error")}
action={
<Button size="xs" variant="default" onClick={() => query.refetch()}>
{tCommon("action.tryAgain")}
</Button>
}
/>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
if (query.isLoading) {
return (
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6} p="xs">
{Array.from({ length: 12 }, (_, index) => (
<Skeleton key={index} height={52} radius="md" />
))}
</SimpleGrid>
);
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
if (totalOptions === 0) {
return (
<PickerMessage
icon={<IconMoodEmpty size={22} />}
title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
/>
);
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
<Stack gap="sm" p="xs">
{sections.local.length > 0 && (
<PickerSection icon={<IconPhoto size={15} />} label={tCommon("iconPicker.localImages")}>
{renderIconOptions(sections.local, 0)}
</PickerSection>
)}
{sections.svg.length > 0 && (
<PickerSection label={tCommon("iconPicker.svgIcons")} badge="SVG">
{renderIconOptions(sections.svg, sections.local.length)}
</PickerSection>
)}
{sections.other.length > 0 && (
<PickerSection icon={<IconCloud size={15} />} label={tCommon("iconPicker.otherImages")}>
{renderIconOptions(sections.other, sections.local.length + sections.svg.length)}
</PickerSection>
)}
</Stack>
);
};
const leftSection = (() => {
if (directUrl && previewState === "loading") return <Loader size={15} />;
if (directUrl && previewState === "error") return <IconPhotoOff size={17} color="var(--mantine-color-red-6)" />;
if (shouldShowPreview(value)) {
return (
<Image
src={value}
alt=""
w={22}
h={22}
fit="contain"
onLoad={() => directUrl && setPreviewState("ready")}
onError={() => directUrl && setPreviewState("error")}
/>
);
}
return <IconPhoto size={17} stroke={1.5} />;
})();
const placeholderText =
placeholder ??
(query.data
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
: tCommon("iconPicker.headerLoading"));
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
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") && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
setValue(first.url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
<Combobox
store={combobox}
withinPortal
resetSelectionOnOptionHover
onOptionSubmit={(url) => {
commitValue(url);
combobox.closeDropdown();
}}
>
<Combobox.Target withExpandedAttribute>
<InputBase
flex={1}
leftSection={leftSection}
rightSection={
<Group gap={2} wrap="nowrap">
{canUpload && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
commitValue(first.url);
combobox.closeDropdown();
}}
>
{({ onClick, loading }) => (
<Tooltip label={tCommon("iconPicker.uploadImage")}>
<ActionIcon
onClick={(event) => {
event.stopPropagation();
onClick();
}}
loading={loading}
size="sm"
variant="subtle"
aria-label={tCommon("iconPicker.uploadImage")}
>
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
)}
</UploadMedia>
)}
</UploadMedia>
)}
</Group>
{(draft || value) && (
<CloseButton
size="sm"
aria-label={tCommon("iconPicker.clear")}
onMouseDown={(event) => event.preventDefault()}
onClick={clearValue}
/>
)}
{query.isFetching && !directUrl ? <Loader size={14} /> : <Combobox.Chevron />}
</Group>
}
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}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{renderDropdown()}
<Combobox.Dropdown className={classes.dropdown}>
<Combobox.Options className={classes.options} aria-label={tCommon("iconPicker.results")}>
{directUrl ? (
<Paper p="sm" m="xs" className={classes.urlPreview}>
<Group wrap="nowrap" align="center">
<Box className={classes.previewFrame}>
{previewState === "loading" && <Loader size={18} className={classes.previewLoader} />}
{previewState === "error" ? (
<ThemeIcon variant="light" color="red" size={40}>
<IconPhotoOff size={21} />
</ThemeIcon>
) : (
<Image
src={directUrl}
alt=""
w={40}
h={40}
fit="contain"
data-testid="direct-url-preview"
onLoad={() => setPreviewState("ready")}
onError={() => setPreviewState("error")}
/>
)}
</Box>
<Box flex={1} miw={0}>
<Text size="sm" fw={600}>
{previewState === "error"
? tCommon("iconPicker.urlError")
: previewState === "ready"
? tCommon("iconPicker.urlReady")
: tCommon("iconPicker.urlLoading")}
</Text>
<Text size="xs" truncate>
{directUrl}
</Text>
</Box>
{previewState === "ready" && (
<ThemeIcon variant="light" color="green" size="sm" aria-hidden>
<IconCheck size={14} />
</ThemeIcon>
)}
</Group>
</Paper>
) : (
renderSearchResults()
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
// 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) => (
<Stack gap={6}>
<Group gap={6} px={2}>
{icon}
<Text size="xs" fw={600}>
{label}
</Text>
{badge && <span className={classes.sectionBadge}>{badge}</span>}
</Group>
{children}
</Stack>
);
interface PickerMessageProps {
icon: React.ReactNode;
title: string;
action?: React.ReactNode;
}
const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
<Stack align="center" gap="xs" p="lg" ta="center">
<ThemeIcon variant="light" color="gray" size="lg">
{icon}
</ThemeIcon>
<Text size="sm">{title}</Text>
{action}
</Stack>
);
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;
}
};
@@ -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);
});
});
@@ -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<AppRouter>;
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;
}
};
-1
View File
@@ -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";
@@ -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;
};
+12 -2
View File
@@ -1441,8 +1441,18 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
"headerLoading": "Type to search icons…",
"description": "Search the library, paste an image URL, or upload a file.",
"header": "Search {countIcons} images by name",
"headerLoading": "Search images by name",
"clear": "Clear image",
"uploadImage": "Upload image",
"results": "Image search results",
"localImages": "Your images",
"svgIcons": "SVG icons",
"otherImages": "Other images",
"urlLoading": "Checking image…",
"urlReady": "Image is ready to use",
"urlError": "This image could not be loaded. Check the URL and try again.",
"noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons."
},
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import {
Accordion,
ActionIcon,
@@ -21,7 +21,6 @@ import {
Tooltip,
} from "@mantine/core";
import type { FormErrors } from "@mantine/form";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconAlertTriangleFilled,
IconBrandDocker,
@@ -48,7 +47,7 @@ import {
normalizeReleaseProviderIdentifier,
releaseProviderKinds,
} from "@homarr/definitions";
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
import { IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -417,13 +416,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
// 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);
@@ -448,25 +440,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
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 (
<Stack>
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
@@ -498,8 +471,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
identifier: event.currentTarget.value,
name,
});
if (event.currentTarget.value) setAutoSetIcon(true);
}}
error={formErrors[`${innerProps.fieldPath}.identifier`]}
style={{ flex: 0.7 }}
@@ -520,11 +491,7 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<TextInput
label={tRepository("name.label")}
value={tempRepository.name ?? ""}
onChange={(event) => {
handleChange({ name: event.currentTarget.value });
if (event.currentTarget.value) setAutoSetIcon(true);
}}
onChange={(event) => handleChange({ name: event.currentTarget.value })}
error={formErrors[`${innerProps.fieldPath}.name`]}
style={{ flex: 0.3 }}
/>
@@ -533,9 +500,9 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<IconPicker
withAsterisk={false}
value={tempRepository.iconUrl ?? ""}
suggestedSearch={tempRepository.name || tempRepository.identifier}
onChange={(url) => {
if (url === "") {
setAutoSetIcon(false);
handleChange({ iconUrl: undefined });
} else {
handleChange({ iconUrl: url });