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 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 { useForm } from '@mantine/form'; | ||||
| import { closeModal, ContextModalProps } from '@mantine/modals'; | ||||
| import { ContextModalProps } from '@mantine/modals'; | ||||
| import { hideNotification, showNotification } from '@mantine/notifications'; | ||||
| import { | ||||
|   IconAccessPoint, | ||||
|   IconAdjustments, | ||||
| @@ -12,7 +12,7 @@ import { | ||||
|   IconPlug, | ||||
| } from '@tabler/icons'; | ||||
| import { useTranslation } from 'next-i18next'; | ||||
| import { hideNotification, showNotification } from '@mantine/notifications'; | ||||
| import Image from 'next/image'; | ||||
| import { ServiceType } from '../../../../types/service'; | ||||
| import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab'; | ||||
| 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 { IconPhoto } from '@tabler/icons'; | ||||
| import { useTranslation } from 'next-i18next'; | ||||
| import { ServiceType } from '../../../../../../types/service'; | ||||
| import { IconSelector } from './IconSelector/IconSelector'; | ||||
|  | ||||
| interface AppearanceTabProps { | ||||
|   form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>; | ||||
| @@ -24,20 +25,35 @@ export const AppearanceTab = ({ form }: AppearanceTabProps) => { | ||||
|  | ||||
|   return ( | ||||
|     <Tabs.Panel value="appearance" pt="lg"> | ||||
|       <TextInput | ||||
|         icon={<Image />} | ||||
|         label="Service Icon" | ||||
|         variant="default" | ||||
|         defaultValue={form.values.appearance.iconUrl} | ||||
|         {...form.getInputProps('appearance.iconUrl')} | ||||
|         withAsterisk | ||||
|         required | ||||
|       /> | ||||
|       <Flex gap={5}> | ||||
|         <TextInput | ||||
|           defaultValue={form.values.appearance.iconUrl} | ||||
|           className={classes.textInput} | ||||
|           icon={<Image />} | ||||
|           label="Service Icon" | ||||
|           variant="default" | ||||
|           withAsterisk | ||||
|           required | ||||
|           {...form.getInputProps('appearance.iconUrl')} | ||||
|         /> | ||||
|         <IconSelector | ||||
|           onChange={(item) => | ||||
|             form.setValues({ | ||||
|               appearance: { | ||||
|                 iconUrl: item.url, | ||||
|               }, | ||||
|             }) | ||||
|           } | ||||
|         /> | ||||
|       </Flex> | ||||
|     </Tabs.Panel> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const useStyles = createStyles(() => ({ | ||||
|   textInput: { | ||||
|     flexGrow: 1, | ||||
|   }, | ||||
|   iconImage: { | ||||
|     objectFit: 'contain', | ||||
|     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" | ||||
|         variant="default" | ||||
|         mb="md" | ||||
|         {...form.getInputProps('onClickUrl')} | ||||
|         {...form.getInputProps('behaviour.onClickUrl')} | ||||
|       /> | ||||
|  | ||||
|       <Switch | ||||
|         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')} | ||||
|       /> | ||||
|       <Switch label="Open in new tab" {...form.getInputProps('behaviour.isOpeningNewTab')} /> | ||||
|     </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