diff --git a/data/constants.ts b/data/constants.ts index 8ca22cd0b..2dfb47db2 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 CURRENT_VERSION = 'v0.11'; +export const ICON_PICKER_SLICE_LIMIT = 36; diff --git a/package.json b/package.json index 0cc6099e9..dec99fed3 100644 --- a/package.json +++ b/package.json @@ -32,16 +32,16 @@ "@dnd-kit/utilities": "^3.2.0", "@emotion/react": "^11.10.5", "@emotion/server": "^11.10.0", - "@mantine/carousel": "^5.9.0", - "@mantine/core": "^5.9.0", - "@mantine/dates": "^5.9.0", - "@mantine/dropzone": "^5.9.0", - "@mantine/form": "^5.9.0", - "@mantine/hooks": "^5.9.0", - "@mantine/modals": "^5.9.0", - "@mantine/next": "^5.9.0", - "@mantine/notifications": "^5.9.0", - "@mantine/prism": "^5.9.0", + "@mantine/carousel": "^5.9.3", + "@mantine/core": "^5.9.3", + "@mantine/dates": "^5.9.3", + "@mantine/dropzone": "^5.9.3", + "@mantine/form": "^5.9.3", + "@mantine/hooks": "^5.9.3", + "@mantine/modals": "^5.9.3", + "@mantine/next": "^5.9.3", + "@mantine/notifications": "^5.9.3", + "@mantine/prism": "^5.9.3", "@nivo/core": "^0.79.0", "@nivo/line": "^0.79.1", "@tabler/icons": "^1.106.0", diff --git a/public/locales/en/layout/header/actions/toggle-edit-mode.json b/public/locales/en/layout/header/actions/toggle-edit-mode.json new file mode 100644 index 000000000..3ae95e24f --- /dev/null +++ b/public/locales/en/layout/header/actions/toggle-edit-mode.json @@ -0,0 +1,11 @@ +{ + "tooltip": "The edit mode enables you to configure your dashboard", + "button": { + "disabled": "Enter Edit Mode", + "enabled": "Exit and Save" + }, + "popover": { + "title": "Edit mode is enabled", + "text": "You can adjust and configure your apps now. Changes are not saved until you exit edit mode" + } +} \ No newline at end of file diff --git a/src/components/About/AboutModal.tsx b/src/components/About/AboutModal.tsx index bd51bddc0..add258043 100644 --- a/src/components/About/AboutModal.tsx +++ b/src/components/About/AboutModal.tsx @@ -1,4 +1,3 @@ -import Image from 'next/image'; import { ActionIcon, Badge, @@ -18,7 +17,9 @@ import { IconVocabulary, IconWorldWww, } from '@tabler/icons'; +import { InitOptions } from 'i18next'; import { i18n } from 'next-i18next'; +import Image from 'next/image'; import { ReactNode } from 'react'; import { CURRENT_VERSION } from '../../../data/constants'; import { usePrimaryGradient } from '../layout/useGradient'; @@ -118,13 +119,44 @@ interface InformationTableItem { content: ReactNode; } +interface ExtendedInitOptions extends InitOptions { + locales: string[]; +} + const useInformationTableItems = (): InformationTableItem[] => { const colorGradiant = usePrimaryGradient(); - const usedI18nNamespaces = i18n?.reportNamespaces?.getUsedNamespaces(); - const configuredi18nLocales: string[] = i18n?.options.locales; + let items: InformationTableItem[] = []; - return [ + if (i18n !== null) { + const usedI18nNamespaces = i18n.reportNamespaces.getUsedNamespaces(); + const initOptions = i18n.options as ExtendedInitOptions; + + items = [ + ...items, + { + icon: , + label: 'Loaded I18n translation namespaces', + content: ( + + {usedI18nNamespaces.length} + + ), + }, + { + icon: , + label: 'Configured I18n locales', + content: ( + + {initOptions.locales.length} + + ), + }, + ]; + } + + items = [ + ...items, { icon: , label: 'Homarr version', @@ -134,25 +166,9 @@ const useInformationTableItems = (): InformationTableItem[] => { ), }, - { - icon: , - label: 'Loaded I18n translation namespaces', - content: ( - - {usedI18nNamespaces?.length ?? 'loading'} - - ), - }, - { - icon: , - label: 'Configured I18n locales', - content: ( - - {configuredi18nLocales?.length ?? 'loading'} - - ), - }, ]; + + return items; }; const useStyles = createStyles(() => ({ diff --git a/src/components/AppShelf/AddAppShelfItem.tsx b/src/components/AppShelf/AddAppShelfItem.tsx deleted file mode 100644 index 3b89d1c85..000000000 --- a/src/components/AppShelf/AddAppShelfItem.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import { - ActionIcon, - Anchor, - Button, - Center, - Group, - Image, - LoadingOverlay, - Modal, - PasswordInput, - Select, - Space, - Stack, - Switch, - Tabs, - TextInput, - Title, - Tooltip, -} from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useDebouncedValue } from '@mantine/hooks'; -import { IconApps } from '@tabler/icons'; -import { useTranslation } from 'next-i18next'; -import { useEffect, useState } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { useConfig } from '../../tools/state'; -import { tryMatchPort, ServiceTypeList, Config } from '../../tools/types'; -import apiKeyPaths from './apiKeyPaths.json'; -import Tip from '../layout/Tip'; - -export function AddItemShelfButton(props: any) { - const { config, setConfig } = useConfig(); - const [opened, setOpened] = useState(false); - const { t } = useTranslation('layout/add-service-app-shelf'); - return ( - <> - {t('modal.title')}} - opened={props.opened || opened} - onClose={() => setOpened(false)} - > - - - - setOpened(true)} - > - - - - - ); -} - -function MatchIcon(name: string | undefined, form: any) { - if (name === undefined || name === '') return null; - fetch( - `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${name - .replace(/\s+/g, '-') - .toLowerCase() - .replace(/^dash\.$/, 'dashdot')}.png` - ).then((res) => { - if (res.ok) { - form.setFieldValue('icon', res.url); - } - }); - - return false; -} - -function MatchService(name: string, form: any) { - const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase()); - if (service) { - form.setFieldValue('type', service); - } -} - -const DEFAULT_ICON = '/imgs/favicon/favicon.png'; - -interface AddAppShelfItemFormProps { - setOpened: (b: boolean) => void; - config: Config; - setConfig: (config: Config) => void; - // Any other props you want to pass to the form - [key: string]: any; -} - -export function AddAppShelfItemForm(props: AddAppShelfItemFormProps) { - const { setOpened, config, setConfig } = props; - // Only get config and setConfig from useCOnfig if they are not present in props - const [isLoading, setLoading] = useState(false); - const { t } = useTranslation('layout/add-service-app-shelf'); - - // Extract all the categories from the services in config - const InitialCategories = config.services.reduce((acc, cur) => { - if (cur.category && !acc.includes(cur.category)) { - acc.push(cur.category); - } - return acc; - }, [] as string[]); - const [categories, setCategories] = useState(InitialCategories); - - const form = useForm({ - initialValues: { - id: props.id ?? uuidv4(), - type: props.type ?? 'Other', - category: props.category ?? null, - name: props.name ?? '', - icon: props.icon ?? DEFAULT_ICON, - url: props.url ?? '', - apiKey: props.apiKey ?? undefined, - username: props.username ?? undefined, - password: props.password ?? undefined, - openedUrl: props.openedUrl ?? undefined, - ping: props.ping ?? true, - newTab: props.newTab ?? true, - }, - validate: { - apiKey: () => null, - // Validate icon with a regex - icon: (value: string) => - // Disable matching to allow any values - null, - // Validate url with a regex http/https - url: (value: string) => { - try { - const _isValid = new URL(value); - } catch (e) { - return t('modal.form.validation.invalidUrl'); - } - return null; - }, - }, - }); - - const [debounced, cancel] = useDebouncedValue(form.values.name, 250); - useEffect(() => { - if ( - form.values.name !== debounced || - form.values.icon !== DEFAULT_ICON || - form.values.type !== 'Other' - ) { - return; - } - MatchIcon(form.values.name, form); - MatchService(form.values.name, form); - tryMatchPort(form.values.name, form); - }, [debounced]); - - // Try to set const hostname to new URL(form.values.url).hostname) - // If it fails, set it to the form.values.url - let hostname = form.values.url; - try { - hostname = new URL(form.values.url).origin; - } catch (e) { - // Do nothing - } - - return ( - <> -
- Placeholder -
-
{ - const newForm = { ...form.values }; - if (newForm.newTab === true) newForm.newTab = undefined; - if (newForm.openedUrl === '') newForm.openedUrl = undefined; - if (newForm.category === null) newForm.category = undefined; - if (newForm.ping === true) newForm.ping = undefined; - // If service already exists, update it. - if (config.services && config.services.find((s) => s.id === newForm.id)) { - setConfig({ - ...config, - // replace the found item by matching ID - services: config.services.map((s) => { - if (s.id === newForm.id) { - return { - ...newForm, - }; - } - return s; - }), - }); - } else { - setConfig({ - ...config, - services: [...config.services, newForm], - }); - } - setOpened(false); - form.reset(); - })} - > - - - {t('modal.tabs.options.title')} - {t('modal.tabs.advancedOptions.title')} - - - - - - - - - { - const item = { value: query, label: query }; - setCategories([...InitialCategories, query]); - return item; - }} - getCreateLabel={(query) => - t('modal.tabs.options.form.category.createLabel', { - query, - }) - } - {...form.getInputProps('category')} - /> - - {(form.values.type === 'Sonarr' || - form.values.type === 'Radarr' || - form.values.type === 'Lidarr' || - form.values.type === 'Overseerr' || - form.values.type === 'Jellyseerr' || - form.values.type === 'Readarr' || - form.values.type === 'Sabnzbd') && ( - <> - { - form.setFieldValue('apiKey', event.currentTarget.value); - }} - error={ - form.errors.apiKey && - t('modal.tabs.options.form.integrations.apiKey.validation.noKey') - } - /> - - {t('modal.tabs.options.form.integrations.apiKey.tip.text')}{' '} - - {t('modal.tabs.options.form.integrations.apiKey.tip.link')} - - - - )} - {form.values.type === 'qBittorrent' && ( - <> - { - form.setFieldValue('username', event.currentTarget.value); - }} - error={ - form.errors.username && - t( - 'modal.tabs.options.form.integrations.qBittorrent.username.validation.invalidUsername' - ) - } - /> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={ - form.errors.password && - t( - 'modal.tabs.options.form.integrations.qBittorrent.password.validation.invalidPassword' - ) - } - /> - - )} - {form.values.type === 'Deluge' && ( - <> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={ - form.errors.password && - t( - 'modal.tabs.options.form.integrations.deluge.password.validation.invalidPassword' - ) - } - /> - - )} - {form.values.type === 'Transmission' && ( - <> - { - form.setFieldValue('username', event.currentTarget.value); - }} - error={ - form.errors.username && - t( - 'modal.tabs.options.form.integrations.transmission.username.validation.invalidUsername' - ) - } - /> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={ - form.errors.password && - t( - 'modal.tabs.options.form.integrations.transmission.password.validation.invalidPassword' - ) - } - /> - - )} - {form.values.type === 'NZBGet' && ( - <> - { - form.setFieldValue('username', event.currentTarget.value); - }} - error={ - form.errors.username && - t( - 'modal.tabs.options.form.integrations.nzbget.username.validation.invalidUsername' - ) - } - /> - { - form.setFieldValue('password', event.currentTarget.value); - }} - error={ - form.errors.password && - t( - 'modal.tabs.options.form.integrations.nzbget.password.validation.invalidPassword' - ) - } - /> - - )} - - - - - - - - - - - - - -
- - ); -} diff --git a/src/components/AppShelf/AppShelfItem.tsx b/src/components/AppShelf/AppShelfItem.tsx index bbc51122c..cbd9744bc 100644 --- a/src/components/AppShelf/AppShelfItem.tsx +++ b/src/components/AppShelf/AppShelfItem.tsx @@ -15,7 +15,7 @@ import { useState } from 'react'; import PingComponent from '../../modules/ping/PingModule'; import { useConfig } from '../../tools/state'; import { serviceItem } from '../../tools/types'; -import AppShelfMenu from './AppShelfMenu'; +import { TileMenu } from '../Dashboard/Menu/TileMenu'; const useStyles = createStyles((theme) => ({ item: { @@ -104,7 +104,7 @@ export function AppShelfItem(props: any) { opacity: hovering ? 1 : 0, }} > - + { /* TODO: Remove this component */ } diff --git a/src/components/AppShelf/AppShelfMenu.tsx b/src/components/AppShelf/AppShelfMenu.tsx deleted file mode 100644 index 402867ac0..000000000 --- a/src/components/AppShelf/AppShelfMenu.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { ActionIcon, Menu, Modal, Text } from '@mantine/core'; -import { showNotification } from '@mantine/notifications'; -import { useState } from 'react'; -import { IconCheck as Check, IconEdit as Edit, IconMenu, IconTrash as Trash } from '@tabler/icons'; -import { useTranslation } from 'next-i18next'; -import { useConfig } from '../../tools/state'; -import { serviceItem } from '../../tools/types'; -import { AddAppShelfItemForm } from './AddAppShelfItem'; -import { useColorTheme } from '../../tools/color'; - -export default function AppShelfMenu(props: any) { - const { service }: { service: serviceItem } = props; - const { config, setConfig } = useConfig(); - const { secondaryColor } = useColorTheme(); - const { t } = useTranslation('layout/app-shelf-menu'); - const [opened, setOpened] = useState(false); - return ( - <> - setOpened(false)} - title={t('modal.title')} - > - - - - - - - - - - {t('menu.labels.settings')} - } onClick={() => setOpened(true)}> - {t('menu.actions.edit')} - - {t('menu.labels.dangerZone')} - { - setConfig({ - ...config, - services: config.services.filter((s) => s.id !== service.id), - }); - showNotification({ - autoClose: 5000, - title: ( - - Service {service.name} removed successfully! - - ), - color: 'green', - icon: , - message: undefined, - }); - }} - icon={} - > - {t('menu.actions.delete')} - - - - - ); -} diff --git a/src/components/Dashboard/Menu/TileMenu.tsx b/src/components/Dashboard/Menu/TileMenu.tsx new file mode 100644 index 000000000..22c0c1de6 --- /dev/null +++ b/src/components/Dashboard/Menu/TileMenu.tsx @@ -0,0 +1,76 @@ +import { ActionIcon, Menu, Text } from '@mantine/core'; +import { openContextModal } from '@mantine/modals'; +import { showNotification } from '@mantine/notifications'; +import { IconCheck, IconEdit, IconMenu, IconTrash } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useConfigContext } from '../../../config/provider'; +import { useConfigStore } from '../../../config/store'; +import { useColorTheme } from '../../../tools/color'; +import { ServiceType } from '../../../types/service'; + +interface TileMenuProps { + service: ServiceType; +} + +export const TileMenu = ({ service }: TileMenuProps) => { + const { secondaryColor } = useColorTheme(); + const { t } = useTranslation('layout/app-shelf-menu'); + const updateConfig = useConfigStore((x) => x.updateConfig); + const { name: configName } = useConfigContext(); + + return ( + + + + + + + + {t('menu.labels.settings')} + } + onClick={() => + openContextModal({ + modal: 'changeTilePosition', + innerProps: { + type: 'service', + tile: service, + }, + }) + } + > + {t('menu.actions.edit')} + + {t('menu.labels.dangerZone')} + { + if (!configName) { + return; + } + updateConfig(configName, (previousConfig) => ({ + ...previousConfig, + services: previousConfig.services.filter((x) => x.id !== service.id), + })).then(() => { + showNotification({ + autoClose: 5000, + title: ( + + Service {service.name} removed successfully! + + ), + color: 'green', + icon: , + message: undefined, + }); + }); + }} + icon={} + > + {t('menu.actions.delete')} + + + + ); +}; diff --git a/src/components/Dashboard/Modals/ChangePosition/ChangePositionModal.tsx b/src/components/Dashboard/Modals/ChangePosition/ChangePositionModal.tsx new file mode 100644 index 000000000..7c6aec0c4 --- /dev/null +++ b/src/components/Dashboard/Modals/ChangePosition/ChangePositionModal.tsx @@ -0,0 +1,94 @@ +import { Button, Flex, Grid, NumberInput } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { closeModal, ContextModalProps } from '@mantine/modals'; +import { useConfigContext } from '../../../../config/provider'; +import { useConfigStore } from '../../../../config/store'; +import { ServiceType } from '../../../../types/service'; +import { TileBaseType } from '../../../../types/tile'; + +export const ChangePositionModal = ({ + context, + id, + innerProps, +}: ContextModalProps<{ type: 'service' | 'type'; tile: TileBaseType }>) => { + const updateConfig = useConfigStore((x) => x.updateConfig); + const { name: configName } = useConfigContext(); + + const form = useForm({ + initialValues: { + tile: innerProps.tile, + }, + validateInputOnChange: true, + validateInputOnBlur: true, + }); + + const onSubmit = () => { + if (!configName) { + return; + } + + const tileAsService = form.values.tile as ServiceType; + + updateConfig(configName, (previous) => ({ + ...previous, + services: [...previous.services.filter((x) => x.id === tileAsService.id), tileAsService], + })); + + closeModal(id); + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx b/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx index ba2f86161..576dff849 100644 --- a/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx +++ b/src/components/Dashboard/Modals/EditService/EditServiceModal.tsx @@ -1,24 +1,30 @@ -import Image from 'next/image'; -import { Button, createStyles, Group, Stack, Tabs, Text } from '@mantine/core'; +import { Alert, Button, createStyles, Group, Stack, Tabs, Text, ThemeIcon } 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, + IconAlertTriangle, IconBrush, IconClick, - IconDeviceFloppy, - IconDoorExit, IconPlug, } from '@tabler/icons'; import { useTranslation } from 'next-i18next'; -import { hideNotification, showNotification } from '@mantine/notifications'; +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 +33,66 @@ 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: { + iconUrl: (url: string) => { + if (url.length < 1) { + return 'This field is required'; + } + + return null; + }, + }, + behaviour: { + onClickUrl: (url: string) => { + if (url === undefined || url.length < 1) { + return null; + } + + if (!url.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], + })); + + // also close the parent modal + context.closeAll(); }; + const [activeTab, setActiveTab] = useState('general'); + const tryCloseModal = () => { if (form.isDirty()) { showNotification({ @@ -63,8 +119,27 @@ export const EditServiceModal = ({ context.closeModal(id); }; + const validationErrors = Object.keys(form.errors); + + const ValidationErrorIndicator = ({ keys }: { keys: string[] }) => { + const relevantErrors = validationErrors.filter((x) => keys.includes(x)); + + return ( + + + + ); + }; + 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 @@ -84,27 +159,52 @@ export const EditServiceModal = ({ {form.values.name ?? 'New Service'} +
- + setActiveTab(tab as EditServiceModalTab)} + defaultValue="general" + > - }> + } + icon={} + value="general" + > General - }> + } + icon={} + value="behaviour" + > Behaviour - }> + } + icon={} + value="network" + > Network - }> + } + icon={} + value="appearance" + > Appearance - }> + } + icon={} + value="integration" + > Integration - + setActiveTab(targetTab)} /> @@ -112,16 +212,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 1066a28df..17dc43874 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 Image from 'next/image'; +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>; @@ -12,32 +13,56 @@ export const AppearanceTab = ({ form }: AppearanceTabProps) => { const { t } = useTranslation(''); const { classes } = useStyles(); - const Image = () => { - if (form.values.appearance.iconUrl !== undefined) { + const PreviewImage = () => { + if (form.values.appearance.iconUrl !== undefined && form.values.appearance.iconUrl.length > 0) { // disabled due to too many dynamic targets for next image cache // eslint-disable-next-line @next/next/no-img-element - return jife; + return ; } - return ; + return ( + + ); }; return ( - } - label="Service Icon" - variant="default" - defaultValue={form.values.appearance.iconUrl} - {...form.getInputProps('appearance.iconUrl')} - withAsterisk - required - /> + + } + label="Service Icon" + description="Logo of your service displayed in your dashboard. Must return a body content containg an image" + 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..b82a2a49d --- /dev/null +++ b/src/components/Dashboard/Modals/EditService/Tabs/AppereanceTab/IconSelector/IconSelector.tsx @@ -0,0 +1,117 @@ +/* eslint-disable @next/next/no-img-element */ +import { + ActionIcon, + Button, + createStyles, + Divider, + Flex, + Loader, + Popover, + ScrollArea, + Stack, + Text, + TextInput, + Title, +} from '@mantine/core'; +import { 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 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; + + return ( + + + + + + + setSearchTerm(event.currentTarget.value)} + placeholder="Search for icons..." + variant="filled" + rightSection={ + setSearchTerm('')}> + + + } + /> + + + + {slicedFilteredItems.map((item) => ( + onChange(item)} size={40} p={3}> + + + ))} + + + {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..4c760ee06 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/BehaviourTab/BehaviourTab.tsx @@ -15,32 +15,16 @@ 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('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')} + label="Open in new tab" + {...form.getInputProps('behaviour.isOpeningNewTab', { type: 'checkbox' })} /> ); diff --git a/src/components/Dashboard/Modals/EditService/Tabs/GeneralTab/GeneralTab.tsx b/src/components/Dashboard/Modals/EditService/Tabs/GeneralTab/GeneralTab.tsx index 4971058c5..397db91f5 100644 --- a/src/components/Dashboard/Modals/EditService/Tabs/GeneralTab/GeneralTab.tsx +++ b/src/components/Dashboard/Modals/EditService/Tabs/GeneralTab/GeneralTab.tsx @@ -1,32 +1,52 @@ -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" + span + style={{ + cursor: 'pointer', + }} + > + {' '} + 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..5087f49c7 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 @@ -1,21 +1,9 @@ /* eslint-disable @next/next/no-img-element */ -import { - Alert, - Card, - Group, - PasswordInput, - Select, - SelectItem, - Space, - Text, - TextInput, -} from '@mantine/core'; +import { Group, Select, SelectItem, Text } from '@mantine/core'; import { UseFormReturnType } from '@mantine/form'; -import { IconKey, IconUser } from '@tabler/icons'; import { useTranslation } from 'next-i18next'; import { forwardRef, useState } from 'react'; -import { ServiceType } from '../../../../../../../../../types/service'; -import { TextExplanation } from '../TextExplanation/TextExplanation'; +import { ServiceType } from '../../../../../../../../types/service'; interface IntegrationSelectorProps { form: UseFormReturnType ServiceType>; @@ -62,11 +50,9 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => { return ( <> - - -