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:
1 parent
742bde1d27
commit
ff2cdecc86
19 files changed
+1005
-331
No files matched your search
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user