Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29ecf748a |
No files matched your search
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.input(iconsFindSchema)
|
.input(iconsFindSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.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}%`)));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icons: await ctx.db.query.iconRepositories.findMany({
|
icons: await ctx.db.query.iconRepositories.findMany({
|
||||||
with: {
|
with: {
|
||||||
icons: {
|
icons: {
|
||||||
columns: {
|
columns: { id: true, name: true, url: true },
|
||||||
id: true,
|
where: whereCondition,
|
||||||
name: true,
|
orderBy: (table, { asc, sql }) => {
|
||||||
url: true,
|
// ponytail: SVG first, then alphabetical; with a search term,
|
||||||
|
// prepend a relevance tier (exact > prefix > substring).
|
||||||
|
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];
|
||||||
},
|
},
|
||||||
where:
|
|
||||||
(input.searchText?.length ?? 0) > 0
|
|
||||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
|
||||||
: undefined,
|
|
||||||
limit: input.limitPerGroup,
|
limit: input.limitPerGroup,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import type { FocusEventHandler } from "react";
|
import type { FocusEventHandler } from "react";
|
||||||
import { startTransition } from "react";
|
import { startTransition, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Combobox,
|
Combobox,
|
||||||
Flex,
|
Flex,
|
||||||
@@ -18,7 +21,7 @@ import {
|
|||||||
useCombobox,
|
useCombobox,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||||
import { IconUpload } from "@tabler/icons-react";
|
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -34,7 +37,6 @@ interface IconPickerProps {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onFocus?: FocusEventHandler;
|
onFocus?: FocusEventHandler;
|
||||||
onBlur?: FocusEventHandler;
|
onBlur?: FocusEventHandler;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -50,29 +52,30 @@ export const IconPicker = ({
|
|||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: IconPickerProps) => {
|
}: IconPickerProps) => {
|
||||||
const [value, setValue] = useUncontrolled({
|
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||||
value: propsValue,
|
const [query, setQuery] = useState("");
|
||||||
onChange,
|
|
||||||
});
|
// ponytail: reset the typed query when the committed value changes
|
||||||
const [search, setSearch] = useUncontrolled({
|
// externally (form reset, parent re-render) so the input reverts to the URL.
|
||||||
value,
|
useEffect(() => {
|
||||||
onChange: (value) => {
|
setQuery("");
|
||||||
setValue(value);
|
}, [value]);
|
||||||
},
|
|
||||||
});
|
|
||||||
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
|
||||||
value: propsValue ?? null,
|
|
||||||
});
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||||
|
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 100);
|
// ponytail: only search when the typed query differs from the committed
|
||||||
|
// value. On mount the input shows the existing URL (edit mode), which
|
||||||
|
// would never match the bare filenames in the icon table.
|
||||||
|
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
|
||||||
|
|
||||||
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
// ponytail: not useSuspenseQuery — an above Suspense boundary would
|
||||||
const { data } = clientApi.icon.findIcons.useQuery({
|
// trigger on every keystroke and the dropdown flash is bad UX.
|
||||||
searchText: debouncedSearch,
|
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||||
});
|
{ searchText },
|
||||||
|
{ placeholderData: (prev) => prev },
|
||||||
|
);
|
||||||
|
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
@@ -90,25 +93,15 @@ export const IconPicker = ({
|
|||||||
>
|
>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const value = item.url;
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setPreviewUrl(value);
|
setValue(item.url);
|
||||||
setSearch(value);
|
setQuery("");
|
||||||
setValue(value);
|
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||||
<Card
|
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
||||||
p="sm"
|
|
||||||
pos="relative"
|
|
||||||
style={{
|
|
||||||
overflow: "visible",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
className={classes.iconCard}
|
|
||||||
>
|
|
||||||
<Box w={25} h={25}>
|
<Box w={25} h={25}>
|
||||||
<Image src={item.url} w={25} h={25} />
|
<Image src={item.url} w={25} h={25} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -130,6 +123,48 @@ export const IconPicker = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ponytail: show the typed query if non-empty, else fall back to the URL.
|
||||||
|
const inputValue = query || value || "";
|
||||||
|
// ponytail: show the existing icon as a left-section preview.
|
||||||
|
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
||||||
|
// ponytail: prop override -> real count -> loading text.
|
||||||
|
const placeholderText =
|
||||||
|
placeholder ||
|
||||||
|
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
||||||
|
tCommon("iconPicker.headerLoading");
|
||||||
|
|
||||||
|
const renderDropdown = () => {
|
||||||
|
if (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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isLoading) {
|
||||||
|
return Array(15)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index) => (
|
||||||
|
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||||
|
<Skeleton height={25} visible />
|
||||||
|
</Combobox.Option>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (totalOptions > 0) {
|
||||||
|
return <Stack gap={4}>{groups}</Stack>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap="xs" p="md">
|
||||||
|
<IconMoodEmpty size={32} stroke={1.5} />
|
||||||
|
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox store={combobox} withinPortal>
|
<Combobox store={combobox} withinPortal>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
@@ -137,16 +172,11 @@ export const IconPicker = ({
|
|||||||
<InputBase
|
<InputBase
|
||||||
flex={1}
|
flex={1}
|
||||||
rightSection={<Combobox.Chevron />}
|
rightSection={<Combobox.Chevron />}
|
||||||
leftSection={
|
leftSection={leftSection}
|
||||||
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
|
value={inputValue}
|
||||||
}
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
combobox.openDropdown();
|
combobox.openDropdown();
|
||||||
combobox.updateSelectedOptionIndex();
|
setQuery(event.currentTarget.value);
|
||||||
setSearch(event.currentTarget.value);
|
|
||||||
setValue(event.currentTarget.value);
|
|
||||||
setPreviewUrl(null);
|
|
||||||
}}
|
}}
|
||||||
onClick={() => combobox.openDropdown()}
|
onClick={() => combobox.openDropdown()}
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
@@ -156,25 +186,21 @@ export const IconPicker = ({
|
|||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
onBlur?.(event);
|
onBlur?.(event);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
setPreviewUrl(value);
|
setQuery("");
|
||||||
setSearch(value || "");
|
|
||||||
}}
|
}}
|
||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
withAsterisk={withAsterisk}
|
withAsterisk={withAsterisk}
|
||||||
error={error}
|
error={error}
|
||||||
label={label ?? tCommon("iconPicker.label")}
|
label={label ?? tCommon("iconPicker.label")}
|
||||||
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
placeholder={placeholderText}
|
||||||
/>
|
/>
|
||||||
{session?.user.permissions.includes("media-upload") && (
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
<UploadMedia
|
<UploadMedia
|
||||||
onSuccess={(medias) => {
|
onSuccess={(medias) => {
|
||||||
const first = medias.at(0);
|
const first = medias.at(0);
|
||||||
if (!first) return;
|
if (!first) return;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(first.url);
|
setValue(first.url);
|
||||||
setPreviewUrl(first.url);
|
|
||||||
setSearch(first.url);
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -190,17 +216,7 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||||
{totalOptions > 0 ? (
|
{renderDropdown()}
|
||||||
<Stack gap={4}>{groups}</Stack>
|
|
||||||
) : (
|
|
||||||
Array(15)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, index: number) => (
|
|
||||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
|
||||||
<Skeleton height={25} visible />
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Combobox.Dropdown>
|
</Combobox.Dropdown>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
@@ -209,8 +225,8 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
// 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
|
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
|
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
||||||
const shouldShowPreview = (value: string | null): value is string => {
|
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
return !localizationPathRegex.test(value);
|
return !localizationPathRegex.test(value);
|
||||||
};
|
};
|
||||||
@@ -1412,7 +1412,10 @@
|
|||||||
"here": "here",
|
"here": "here",
|
||||||
"iconPicker": {
|
"iconPicker": {
|
||||||
"label": "Icon URL",
|
"label": "Icon URL",
|
||||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
|
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||||
|
"headerLoading": "Type to search icons…",
|
||||||
|
"noResults": "No icons match \"{search}\".",
|
||||||
|
"error": "Couldn't load icons."
|
||||||
},
|
},
|
||||||
"colorScheme": {
|
"colorScheme": {
|
||||||
"options": {
|
"options": {
|
||||||
|
|||||||
Reference in new issue
Block a user