* fix: added `withAsterisk` property to `IconPicker`, defaulted to true so it's backwards compatible * fix: `icon-picker.tsx` formatting --------- Co-authored-by: Andre Silva <asilva01@acuitysso.com>
199 lines
5.6 KiB
TypeScript
199 lines
5.6 KiB
TypeScript
import type { FocusEventHandler } from "react";
|
|
import { startTransition } from "react";
|
|
import {
|
|
ActionIcon,
|
|
Box,
|
|
Card,
|
|
Combobox,
|
|
Flex,
|
|
Group,
|
|
Image,
|
|
Indicator,
|
|
InputBase,
|
|
Paper,
|
|
Skeleton,
|
|
Stack,
|
|
Text,
|
|
UnstyledButton,
|
|
useCombobox,
|
|
} from "@mantine/core";
|
|
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
|
import { IconUpload } from "@tabler/icons-react";
|
|
|
|
import { clientApi } from "@homarr/api/client";
|
|
import { useSession } from "@homarr/auth/client";
|
|
import { useScopedI18n } from "@homarr/translation/client";
|
|
|
|
import { UploadMedia } from "../upload-media/upload-media";
|
|
import classes from "./icon-picker.module.css";
|
|
|
|
interface IconPickerProps {
|
|
value?: string;
|
|
onChange: (iconUrl: string) => void;
|
|
error?: string | null;
|
|
onFocus?: FocusEventHandler;
|
|
onBlur?: FocusEventHandler;
|
|
withAsterisk?: boolean;
|
|
}
|
|
|
|
export const IconPicker = ({
|
|
value: propsValue,
|
|
onChange,
|
|
error,
|
|
onFocus,
|
|
onBlur,
|
|
withAsterisk = true,
|
|
}: IconPickerProps) => {
|
|
const [value, setValue] = useUncontrolled({
|
|
value: propsValue,
|
|
onChange,
|
|
});
|
|
const [search, setSearch] = useUncontrolled({
|
|
value,
|
|
onChange: (value) => {
|
|
setValue(value);
|
|
},
|
|
});
|
|
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
|
value: propsValue ?? null,
|
|
});
|
|
const { data: session } = useSession();
|
|
|
|
const tCommon = useScopedI18n("common");
|
|
|
|
const [debouncedSearch] = useDebouncedValue(search, 100);
|
|
|
|
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
|
const { data } = clientApi.icon.findIcons.useQuery({
|
|
searchText: debouncedSearch,
|
|
});
|
|
|
|
const combobox = useCombobox({
|
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
|
});
|
|
|
|
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={() => {
|
|
const value = item.url;
|
|
startTransition(() => {
|
|
setPreviewUrl(value);
|
|
setSearch(value);
|
|
setValue(value);
|
|
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}
|
|
withBorder
|
|
>
|
|
<Box w={25} h={25}>
|
|
<Image src={item.url} w={25} h={25} radius="md" />
|
|
</Box>
|
|
</Card>
|
|
</Indicator>
|
|
</UnstyledButton>
|
|
</Combobox.Option>
|
|
));
|
|
|
|
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>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<Combobox store={combobox} withinPortal>
|
|
<Combobox.Target>
|
|
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
|
<InputBase
|
|
flex={1}
|
|
rightSection={<Combobox.Chevron />}
|
|
leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
|
|
value={search}
|
|
onChange={(event) => {
|
|
combobox.openDropdown();
|
|
combobox.updateSelectedOptionIndex();
|
|
setSearch(event.currentTarget.value);
|
|
setValue(event.currentTarget.value);
|
|
setPreviewUrl(null);
|
|
}}
|
|
onClick={() => combobox.openDropdown()}
|
|
onFocus={(event) => {
|
|
onFocus?.(event);
|
|
combobox.openDropdown();
|
|
}}
|
|
onBlur={(event) => {
|
|
onBlur?.(event);
|
|
combobox.closeDropdown();
|
|
setPreviewUrl(value);
|
|
setSearch(value || "");
|
|
}}
|
|
rightSectionPointerEvents="none"
|
|
withAsterisk={withAsterisk}
|
|
error={error}
|
|
label={tCommon("iconPicker.label")}
|
|
placeholder={tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
|
/>
|
|
{session?.user.permissions.includes("media-upload") && (
|
|
<UploadMedia
|
|
onSuccess={({ url }) => {
|
|
startTransition(() => {
|
|
setValue(url);
|
|
setPreviewUrl(url);
|
|
setSearch(url);
|
|
});
|
|
}}
|
|
>
|
|
{({ onClick, loading }) => (
|
|
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
|
<IconUpload size={16} stroke={1.5} />
|
|
</ActionIcon>
|
|
)}
|
|
</UploadMedia>
|
|
)}
|
|
</Group>
|
|
</Combobox.Target>
|
|
|
|
<Combobox.Dropdown>
|
|
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
|
{totalOptions > 0 ? (
|
|
<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.Dropdown>
|
|
</Combobox>
|
|
);
|
|
};
|