mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 06:55:51 +01:00
♻️ Refactor icon picker (#724)
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
14
src/hooks/icons/useGetDashboardIcons.tsx
Normal file
14
src/hooks/icons/useGetDashboardIcons.tsx
Normal 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,
|
||||||
|
});
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
27
src/pages/api/icons/index.ts
Normal file
27
src/pages/api/icons/index.ts
Normal 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',
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
32
src/tools/server/images/abstract-icons-repository.ts
Normal file
32
src/tools/server/images/abstract-icons-repository.ts
Normal 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;
|
||||||
|
};
|
||||||
71
src/tools/server/images/jsdelivr-icons-repository.ts
Normal file
71
src/tools/server/images/jsdelivr-icons-repository.ts
Normal 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;
|
||||||
|
};
|
||||||
44
src/tools/server/images/local-icons-repository.ts
Normal file
44
src/tools/server/images/local-icons-repository.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/tools/server/images/unpkg-icons-repository.ts
Normal file
52
src/tools/server/images/unpkg-icons-repository.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface IconSelectorItem {
|
|
||||||
url: string;
|
|
||||||
fileName: string;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export interface WalkxcodeRepositoryIcon {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user