♻️ Refactor icon picker (#724)

This commit is contained in:
Manuel
2023-02-20 22:11:30 +01:00
committed by GitHub
parent 2c1b329dfd
commit f5686fbf2c
17 changed files with 441 additions and 258 deletions

View File

@@ -39,7 +39,15 @@
"appearance": { "appearance": {
"icon": { "icon": {
"label": "App Icon", "label": "App Icon",
"description": "The icon that will be displayed on the dashboard." "description": "Choose a an icon to be displayed on your dashboard. Choose from {{suggestionsCount}} icons or enter your own URL",
"autocomplete": {
"title": "No results found",
"text": "Try to use a more specific search term. If you can't find your desired icon, paste the image URL above for a custom icon"
},
"noItems": {
"title": "Loading external icons",
"text": "This may take a few seconds"
}
} }
}, },
"integration": { "integration": {

View File

@@ -1,7 +0,0 @@
{
"iconPicker": {
"textInputPlaceholder": "Search something...",
"searchLimitationTitle": "Limited to 30 results",
"searchLimitationMessage": "Search results were limited to 30 because there were too many matches"
}
}

View File

@@ -1,10 +1,10 @@
import { Autocomplete, createStyles, Flex, Tabs } from '@mantine/core'; import { Flex, Tabs } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form'; import { UseFormReturnType } from '@mantine/form';
import { useQuery } from '@tanstack/react-query'; import { useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'next-i18next'; import { useEffect } from 'react';
import { useGetDashboardIcons } from '../../../../../../hooks/icons/useGetDashboardIcons';
import { AppType } from '../../../../../../types/app'; import { AppType } from '../../../../../../types/app';
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon'; import { IconSelector } from './IconSelector';
import { IconSelector } from './IconSelector/IconSelector';
interface AppearanceTabProps { interface AppearanceTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>; form: UseFormReturnType<AppType, (values: AppType) => AppType>;
@@ -17,46 +17,39 @@ export const AppearanceTab = ({
disallowAppNameProgagation, disallowAppNameProgagation,
allowAppNamePropagation, allowAppNamePropagation,
}: AppearanceTabProps) => { }: AppearanceTabProps) => {
const { t } = useTranslation('layout/modals/add-app'); const { data, isLoading } = useGetDashboardIcons();
const { classes } = useStyles();
const { isLoading, error, data } = useQuery({ const [debouncedValue] = useDebouncedValue(form.values.name, 500);
queryKey: ['autocompleteLocale'],
queryFn: () => fetch('/api/getLocalImages').then((res) => res.json()), 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 ( return (
<Tabs.Panel value="appearance" pt="lg"> <Tabs.Panel value="appearance" pt="lg">
<Flex gap={5}> <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 <IconSelector
onChange={(item) => {
form.setValues({
appearance: {
iconUrl: item.url,
},
});
disallowAppNameProgagation();
}}
allowAppNamePropagation={allowAppNamePropagation}
form={form} form={form}
data={data}
isLoading={isLoading}
allowAppNamePropagation={allowAppNamePropagation}
disallowAppNameProgagation={disallowAppNameProgagation}
/> />
</Flex> </Flex>
</Tabs.Panel> </Tabs.Panel>
); );
}; };
const useStyles = createStyles(() => ({ const replaceCharacters = (value: string) => value.toLowerCase().replaceAll('', '-');
textInput: {
flexGrow: 1,
},
}));

View File

@@ -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;
}

View File

@@ -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',
},
}));

View File

@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { NormalizedIconRepositoryResult } from '../../tools/server/images/abstract-icons-repository';
export const useGetDashboardIcons = () =>
useQuery({
queryKey: ['repository-icons'],
queryFn: async () => {
const response = await fetch('/api/icons/');
const data = await response.json();
return data as NormalizedIconRepositoryResult[];
},
refetchOnMount: false,
refetchOnWindowFocus: false,
});

View File

