mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 07:25:48 +01:00
♻️ Refactor icon picker (#724)
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { Autocomplete, createStyles, Flex, Tabs } from '@mantine/core';
|
||||
import { Flex, Tabs } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useEffect } from 'react';
|
||||
import { useGetDashboardIcons } from '../../../../../../hooks/icons/useGetDashboardIcons';
|
||||
import { AppType } from '../../../../../../types/app';
|
||||
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon';
|
||||
import { IconSelector } from './IconSelector/IconSelector';
|
||||
import { IconSelector } from './IconSelector';
|
||||
|
||||
interface AppearanceTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
@@ -17,46 +17,39 @@ export const AppearanceTab = ({
|
||||
disallowAppNameProgagation,
|
||||
allowAppNamePropagation,
|
||||
}: AppearanceTabProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const { classes } = useStyles();
|
||||
const { isLoading, error, data } = useQuery({
|
||||
queryKey: ['autocompleteLocale'],
|
||||
queryFn: () => fetch('/api/getLocalImages').then((res) => res.json()),
|
||||
});
|
||||
const { data, isLoading } = useGetDashboardIcons();
|
||||
|
||||
const [debouncedValue] = useDebouncedValue(form.values.name, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowAppNamePropagation !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingDebouncedIcon = data
|
||||
?.flatMap((x) => x.entries)
|
||||
.find((x) => replaceCharacters(x.name.split('.')[0]) === replaceCharacters(debouncedValue));
|
||||
|
||||
if (!matchingDebouncedIcon) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldValue('appearance.iconUrl', matchingDebouncedIcon.url);
|
||||
}, [debouncedValue]);
|
||||
|
||||
return (
|
||||
<Tabs.Panel value="appearance" pt="lg">
|
||||
<Flex gap={5}>
|
||||
<Autocomplete
|
||||
className={classes.textInput}
|
||||
icon={<DebouncedAppIcon form={form} width={20} height={20} />}
|
||||
label={t('appearance.icon.label')}
|
||||
description={t('appearance.icon.description')}
|
||||
variant="default"
|
||||
data={data?.files ?? []}
|
||||
withAsterisk
|
||||
required
|
||||
{...form.getInputProps('appearance.iconUrl')}
|
||||
/>
|
||||
<IconSelector
|
||||
onChange={(item) => {
|
||||
form.setValues({
|
||||
appearance: {
|
||||
iconUrl: item.url,
|
||||
},
|
||||
});
|
||||
disallowAppNameProgagation();
|
||||
}}
|
||||
allowAppNamePropagation={allowAppNamePropagation}
|
||||
form={form}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
allowAppNamePropagation={allowAppNamePropagation}
|
||||
disallowAppNameProgagation={disallowAppNameProgagation}
|
||||
/>
|
||||
</Flex>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
textInput: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
}));
|
||||
const replaceCharacters = (value: string) => value.toLowerCase().replaceAll('', '-');
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
CloseButton,
|
||||
createStyles,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
SelectItemProps,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { IconSearch } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { forwardRef } from 'react';
|
||||
import { humanFileSize } from '../../../../../../tools/humanFileSize';
|
||||
import { NormalizedIconRepositoryResult } from '../../../../../../tools/server/images/abstract-icons-repository';
|
||||
import { AppType } from '../../../../../../types/app';
|
||||
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon';
|
||||
|
||||
interface IconSelectorProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
data: NormalizedIconRepositoryResult[] | undefined;
|
||||
isLoading: boolean;
|
||||
disallowAppNameProgagation: () => void;
|
||||
allowAppNamePropagation: boolean;
|
||||
}
|
||||
|
||||
export const IconSelector = ({
|
||||
form,
|
||||
data,
|
||||
isLoading,
|
||||
allowAppNamePropagation,
|
||||
disallowAppNameProgagation,
|
||||
}: IconSelectorProps) => {
|
||||
const { t } = useTranslation('layout/modals/add-app');
|
||||
const { classes } = useStyles();
|
||||
|
||||
const a =
|
||||
data === undefined
|
||||
? []
|
||||
: data.flatMap((repository) =>
|
||||
repository.entries.map((entry) => ({
|
||||
url: entry.url,
|
||||
label: entry.name,
|
||||
size: entry.size,
|
||||
value: entry.url,
|
||||
group: repository.name,
|
||||
copyright: repository.copyright,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack w="100%">
|
||||
<Autocomplete
|
||||
nothingFound={
|
||||
<Stack align="center" spacing="xs" my="lg">
|
||||
<IconSearch />
|
||||
<Title order={6} align="center">
|
||||
{t('appearance.icon.autocomplete.title')}
|
||||
</Title>
|
||||
<Text align="center" maw={350}>
|
||||
{t('appearance.icon.autocomplete.text')}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
icon={<DebouncedAppIcon form={form} width={20} height={20} />}
|
||||
rightSection={
|
||||
form.values.appearance.iconUrl.length > 0 ? (
|
||||
<CloseButton onClick={() => form.setFieldValue('appearance.iconUrl', '')} />
|
||||
) : null
|
||||
}
|
||||
itemComponent={AutoCompleteItem}
|
||||
className={classes.textInput}
|
||||
data={a}
|
||||
limit={25}
|
||||
label={t('appearance.icon.label')}
|
||||
description={t('appearance.icon.description', {
|
||||
suggestionsCount: data?.reduce((a, b) => a + b.count, 0) ?? 0,
|
||||
})}
|
||||
filter={(search, item) =>
|
||||
item.value
|
||||
.toLowerCase()
|
||||
.replaceAll('_', '')
|
||||
.replaceAll(' ', '-')
|
||||
.includes(search.toLowerCase().replaceAll('_', '').replaceAll(' ', '-'))
|
||||
}
|
||||
variant="default"
|
||||
withAsterisk
|
||||
dropdownComponent={(props: any) => <ScrollArea {...props} mah={400} />}
|
||||
required
|
||||
onChange={(event) => {
|
||||
if (allowAppNamePropagation) {
|
||||
disallowAppNameProgagation();
|
||||
}
|
||||
form.setFieldValue('appearance.iconUrl', event);
|
||||
}}
|
||||
value={form.values.appearance.iconUrl}
|
||||
/>
|
||||
{(!data || isLoading) && (
|
||||
<Group>
|
||||
<Loader variant="oval" size="sm" />
|
||||
<Stack spacing={0}>
|
||||
<Text size="xs" weight="bold">
|
||||
{t('appearance.icon.noItems.title')}
|
||||
</Text>
|
||||
<Text color="dimmed" size="xs">
|
||||
{t('appearance.icon.noItems.text')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
textInput: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
const AutoCompleteItem = forwardRef<HTMLDivElement, ItemProps>(
|
||||
({ label, size, copyright, url, ...others }: ItemProps, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2],
|
||||
borderRadius: theme.radius.md,
|
||||
})}
|
||||
p={2}
|
||||
>
|
||||
<Image src={url} width={30} height={30} fit="contain" />
|
||||
</Box>
|
||||
<Stack spacing={0}>
|
||||
<Text>{label}</Text>
|
||||
<Group>
|
||||
<Text color="dimmed" size="xs">
|
||||
{humanFileSize(size, false)}
|
||||
</Text>
|
||||
{copyright && (
|
||||
<Text color="dimmed" size="xs">
|
||||
© {copyright}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
interface ItemProps extends SelectItemProps {
|
||||
url: string;
|
||||
group: string;
|
||||
size: number;
|
||||
copyright: string | undefined;
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
createStyles,
|
||||
Divider,
|
||||
Flex,
|
||||
Loader,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconSearch, IconX } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ICON_PICKER_SLICE_LIMIT } from '../../../../../../../../data/constants';
|
||||
import { IconSelectorItem } from '../../../../../../../types/iconSelector/iconSelectorItem';
|
||||
import { WalkxcodeRepositoryIcon } from '../../../../../../../types/iconSelector/repositories/walkxcodeIconRepository';
|
||||
import { AppType } from '../../../../../../../types/app';
|
||||
import { useRepositoryIconsQuery } from '../../../../../../../hooks/useRepositoryIconsQuery';
|
||||
|
||||
interface IconSelectorProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
onChange: (icon: IconSelectorItem) => void;
|
||||
allowAppNamePropagation: boolean;
|
||||
}
|
||||
|
||||
export const IconSelector = ({ onChange, allowAppNamePropagation, form }: IconSelectorProps) => {
|
||||
const { t } = useTranslation('layout/modals/icon-picker');
|
||||
|
||||
const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({
|
||||
url: 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png',
|
||||
converter: (item) => ({
|
||||
url: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/${item.name}`,
|
||||
fileName: item.name,
|
||||
}),
|
||||
});
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const { classes } = useStyles();
|
||||
|
||||
const [debouncedValue] = useDebouncedValue(form.values.name, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowAppNamePropagation !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingDebouncedIcon = data?.find(
|
||||
(x) => replaceCharacters(x.fileName.split('.')[0]) === replaceCharacters(debouncedValue)
|
||||
);
|
||||
|
||||
if (!matchingDebouncedIcon) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldValue('appearance.iconUrl', matchingDebouncedIcon.url);
|
||||
}, [debouncedValue]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const replaceCharacters = (value: string) => value.toLowerCase().replaceAll('', '-');
|
||||
|
||||
const filteredItems = searchTerm
|
||||
? data.filter((x) => replaceCharacters(x.url).includes(replaceCharacters(searchTerm)))
|
||||
: data;
|
||||
const slicedFilteredItems = filteredItems.slice(0, ICON_PICKER_SLICE_LIMIT);
|
||||
const isTruncated =
|
||||
slicedFilteredItems.length > 0 && slicedFilteredItems.length !== filteredItems.length;
|
||||
|
||||
return (
|
||||
<Popover width={310}>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
className={classes.actionIcon}
|
||||
variant="default"
|
||||
leftIcon={<IconSearch size={20} />}
|
||||
>
|
||||
Icon Picker
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack pt={4}>
|
||||
<TextInput
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.currentTarget.value)}
|
||||
placeholder={t('iconPicker.textInputPlaceholder')}
|
||||
variant="filled"
|
||||
rightSection={
|
||||
<ActionIcon onClick={() => setSearchTerm('')}>
|
||||
<IconX opacity={0.5} size={20} strokeWidth={2} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollArea style={{ height: 250 }} type="always">
|
||||
<Flex gap={4} wrap="wrap" pr={15}>
|
||||
{slicedFilteredItems.map((item) => (
|
||||
<ActionIcon key={item.url} onClick={() => onChange(item)} size={40} p={3}>
|
||||
<img className={classes.icon} src={item.url} alt="" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
{isTruncated && (
|
||||
<Stack spacing="xs" pr={15}>
|
||||
<Divider mt={35} mx="xl" />
|
||||
<Title order={6} color="dimmed" align="center">
|
||||
{t('iconPicker.searchLimitationTitle', { max: ICON_PICKER_SLICE_LIMIT })}
|
||||
</Title>
|
||||
<Text color="dimmed" align="center" size="sm">
|
||||
{t('iconPicker.searchLimitationMessage', { max: ICON_PICKER_SLICE_LIMIT })}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
flameIcon: {
|
||||
margin: '0 auto',
|
||||
},
|
||||
icon: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
actionIcon: {
|
||||
alignSelf: 'end',
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user