From d7bec26ee21b448acba66473e685e6e6b821cc8c Mon Sep 17 00:00:00 2001 From: Manuel Ruwe Date: Mon, 5 Dec 2022 21:43:47 +0100 Subject: [PATCH 01/16] =?UTF-8?q?=E2=9C=A8=20Add=20icon=20picker=20for=20s?= =?UTF-8?q?ervice=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Meier Lukas --- data/constants.ts | 1 + .../Modals/EditService/EditServiceModal.tsx | 6 +- .../Tabs/AppereanceTab/AppereanceTab.tsx | 36 ++++-- .../IconSelector/IconSelector.tsx | 108 ++++++++++++++++++ .../Tabs/BehaviourTab/BehaviourTab.tsx | 24 +--- src/tools/hooks/useRepositoryIconsQuery.ts | 26 +++++ src/types/iconSelector/iconSelectorItem.ts | 3 + .../repositories/walkxcodeIconRepository.ts | 3 + 8 files changed, 172 insertions(+), 35 deletions(-) create mode 100644 src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx create mode 100644 src/tools/hooks/useRepositoryIconsQuery.ts create mode 100644 src/types/iconSelector/iconSelectorItem.ts create mode 100644 src/types/iconSelector/repositories/walkxcodeIconRepository.ts diff --git a/data/constants.ts b/data/constants.ts index 8ca22cd0b..1db7f949d 100644 --- a/data/constants.ts +++ b/data/constants.ts @@ -1,2 +1,3 @@ export const REPO_URL = 'ajnart/homarr'; export const CURRENT_VERSION = 'v0.10.7'; +export const ICON_PICKER_SLICE_LIMIT = 36; diff --git a/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx b/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx index ba2f86161..7eb11d9ba 100644 --- a/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx +++ b/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx @@ -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'; diff --git a/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/AppereanceTab.tsx b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/AppereanceTab.tsx index 1066a28df..048c2ed8f 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/AppereanceTab.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/AppereanceTab.tsx @@ -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>; @@ -24,20 +25,35 @@ export const AppearanceTab = ({ form }: AppearanceTabProps) => { return ( - } - label="Service Icon" - variant="default" - defaultValue={form.values.appearance.iconUrl} - {...form.getInputProps('appearance.iconUrl')} - withAsterisk - required - /> + + } + label="Service Icon" + variant="default" + withAsterisk + required + {...form.getInputProps('appearance.iconUrl')} + /> + + form.setValues({ + appearance: { + iconUrl: item.url, + }, + }) + } + /> + ); }; const useStyles = createStyles(() => ({ + textInput: { + flexGrow: 1, + }, iconImage: { objectFit: 'contain', width: 20, diff --git a/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx new file mode 100644 index 000000000..e6cbc82e9 --- /dev/null +++ b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx @@ -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({ + 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(''); + const { classes } = useStyles(); + + if (isLoading || !data) { + return ; + } + + 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 ( + + + + + + + + + setSearchTerm(event.currentTarget.value)} + placeholder="Search for icons..." + variant="filled" + rightSection={ + setSearchTerm('')}> + + + } + /> + + + + {slicedFilteredItems.map((item) => ( + onChange(item)} size={40} p={3}> + icon from repository + + ))} + + + {isTruncated && ( + + + + + Search is limited to {ICON_PICKER_SLICE_LIMIT} icons + + + To keep things snappy and fast, the search is limited to {ICON_PICKER_SLICE_LIMIT}{' '} + icons. Use the search box to find more icons. + + + )} + + + + + ); +}; + +const useStyles = createStyles(() => ({ + flameIcon: { + margin: '0 auto', + }, + icon: { + width: '100%', + height: '100%', + objectFit: 'contain', + }, + actionIcon: { + alignSelf: 'end', + }, +})); diff --git a/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx b/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx index 839c0fe41..19e39aed0 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx @@ -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')} /> - - Disables the direct movement of the tile - - } - mb="md" - {...form.getInputProps('isEditModeMovingDisabled')} - /> - - Disables the movement of the tile when moving others - - } - {...form.getInputProps('isEditModeTileFreezed')} - /> + ); }; diff --git a/src/tools/hooks/useRepositoryIconsQuery.ts b/src/tools/hooks/useRepositoryIconsQuery.ts new file mode 100644 index 000000000..e08e096ad --- /dev/null +++ b/src/tools/hooks/useRepositoryIconsQuery.ts @@ -0,0 +1,26 @@ +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/types/iconSelector/iconSelectorItem.ts b/src/types/iconSelector/iconSelectorItem.ts new file mode 100644 index 000000000..6a38b1850 --- /dev/null +++ b/src/types/iconSelector/iconSelectorItem.ts @@ -0,0 +1,3 @@ +export interface IconSelectorItem { + url: string; +} diff --git a/src/types/iconSelector/repositories/walkxcodeIconRepository.ts b/src/types/iconSelector/repositories/walkxcodeIconRepository.ts new file mode 100644 index 000000000..70aab46aa --- /dev/null +++ b/src/types/iconSelector/repositories/walkxcodeIconRepository.ts @@ -0,0 +1,3 @@ +export interface WalkxcodeRepositoryIcon { + name: string; +} From b28547777f3879a7eabb759e5e9a317eb62ac912 Mon Sep 17 00:00:00 2001 From: Manuel Ruwe Date: Tue, 6 Dec 2022 20:48:35 +0100 Subject: [PATCH 02/16] feat: improve design in edit service modal --- .../Modals/EditService/EditServiceModal.tsx | 85 ++++++++++++++----- .../Tabs/AppereanceTab/AppereanceTab.tsx | 1 + .../IconSelector/IconSelector.tsx | 21 +++-- .../Tabs/BehaviourTab/BehaviourTab.tsx | 8 +- .../Tabs/GeneralTab/GeneralTab.tsx | 22 ++++- .../InputElements/IntegrationSelector.tsx | 6 +- .../TextExplanation/TextExplanation.tsx | 10 --- .../Tabs/NetworkTab/NetworkTab.tsx | 2 + .../Dashboard/Modals/EditService/Tabs/type.ts | 1 + .../Overview/AvailableElementsOverview.tsx | 3 +- src/types/service.ts | 2 +- 11 files changed, 112 insertions(+), 49 deletions(-) delete mode 100644 src/components/Dashboard/Modals/EditService/Tabs/IntegrationTab/Components/TextExplanation/TextExplanation.tsx create mode 100644 src/components/Dashboard/Modals/EditService/Tabs/type.ts diff --git a/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx b/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx index 7eb11d9ba..2edea1680 100644 --- a/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx +++ b/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx @@ -1,24 +1,23 @@ -import { Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core'; +import { Alert, Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core'; import { useForm } from '@mantine/form'; import { ContextModalProps } from '@mantine/modals'; import { hideNotification, showNotification } from '@mantine/notifications'; -import { - IconAccessPoint, - IconAdjustments, - IconBrush, - IconClick, - IconDeviceFloppy, - IconDoorExit, - IconPlug, -} from '@tabler/icons'; +import { IconAccessPoint, IconAdjustments, IconBrush, IconClick, IconPlug } from '@tabler/icons'; import { useTranslation } from 'next-i18next'; import Image from 'next/image'; +import { useState } from 'react'; +import { useConfigContext } from '../../../../config/provider'; +import { useConfigStore } from '../../../../config/store'; import { ServiceType } from '../../../../types/service'; import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab'; import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab'; import { GeneralTab } from './Tabs/GeneralTab/GeneralTab'; import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab'; import { NetworkTab } from './Tabs/NetworkTab/NetworkTab'; +import { EditServiceModalTab } from './Tabs/type'; + +const serviceUrlRegex = + '(https?://(?:www.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|www.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|https?://(?:www.|(?!www))[a-zA-Z0-9]+.[^\\s]{2,}|www.[a-zA-Z0-9]+.[^\\s]{2,})'; export const EditServiceModal = ({ context, @@ -27,16 +26,55 @@ export const EditServiceModal = ({ }: ContextModalProps<{ service: ServiceType }>) => { const { t } = useTranslation(); const { classes } = useStyles(); + const { name: configName, config } = useConfigContext(); + const updateConfig = useConfigStore((store) => store.updateConfig); const form = useForm({ initialValues: innerProps.service, + validate: { + name: (name) => (!name ? 'Name is required' : null), + url: (url) => { + if (!url) { + return 'Url is required'; + } + + if (!url.match(serviceUrlRegex)) { + return 'Value is not a valid url'; + } + + return null; + }, + appearance: (appearance) => (!appearance.iconUrl ? 'Icon is required' : null), + behaviour: (behaviour) => { + if (behaviour.onClickUrl === undefined || behaviour.onClickUrl.length < 1) { + return null; + } + + if (!behaviour.onClickUrl?.match(serviceUrlRegex)) { + return 'Uri override is not a valid uri'; + } + + return null; + }, + }, + validateInputOnChange: true, }); const onSubmit = (values: ServiceType) => { - console.log('form submitted'); console.log(values); + + if (!configName) { + return; + } + + updateConfig(configName, (previousConfig) => ({ + ...previousConfig, + services: [...previousConfig.services.filter((x) => x.id !== form.values.id), form.values], + })); }; + const [activeTab, setActiveTab] = useState('general'); + const tryCloseModal = () => { if (form.isDirty()) { showNotification({ @@ -65,6 +103,13 @@ export const EditServiceModal = ({ return ( <> + {configName === undefined || + (config === undefined && ( + + There was an unexpected problem loading the configuration. Functionality might be + restricted. Please report this incident. + + ))} {form.values.appearance.iconUrl ? ( // disabled because image target is too dynamic for next image cache @@ -85,7 +130,11 @@ export const EditServiceModal = ({
- + setActiveTab(tab as EditServiceModalTab)} + defaultValue="general" + > }> General @@ -104,7 +153,7 @@ export const EditServiceModal = ({ - + setActiveTab(targetTab)} /> @@ -112,16 +161,10 @@ export const EditServiceModal = ({ - - diff --git a/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/AppereanceTab.tsx b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/AppereanceTab.tsx index 048c2ed8f..c7c496d0e 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/AppereanceTab.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/AppereanceTab.tsx @@ -31,6 +31,7 @@ export const AppearanceTab = ({ form }: AppearanceTabProps) => { className={classes.textInput} icon={} label="Service Icon" + description="Logo of your service displayed in your dashboard. Must return a body content containg an image" variant="default" withAsterisk required diff --git a/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx index e6cbc82e9..bac318863 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx @@ -1,6 +1,7 @@ /* eslint-disable @next/next/no-img-element */ import { ActionIcon, + Button, createStyles, Divider, Flex, @@ -12,7 +13,7 @@ import { TextInput, Title, } from '@mantine/core'; -import { IconFlame, IconSearch, IconX } from '@tabler/icons'; +import { IconSearch, IconX } from '@tabler/icons'; import { useState } from 'react'; import { ICON_PICKER_SLICE_LIMIT } from '../../../../../../../../data/constants'; import { useRepositoryIconsQuery } from '../../../../../../../tools/hooks/useRepositoryIconsQuery'; @@ -38,7 +39,12 @@ export const IconSelector = ({ onChange }: IconSelectorProps) => { return ; } - const filteredItems = searchTerm ? data.filter((x) => x.url.includes(searchTerm)) : data; + const replaceCharacters = (value: string) => + value.toLowerCase().replaceAll(' ', '').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; @@ -46,9 +52,13 @@ export const IconSelector = ({ onChange }: IconSelectorProps) => { return ( - - - + @@ -76,7 +86,6 @@ export const IconSelector = ({ onChange }: IconSelectorProps) => { {isTruncated && ( - Search is limited to {ICON_PICKER_SLICE_LIMIT} icons diff --git a/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx b/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx index 19e39aed0..4c760ee06 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx @@ -15,13 +15,17 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => { } label="On click url" - placeholder="Override the default service url when clicking on the service" + description="Overrides the service URL when clicking on the service" + placeholder="URL that should be opened instead when clicking on the service" variant="default" mb="md" {...form.getInputProps('behaviour.onClickUrl')} /> - + ); }; diff --git a/src/components/Dashboard/Modals/EditService/Tabs/GeneralTab/GeneralTab.tsx b/src/components/Dashboard/Modals/EditService/Tabs/GeneralTab/GeneralTab.tsx index 4971058c5..69e819403 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/GeneralTab/GeneralTab.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/GeneralTab/GeneralTab.tsx @@ -1,32 +1,46 @@ -import { Tabs, TextInput } from '@mantine/core'; +import { Group, Tabs, Text, TextInput } from '@mantine/core'; import { UseFormReturnType } from '@mantine/form'; import { IconCursorText, IconLink } from '@tabler/icons'; import { useTranslation } from 'next-i18next'; import { ServiceType } from '../../../../../../types/service'; +import { EditServiceModalTab } from '../type'; interface GeneralTabProps { form: UseFormReturnType ServiceType>; + openTab: (tab: EditServiceModalTab) => void; } -export const GeneralTab = ({ form }: GeneralTabProps) => { +export const GeneralTab = ({ form, openTab }: GeneralTabProps) => { const { t } = useTranslation(''); return ( } label="Service name" + description="Used for displaying the service on the dashboard" placeholder="My example service" variant="default" mb="md" - required + withAsterisk {...form.getInputProps('name')} /> } label="Service url" + description={ + + + URL that will be opened when clicking on the service. Can be overwritten using + + openTab('behaviour')} variant="link"> + on click URL + + when using external URLs to enhance security. + + } placeholder="https://google.com" variant="default" - required + withAsterisk {...form.getInputProps('url')} /> diff --git a/src/components/Dashboard/Modals/EditService/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditService/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index 0e2176d07..4fa58bd6c 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -62,11 +62,9 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => { return ( <> - - - -