@@ -1,27 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { IconSelectorItem } from '../types/iconSelector/iconSelectorItem';
export const useRepositoryIconsQuery = <TRepositoryIcon extends object>({
url,
converter,
}: {
url: string;
converter: (value: TRepositoryIcon) => IconSelectorItem;
}) =>
useQuery({
queryKey: ['repository-icons', { url }],
queryFn: async () => fetchRepositoryIcons<TRepositoryIcon>(url),
select(data) {
return data.map((x) => converter(x));
},
refetchOnWindowFocus: false,
});
const fetchRepositoryIcons = async <TRepositoryIcon extends object>(
url: string
): Promise<TRepositoryIcon[]> => {
const response = await fetch(
'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png'
);
return response.json();
};

View File

@@ -1,27 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import fs from 'fs';
function Get(req: NextApiRequest, res: NextApiResponse) {
// Get the name of all the files in the /public/icons folder handle if the folder doesn't exist
if (!fs.existsSync('./public/icons')) {
return res.status(200).json({
files: [],
});
}
const files = fs.readdirSync('./public/icons');
// Return the list of files with the /public/icons prefix
return res.status(200).json({
files: files.map((file) => `/icons/${file}`),
});
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -0,0 +1,27 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { JsdelivrIconsRepository } from '../../../tools/server/images/jsdelivr-icons-repository';
import { LocalIconsRepository } from '../../../tools/server/images/local-icons-repository';
import { UnpkgIconsRepository } from '../../../tools/server/images/unpkg-icons-repository';
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const respositories = [
new LocalIconsRepository(),
new JsdelivrIconsRepository(JsdelivrIconsRepository.tablerRepository, 'Walkxcode Dashboard Icons', 'Walkxcode on Github'),
new UnpkgIconsRepository(UnpkgIconsRepository.tablerRepository, 'Tabler Icons', 'Tabler Icons - GitHub (MIT)'),
new JsdelivrIconsRepository(JsdelivrIconsRepository.papirusRepository, 'Papirus Icons', 'Papirus Development Team on GitHub (Apache 2.0)'),
new JsdelivrIconsRepository(JsdelivrIconsRepository.homelabSvgAssetsRepository, 'Homelab Svg Assets', 'loganmarchione on GitHub (MIT)'),
];
const fetches = respositories.map((rep) => rep.fetch());
const data = await Promise.all(fetches);
return response.status(200).json(data);
};
export default async (request: NextApiRequest, response: NextApiResponse) => {
if (request.method === 'GET') {
return Get(request, response);
}
return response.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -1,10 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
export default async (req: NextApiRequest, res: NextApiResponse) => {
const url = decodeURIComponent(req.query.url as string);
const result = await fetch(url);
const body = await result.body;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
body.pipe(res);
};

View File

@@ -0,0 +1,32 @@
export abstract class AbstractIconRepository {
constructor(readonly copyright?: string) {}
async fetch(): Promise<NormalizedIconRepositoryResult> {
try {
return await this.fetchInternally();
} catch (err) {
return {
success: false,
count: 0,
entries: [],
name: '',
copyright: this.copyright,
};
}
}
protected abstract fetchInternally(): Promise<NormalizedIconRepositoryResult>;
}
export type NormalizedIconRepositoryResult = {
name: string;
success: boolean;
count: number;
copyright: string | undefined;
entries: NormalizedIcon[];
};
export type NormalizedIcon = {
url: string;
name: string;
size: number;
};

View File

@@ -0,0 +1,71 @@
import {
AbstractIconRepository,
NormalizedIcon,
NormalizedIconRepositoryResult,
} from './abstract-icons-repository';
export class JsdelivrIconsRepository extends AbstractIconRepository {
static readonly tablerRepository = {
api: 'https://data.jsdelivr.com/v1/packages/gh/walkxcode/dashboard-icons@main?structure=flat',
blob: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}/{1}',
} as JsdelivrRepositoryUrl;
static readonly papirusRepository = {
api: 'https://data.jsdelivr.com/v1/packages/gh/PapirusDevelopmentTeam/papirus_icons@master?structure=flat',
blob: 'https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons/src/{1}',
} as JsdelivrRepositoryUrl;
static readonly homelabSvgAssetsRepository = {
api: 'https://data.jsdelivr.com/v1/packages/gh/loganmarchione/homelab-svg-assets@main?structure=flat',
blob: 'https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/{1}',
} as JsdelivrRepositoryUrl;
constructor(
private readonly repository: JsdelivrRepositoryUrl,
private readonly displayName: string,
copyright: string,
) {
super(copyright);
}
protected async fetchInternally(): Promise<NormalizedIconRepositoryResult> {
const response = await fetch(this.repository.api);
const body = (await response.json()) as JsdelivrResponse;
const normalizedEntries = body.files
.filter((file) => !['_banner.png', '_logo.png'].some((x) => file.name.includes(x)))
.filter((file) => ['.png', '.svg'].some((x) => file.name.endsWith(x)))
.map((file): NormalizedIcon => {
const fileNameParts = file.name.split('/');
const fileName = fileNameParts[fileNameParts.length - 1];
const extensions = fileName.split('.')[1];
return {
url: this.repository.blob.replace('{0}', extensions).replace('{1}', fileName),
name: fileName,
size: file.size,
};
});
return {
entries: normalizedEntries,
count: normalizedEntries.length,
success: true,
name: this.displayName,
copyright: this.copyright,
};
}
}
type JsdelivrRepositoryUrl = {
api: string;
blob: string;
};
type JsdelivrResponse = {
files: JsdelivrFile[];
};
type JsdelivrFile = {
name: string;
size: number;
};

