mirror of
				https://github.com/ajnart/homarr.git
				synced 2025-10-31 10:36:02 +01:00 
			
		
		
		
	✨ Add icon picker for service icon
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
		| @@ -1,2 +1,3 @@ | |||||||
| export const REPO_URL = 'ajnart/homarr'; | export const REPO_URL = 'ajnart/homarr'; | ||||||
| export const CURRENT_VERSION = 'v0.10.7'; | export const CURRENT_VERSION = 'v0.10.7'; | ||||||
|  | export const ICON_PICKER_SLICE_LIMIT = 36; | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import Image from 'next/image'; |  | ||||||
| import { Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core'; | import { Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core'; | ||||||
| import { useForm } from '@mantine/form'; | import { useForm } from '@mantine/form'; | ||||||
| import { closeModal, ContextModalProps } from '@mantine/modals'; | import { ContextModalProps } from '@mantine/modals'; | ||||||
|  | import { hideNotification, showNotification } from '@mantine/notifications'; | ||||||
| import { | import { | ||||||
|   IconAccessPoint, |   IconAccessPoint, | ||||||
|   IconAdjustments, |   IconAdjustments, | ||||||
| @@ -12,7 +12,7 @@ import { | |||||||
|   IconPlug, |   IconPlug, | ||||||
| } from '@tabler/icons'; | } from '@tabler/icons'; | ||||||
| import { useTranslation } from 'next-i18next'; | import { useTranslation } from 'next-i18next'; | ||||||
| import { hideNotification, showNotification } from '@mantine/notifications'; | import Image from 'next/image'; | ||||||
| import { ServiceType } from '../../../../types/service'; | import { ServiceType } from '../../../../types/service'; | ||||||
| import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab'; | import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab'; | ||||||
| import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab'; | import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab'; | ||||||
|   | |||||||
| @@ -1,8 +1,9 @@ | |||||||
| import { Tabs, TextInput, createStyles } from '@mantine/core'; | import { createStyles, Flex, Tabs, TextInput } from '@mantine/core'; | ||||||
| import { UseFormReturnType } from '@mantine/form'; | import { UseFormReturnType } from '@mantine/form'; | ||||||
| import { IconPhoto } from '@tabler/icons'; | import { IconPhoto } from '@tabler/icons'; | ||||||
| import { useTranslation } from 'next-i18next'; | import { useTranslation } from 'next-i18next'; | ||||||
| import { ServiceType } from '../../../../../../types/service'; | import { ServiceType } from '../../../../../../types/service'; | ||||||
|  | import { IconSelector } from './IconSelector/IconSelector'; | ||||||
|  |  | ||||||
| interface AppearanceTabProps { | interface AppearanceTabProps { | ||||||
|   form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>; |   form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>; | ||||||
| @@ -24,20 +25,35 @@ export const AppearanceTab = ({ form }: AppearanceTabProps) => { | |||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Tabs.Panel value="appearance" pt="lg"> |     <Tabs.Panel value="appearance" pt="lg"> | ||||||
|       <TextInput |       <Flex gap={5}> | ||||||
|         icon={<Image />} |         <TextInput | ||||||
|         label="Service Icon" |           defaultValue={form.values.appearance.iconUrl} | ||||||
|         variant="default" |           className={classes.textInput} | ||||||
|         defaultValue={form.values.appearance.iconUrl} |           icon={<Image />} | ||||||
|         {...form.getInputProps('appearance.iconUrl')} |           label="Service Icon" | ||||||
|         withAsterisk |           variant="default" | ||||||
|         required |           withAsterisk | ||||||
|       /> |           required | ||||||
|  |           {...form.getInputProps('appearance.iconUrl')} | ||||||
|  |         /> | ||||||
|  |         <IconSelector | ||||||
|  |           onChange={(item) => | ||||||
|  |             form.setValues({ | ||||||
|  |               appearance: { | ||||||
|  |                 iconUrl: item.url, | ||||||
|  |               }, | ||||||
|  |             }) | ||||||
|  |           } | ||||||
|  |         /> | ||||||
|  |       </Flex> | ||||||
|     </Tabs.Panel> |     </Tabs.Panel> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const useStyles = createStyles(() => ({ | const useStyles = createStyles(() => ({ | ||||||
|  |   textInput: { | ||||||
|  |     flexGrow: 1, | ||||||
|  |   }, | ||||||
|   iconImage: { |   iconImage: { | ||||||
|     objectFit: 'contain', |     objectFit: 'contain', | ||||||
|     width: 20, |     width: 20, | ||||||
|   | |||||||
| @@ -0,0 +1,108 @@ | |||||||
|  | /* eslint-disable @next/next/no-img-element */ | ||||||
|  | import { | ||||||
|  |   ActionIcon, | ||||||
|  |   createStyles, | ||||||
|  |   Divider, | ||||||
|  |   Flex, | ||||||
|  |   Loader, | ||||||
|  |   Popover, | ||||||
|  |   ScrollArea, | ||||||
|  |   Stack, | ||||||
|  |   Text, | ||||||
|  |   TextInput, | ||||||
|  |   Title, | ||||||
|  | } from '@mantine/core'; | ||||||
|  | import { IconFlame, IconSearch, IconX } from '@tabler/icons'; | ||||||
|  | import { useState } from 'react'; | ||||||
|  | import { ICON_PICKER_SLICE_LIMIT } from '../../../../../../../../data/constants'; | ||||||
|  | import { useRepositoryIconsQuery } from '../../../../../../../tools/hooks/useRepositoryIconsQuery'; | ||||||
|  | import { IconSelectorItem } from '../../../../../../../types/iconSelector/iconSelectorItem'; | ||||||
|  | import { WalkxcodeRepositoryIcon } from '../../../../../../../types/iconSelector/repositories/walkxcodeIconRepository'; | ||||||
|  |  | ||||||
|  | interface IconSelectorProps { | ||||||
|  |   onChange: (icon: IconSelectorItem) => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const IconSelector = ({ onChange }: IconSelectorProps) => { | ||||||
|  |   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/png/${item.name}`, | ||||||
|  |     }), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const [searchTerm, setSearchTerm] = useState<string>(''); | ||||||
|  |   const { classes } = useStyles(); | ||||||
|  |  | ||||||
|  |   if (isLoading || !data) { | ||||||
|  |     return <Loader />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const filteredItems = searchTerm ? data.filter((x) => x.url.includes(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> | ||||||
|  |         <ActionIcon className={classes.actionIcon} size={36} variant="default"> | ||||||
|  |           <IconSearch size={20} /> | ||||||
|  |         </ActionIcon> | ||||||
|  |       </Popover.Target> | ||||||
|  |       <Popover.Dropdown> | ||||||
|  |         <Stack pt={4}> | ||||||
|  |           <TextInput | ||||||
|  |             value={searchTerm} | ||||||
|  |             onChange={(event) => setSearchTerm(event.currentTarget.value)} | ||||||
|  |             placeholder="Search for icons..." | ||||||
|  |             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 onClick={() => onChange(item)} size={40} p={3}> | ||||||
|  |                   <img className={classes.icon} src={item.url} alt="icon from repository" /> | ||||||
|  |                 </ActionIcon> | ||||||
|  |               ))} | ||||||
|  |             </Flex> | ||||||
|  |  | ||||||
|  |             {isTruncated && ( | ||||||
|  |               <Stack spacing="xs" pr={15}> | ||||||
|  |                 <Divider mt={35} mx="xl" /> | ||||||
|  |                 <IconFlame className={classes.flameIcon} size={40} opacity={0.6} strokeWidth={1} /> | ||||||
|  |                 <Title order={6} color="dimmed" align="center"> | ||||||
|  |                   Search is limited to {ICON_PICKER_SLICE_LIMIT} icons | ||||||
|  |                 </Title> | ||||||
|  |                 <Text color="dimmed" align="center" size="sm"> | ||||||
|  |                   To keep things snappy and fast, the search is limited to {ICON_PICKER_SLICE_LIMIT}{' '} | ||||||
|  |                   icons. Use the search box to find more icons. | ||||||
|  |                 </Text> | ||||||
|  |               </Stack> | ||||||
|  |             )} | ||||||
|  |           </ScrollArea> | ||||||
|  |         </Stack> | ||||||
|  |       </Popover.Dropdown> | ||||||
|  |     </Popover> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const useStyles = createStyles(() => ({ | ||||||
|  |   flameIcon: { | ||||||
|  |     margin: '0 auto', | ||||||
|  |   }, | ||||||
|  |   icon: { | ||||||
|  |     width: '100%', | ||||||
|  |     height: '100%', | ||||||
|  |     objectFit: 'contain', | ||||||
|  |   }, | ||||||
|  |   actionIcon: { | ||||||
|  |     alignSelf: 'end', | ||||||
|  |   }, | ||||||
|  | })); | ||||||
| @@ -18,30 +18,10 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => { | |||||||
|         placeholder="Override the default service url when clicking on the service" |         placeholder="Override the default service url when clicking on the service" | ||||||
|         variant="default" |         variant="default" | ||||||
|         mb="md" |         mb="md" | ||||||
|         {...form.getInputProps('onClickUrl')} |         {...form.getInputProps('behaviour.onClickUrl')} | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
|       <Switch |       <Switch label="Open in new tab" {...form.getInputProps('behaviour.isOpeningNewTab')} /> | ||||||
|         value="disable_handle" |  | ||||||
|         label="Disable direct moving in edit modus" |  | ||||||
|         description={ |  | ||||||
|           <Text color="dimmed" size="sm"> |  | ||||||
|             Disables the direct movement of the tile |  | ||||||
|           </Text> |  | ||||||
|         } |  | ||||||
|         mb="md" |  | ||||||
|         {...form.getInputProps('isEditModeMovingDisabled')} |  | ||||||
|       /> |  | ||||||
|       <Switch |  | ||||||
|         value="freze" |  | ||||||
|         label="Freeze tile within edit modus" |  | ||||||
|         description={ |  | ||||||
|           <Text color="dimmed" size="sm"> |  | ||||||
|             Disables the movement of the tile when moving others |  | ||||||
|           </Text> |  | ||||||
|         } |  | ||||||
|         {...form.getInputProps('isEditModeTileFreezed')} |  | ||||||
|       /> |  | ||||||
|     </Tabs.Panel> |     </Tabs.Panel> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
							
								
								
									
										26
									
								
								src/tools/hooks/useRepositoryIconsQuery.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/tools/hooks/useRepositoryIconsQuery.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | 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(); | ||||||
|  | }; | ||||||
							
								
								
									
										3
									
								
								src/types/iconSelector/iconSelectorItem.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/types/iconSelector/iconSelectorItem.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | export interface IconSelectorItem { | ||||||
|  |   url: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | export interface WalkxcodeRepositoryIcon { | ||||||
|  |   name: string; | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user