diff --git a/public/locales/en/layout/modals/add-app.json b/public/locales/en/layout/modals/add-app.json index d0343a93b..52f98b525 100644 --- a/public/locales/en/layout/modals/add-app.json +++ b/public/locales/en/layout/modals/add-app.json @@ -39,7 +39,15 @@ "appearance": { "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": { diff --git a/public/locales/en/layout/modals/icon-picker.json b/public/locales/en/layout/modals/icon-picker.json deleted file mode 100644 index 84f17ce54..000000000 --- a/public/locales/en/layout/modals/icon-picker.json +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx index d8795d25f..04aba2e63 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx @@ -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>; @@ -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 ( - } - label={t('appearance.icon.label')} - description={t('appearance.icon.description')} - variant="default" - data={data?.files ?? []} - withAsterisk - required - {...form.getInputProps('appearance.iconUrl')} - /> { - form.setValues({ - appearance: { - iconUrl: item.url, - }, - }); - disallowAppNameProgagation(); - }} - allowAppNamePropagation={allowAppNamePropagation} form={form} + data={data} + isLoading={isLoading} + allowAppNamePropagation={allowAppNamePropagation} + disallowAppNameProgagation={disallowAppNameProgagation} /> ); }; -const useStyles = createStyles(() => ({ - textInput: { - flexGrow: 1, - }, -})); +const replaceCharacters = (value: string) => value.toLowerCase().replaceAll('', '-'); diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx new file mode 100644 index 000000000..0d4fd4249 --- /dev/null +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx @@ -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>; + 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 ( + + + + + {t('appearance.icon.autocomplete.title')} + + + {t('appearance.icon.autocomplete.text')} + + + } + icon={} + rightSection={ + form.values.appearance.iconUrl.length > 0 ? ( + 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) => } + required + onChange={(event) => { + if (allowAppNamePropagation) { + disallowAppNameProgagation(); + } + form.setFieldValue('appearance.iconUrl', event); + }} + value={form.values.appearance.iconUrl} + /> + {(!data || isLoading) && ( + + + + + {t('appearance.icon.noItems.title')} + + + {t('appearance.icon.noItems.text')} + + + + )} + + ); +}; + +const useStyles = createStyles(() => ({ + textInput: { + flexGrow: 1, + }, +})); + +const AutoCompleteItem = forwardRef( + ({ label, size, copyright, url, ...others }: ItemProps, ref) => ( +
+ + ({ + backgroundColor: + theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2], + borderRadius: theme.radius.md, + })} + p={2} + > + + + + {label} + + + {humanFileSize(size, false)} + + {copyright && ( + + © {copyright} + + )} + + + +
+ ) +); + +interface ItemProps extends SelectItemProps { + url: string; + group: string; + size: number; + copyright: string | undefined; +} diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector/IconSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector/IconSelector.tsx deleted file mode 100644 index ee853678a..000000000 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector/IconSelector.tsx +++ /dev/null @@ -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>; - onChange: (icon: IconSelectorItem) => void; - allowAppNamePropagation: boolean; -} - -export const IconSelector = ({ onChange, allowAppNamePropagation, form }: IconSelectorProps) => { - const { t } = useTranslation('layout/modals/icon-picker'); - - const { data, isLoading } = useRepositoryIconsQuery({ - 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(''); - 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 ; - } - - 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 ( - - - - - - - setSearchTerm(event.currentTarget.value)} - placeholder={t('iconPicker.textInputPlaceholder')} - variant="filled" - rightSection={ - setSearchTerm('')}> - - - } - /> - - - - {slicedFilteredItems.map((item) => ( - onChange(item)} size={40} p={3}> - - - ))} - - - {isTruncated && ( - - - - {t('iconPicker.searchLimitationTitle', { max: ICON_PICKER_SLICE_LIMIT })} - - - {t('iconPicker.searchLimitationMessage', { max: ICON_PICKER_SLICE_LIMIT })} - - - )} - - - - - ); -}; - -const useStyles = createStyles(() => ({ - flameIcon: { - margin: '0 auto', - }, - icon: { - width: '100%', - height: '100%', - objectFit: 'contain', - }, - actionIcon: { - alignSelf: 'end', - }, -})); diff --git a/src/hooks/icons/useGetDashboardIcons.tsx b/src/hooks/icons/useGetDashboardIcons.tsx new file mode 100644 index 000000000..854322693 --- /dev/null +++ b/src/hooks/icons/useGetDashboardIcons.tsx @@ -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, + }); diff --git a/src/hooks/useRepositoryIconsQuery.ts b/src/hooks/useRepositoryIconsQuery.ts deleted file mode 100644 index 0db02254b..000000000 --- a/src/hooks/useRepositoryIconsQuery.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { IconSelectorItem } from '../types/iconSelector/iconSelectorItem'; - -export const useRepositoryIconsQuery = ({ - url, - converter, -}: { - url: string; - converter: (value: TRepositoryIcon) => IconSelectorItem; -}) => - useQuery({ - queryKey: ['repository-icons', { url }], - queryFn: async () => fetchRepositoryIcons(url), - select(data) { - return data.map((x) => converter(x)); - }, - refetchOnWindowFocus: false, - }); - -const fetchRepositoryIcons = async ( - url: string -): Promise => { - const response = await fetch( - 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png' - ); - return response.json(); -}; diff --git a/src/pages/api/getLocalImages.ts b/src/pages/api/getLocalImages.ts deleted file mode 100644 index 94fea61c0..000000000 --- a/src/pages/api/getLocalImages.ts +++ /dev/null @@ -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', - }); -}; diff --git a/src/pages/api/icons/index.ts b/src/pages/api/icons/index.ts new file mode 100644 index 000000000..7b1c6a4f9 --- /dev/null +++ b/src/pages/api/icons/index.ts @@ -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', + }); +}; diff --git a/src/pages/api/imageproxy.ts b/src/pages/api/imageproxy.ts deleted file mode 100644 index 575960467..000000000 --- a/src/pages/api/imageproxy.ts +++ /dev/null @@ -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); -}; diff --git a/src/tools/server/images/abstract-icons-repository.ts b/src/tools/server/images/abstract-icons-repository.ts new file mode 100644 index 000000000..e6cef53a8 --- /dev/null +++ b/src/tools/server/images/abstract-icons-repository.ts @@ -0,0 +1,32 @@ +export abstract class AbstractIconRepository { + constructor(readonly copyright?: string) {} + + async fetch(): Promise { + try { + return await this.fetchInternally(); + } catch (err) { + return { + success: false, + count: 0, + entries: [], + name: '', + copyright: this.copyright, + }; + } + } + protected abstract fetchInternally(): Promise; +} + +export type NormalizedIconRepositoryResult = { + name: string; + success: boolean; + count: number; + copyright: string | undefined; + entries: NormalizedIcon[]; +}; + +export type NormalizedIcon = { + url: string; + name: string; + size: number; +}; diff --git a/src/tools/server/images/jsdelivr-icons-repository.ts b/src/tools/server/images/jsdelivr-icons-repository.ts new file mode 100644 index 000000000..734f49853 --- /dev/null +++ b/src/tools/server/images/jsdelivr-icons-repository.ts @@ -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 { + 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; +}; diff --git a/src/tools/server/images/local-icons-repository.ts b/src/tools/server/images/local-icons-repository.ts new file mode 100644 index 000000000..a19079443 --- /dev/null +++ b/src/tools/server/images/local-icons-repository.ts @@ -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 { + 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, + }; + } +} diff --git a/src/tools/server/images/unpkg-icons-repository.ts b/src/tools/server/images/unpkg-icons-repository.ts new file mode 100644 index 000000000..c57e020a4 --- /dev/null +++ b/src/tools/server/images/unpkg-icons-repository.ts @@ -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 { + 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; +}; diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 3e819398d..5fe821c3f 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -3,7 +3,6 @@ export const dashboardNamespaces = [ 'layout/element-selector/selector', 'layout/modals/add-app', 'layout/modals/change-position', - 'layout/modals/icon-picker', 'layout/modals/about', 'layout/header/actions/toggle-edit-mode', 'layout/mobile/drawer', diff --git a/src/types/iconSelector/iconSelectorItem.ts b/src/types/iconSelector/iconSelectorItem.ts deleted file mode 100644 index 4056bc21c..000000000 --- a/src/types/iconSelector/iconSelectorItem.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IconSelectorItem { - url: string; - fileName: string; -} diff --git a/src/types/iconSelector/repositories/walkxcodeIconRepository.ts b/src/types/iconSelector/repositories/walkxcodeIconRepository.ts deleted file mode 100644 index 70aab46aa..000000000 --- a/src/types/iconSelector/repositories/walkxcodeIconRepository.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface WalkxcodeRepositoryIcon { - name: string; -}