View File

@@ -0,0 +1,44 @@
import fs from 'fs';
import {
AbstractIconRepository,
NormalizedIcon,
NormalizedIconRepositoryResult,
} from './abstract-icons-repository';
export class LocalIconsRepository extends AbstractIconRepository {
constructor() {
super('');
}
protected async fetchInternally(): Promise<NormalizedIconRepositoryResult> {
if (!fs.existsSync('./public/icons')) {
return {
count: 0,
entries: [],
name: 'Local',
success: true,
copyright: this.copyright,
};
}
const files = fs.readdirSync('./public/icons');
const normalizedEntries = files
.filter((file) => ['.png', '.svg', '.jpeg', '.jpg'].some((x) => file.endsWith(x)))
.map(
(file): NormalizedIcon => ({
name: file,
url: `./icons/${file}`,
size: 0,
})
);
return {
entries: normalizedEntries,
count: normalizedEntries.length,
success: true,
name: 'Local',
copyright: this.copyright,
};
}
}

View File

@@ -0,0 +1,52 @@
import {
AbstractIconRepository,
NormalizedIcon,
NormalizedIconRepositoryResult,
} from './abstract-icons-repository';
export class UnpkgIconsRepository extends AbstractIconRepository {
static tablerRepository = 'https://unpkg.com/@tabler/icons-png@2.0.0-beta/icons/';
constructor(
private readonly repository: string,
private readonly displayName: string,
copyright: string
) {
super(copyright);
}
protected async fetchInternally(): Promise<NormalizedIconRepositoryResult> {
const response = await fetch(`${this.repository}?meta`);
const body = (await response.json()) as UnpkgResponse;
const normalizedEntries = body.files
.filter((file) => file.type === 'file')
.map((file): NormalizedIcon => {
const fileName = file.path.replace('/icons/', '');
const url = `${this.repository}${fileName}`;
return {
name: fileName,
url,
size: file.size,
};
});
return {
entries: normalizedEntries,
count: normalizedEntries.length,
success: true,
name: this.displayName,
copyright: this.copyright,
};
}
}
type UnpkgResponse = {
files: UnpkgFile[];
};
type UnpkgFile = {
path: string;
type: string;
size: number;
};

View File

@@ -3,7 +3,6 @@ export const dashboardNamespaces = [
'layout/element-selector/selector', 'layout/element-selector/selector',
'layout/modals/add-app', 'layout/modals/add-app',
'layout/modals/change-position', 'layout/modals/change-position',
'layout/modals/icon-picker',
'layout/modals/about', 'layout/modals/about',
'layout/header/actions/toggle-edit-mode', 'layout/header/actions/toggle-edit-mode',
'layout/mobile/drawer', 'layout/mobile/drawer',

View File

@@ -1,4 +0,0 @@
export interface IconSelectorItem {
url: string;
fileName: string;
}

View File

@@ -1,3 +0,0 @@
export interface WalkxcodeRepositoryIcon {
name: string;
}