mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-01 02:56:04 +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">
|
||||||
|
<Flex gap={5}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
defaultValue={form.values.appearance.iconUrl}
|
||||||
|
className={classes.textInput}
|
||||||
icon={<Image />}
|
icon={<Image />}
|
||||||
label="Service Icon"
|
label="Service Icon"
|
||||||
variant="default"
|
variant="default"
|
||||||
defaultValue={form.values.appearance.iconUrl}
|
|
||||||
{...form.getInputProps('appearance.iconUrl')}
|
|
||||||
withAsterisk
|
withAsterisk
|
||||||
required
|
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