diff --git a/public/locales/en/modules/bookmark.json b/public/locales/en/modules/bookmark.json new file mode 100644 index 000000000..377e4bf9d --- /dev/null +++ b/public/locales/en/modules/bookmark.json @@ -0,0 +1,21 @@ +{ + "descriptor": { + "name": "Bookmark", + "description": "Displays a static list of strings or links", + "settings": { + "title": "Bookmark settings", + "items": { + "label": "Items" + }, + "layout": { + "label": "Layout" + } + } + }, + "card": { + "noneFound": { + "title": "Bookmark list empty", + "text": "Add new items to this list in the edit mode" + } + } +} diff --git a/public/locales/en/widgets/draggable-list.json b/public/locales/en/widgets/draggable-list.json new file mode 100644 index 000000000..ed1676e86 --- /dev/null +++ b/public/locales/en/widgets/draggable-list.json @@ -0,0 +1,7 @@ +{ + "noEntries": { + "title": "No entries", + "text": "Use the buttons below to add more entries" + }, + "buttonAdd": "Add" +} diff --git a/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx b/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx index 752f08732..ce48985f8 100644 --- a/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx @@ -15,13 +15,13 @@ import { useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; import { AppType } from '../../../../types/app'; +import { DebouncedImage } from '../../../IconSelector/DebouncedImage'; import { useEditModeStore } from '../../Views/useEditModeStore'; 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 { DebouncedAppIcon } from './Tabs/Shared/DebouncedAppIcon'; import { EditAppModalTab } from './Tabs/type'; const appUrlRegex = @@ -138,7 +138,7 @@ export const EditAppModal = ({ ))} - + {form.values.name ?? 'New App'} diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx index 04aba2e63..0ffd0a7cf 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx @@ -1,10 +1,9 @@ import { Flex, Tabs } from '@mantine/core'; import { UseFormReturnType } from '@mantine/form'; import { useDebouncedValue } from '@mantine/hooks'; -import { useEffect } from 'react'; -import { useGetDashboardIcons } from '../../../../../../hooks/icons/useGetDashboardIcons'; +import { useEffect, useRef } from 'react'; import { AppType } from '../../../../../../types/app'; -import { IconSelector } from './IconSelector'; +import { IconSelector } from '../../../../../IconSelector/IconSelector'; interface AppearanceTabProps { form: UseFormReturnType AppType>; @@ -17,8 +16,7 @@ export const AppearanceTab = ({ disallowAppNameProgagation, allowAppNamePropagation, }: AppearanceTabProps) => { - const { data, isLoading } = useGetDashboardIcons(); - + const iconSelectorRef = useRef(); const [debouncedValue] = useDebouncedValue(form.values.name, 500); useEffect(() => { @@ -26,26 +24,28 @@ export const AppearanceTab = ({ return; } - const matchingDebouncedIcon = data - ?.flatMap((x) => x.entries) - .find((x) => replaceCharacters(x.name.split('.')[0]) === replaceCharacters(debouncedValue)); - - if (!matchingDebouncedIcon) { + if (!iconSelectorRef.current) { return; } - form.setFieldValue('appearance.iconUrl', matchingDebouncedIcon.url); + const currentRef = iconSelectorRef.current as { + chooseFirstOrDefault: (debouncedValue: string) => void; + }; + + currentRef.chooseFirstOrDefault(debouncedValue); }, [debouncedValue]); return ( { + form.setFieldValue('appearance.iconUrl', value); + disallowAppNameProgagation(); + }} + value={form.values.appearance.iconUrl} + ref={iconSelectorRef} /> diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx deleted file mode 100644 index d9fc60ad3..000000000 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { - Autocomplete, - Box, - CloseButton, - createStyles, - Group, - Image, - Loader, - ScrollArea, - SelectItemProps, - Stack, - Text, - Title, -} from '@mantine/core'; -import { UseFormReturnType } from '@mantine/form'; -import { IconSearch } from '@tabler/icons'; -import { useTranslation } from 'next-i18next'; -import { forwardRef } from 'react'; -import { humanFileSize } from '../../../../../../tools/humanFileSize'; -import { NormalizedIconRepositoryResult } from '../../../../../../tools/server/images/abstract-icons-repository'; -import { AppType } from '../../../../../../types/app'; -import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon'; - -interface IconSelectorProps { - form: UseFormReturnType AppType>; - data: NormalizedIconRepositoryResult[] | undefined; - isLoading: boolean; - disallowAppNameProgagation: () => void; - allowAppNamePropagation: boolean; -} - -export const IconSelector = ({ - form, - data, - isLoading, - allowAppNamePropagation, - disallowAppNameProgagation, -}: IconSelectorProps) => { - const { t } = useTranslation('layout/modals/add-app'); - const { classes } = useStyles(); - - const a = - data === undefined - ? [] - : data.flatMap((repository) => - repository.entries.map((entry) => ({ - url: entry.url, - label: entry.name, - size: entry.size, - value: entry.url, - group: repository.name, - copyright: repository.copyright, - })) - ); - - return ( - - - - - {t('appearance.icon.autocomplete.title')} - - - {t('appearance.icon.autocomplete.text')} - - - } - icon={} - rightSection={ - form.values.appearance.iconUrl.length > 0 ? ( - form.setFieldValue('appearance.iconUrl', '')} /> - ) : null - } - itemComponent={AutoCompleteItem} - className={classes.textInput} - data={a} - limit={25} - label={t('appearance.icon.label')} - description={t('appearance.icon.description', { - suggestionsCount: data?.reduce((a, b) => a + b.count, 0) ?? 0, - })} - filter={(search, item) => - item.value - .toLowerCase() - .replaceAll('_', '') - .replaceAll(' ', '-') - .includes(search.toLowerCase().replaceAll('_', '').replaceAll(' ', '-')) - } - variant="default" - withAsterisk - dropdownComponent={(props: any) => } - dropdownPosition="bottom" - required - onChange={(event) => { - if (allowAppNamePropagation) { - disallowAppNameProgagation(); - } - form.setFieldValue('appearance.iconUrl', event); - }} - value={form.values.appearance.iconUrl} - /> - {(!data || isLoading) && ( - - - - - {t('appearance.icon.noItems.title')} - - - {t('appearance.icon.noItems.text')} - - - - )} - - ); -}; - -const useStyles = createStyles(() => ({ - textInput: { - flexGrow: 1, - }, -})); - -const AutoCompleteItem = forwardRef( - ({ label, size, copyright, url, ...others }: ItemProps, ref) => ( -
- - ({ - backgroundColor: - theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2], - borderRadius: theme.radius.md, - })} - p={2} - > - - - - {label} - - - {humanFileSize(size, false)} - - {copyright && ( - - © {copyright} - - )} - - - -
- ) -); - -interface ItemProps extends SelectItemProps { - url: string; - group: string; - size: number; - copyright: string | undefined; -} diff --git a/src/components/Dashboard/Tiles/Widgets/Inputs/DraggableList.tsx b/src/components/Dashboard/Tiles/Widgets/Inputs/DraggableList.tsx new file mode 100644 index 000000000..5b0c02c43 --- /dev/null +++ b/src/components/Dashboard/Tiles/Widgets/Inputs/DraggableList.tsx @@ -0,0 +1,138 @@ +import { Collapse, Flex, Stack, Text, createStyles } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { IconChevronDown, IconGripVertical } from '@tabler/icons'; +import { Reorder, useDragControls } from 'framer-motion'; +import { FC, useEffect, useRef } from 'react'; +import { IDraggableEditableListInputValue } from '../../../../../widgets/widgets'; + +interface DraggableListProps { + items: { + data: { id: string } & any; + }[]; + value: IDraggableEditableListInputValue['defaultValue']; + onChange: (value: IDraggableEditableListInputValue['defaultValue']) => void; + options: IDraggableEditableListInputValue; +} + +export const DraggableList = ({ items, value, onChange, options }: DraggableListProps) => ( +
+ x.data.id)} + onReorder={(order) => onChange(order.map((id) => value.find((v) => v.id === id)!))} + as="div" + > + {items.map(({ data }) => ( + + { + onChange( + items.map((item) => { + if (item.data.id === data.id) return data; + return item.data; + }) + ); + }} + delete={() => { + onChange(items.filter((item) => item.data.id !== data.id).map((item) => item.data)); + }} + /> + + ))} + +
+); + +const ListItem: FC<{ + item: any; + label: string | JSX.Element; +}> = ({ item, label, children }) => { + const [opened, handlers] = useDisclosure(false); + const { classes, cx } = useStyles(); + const controls = useDragControls(); + + // Workaround for mobile drag controls not working + // https://github.com/framer/motion/issues/1597#issuecomment-1235026724 + const dragRef = useRef(null); + useEffect(() => { + const touchHandler: EventListener = (e) => e.preventDefault(); + + const dragItem = dragRef.current; + + if (dragItem) { + dragItem.addEventListener('touchstart', touchHandler, { passive: false }); + + return () => { + dragItem.removeEventListener('touchstart', touchHandler); + }; + } + + return undefined; + }, [dragRef]); + + return ( + +
+
+ controls.start(e)}> + + + +
+ {label} +
+ + handlers.toggle()} + size={18} + stroke={1.5} + /> +
+ + + {children} + +
+
+ ); +}; + +const useStyles = createStyles((theme) => ({ + container: { + display: 'flex', + flexDirection: 'column', + borderRadius: theme.radius.md, + border: `1px solid ${ + theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2] + }`, + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.white, + marginBottom: theme.spacing.xs, + }, + row: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + gap: theme.spacing.sm, + }, + middle: { + flexGrow: 1, + }, + symbol: { + fontSize: 16, + }, + clickableIcons: { + color: theme.colorScheme === 'dark' ? theme.colors.dark[1] : theme.colors.gray[6], + cursor: 'pointer', + userSelect: 'none', + transition: 'transform .3s ease-in-out', + }, + rotate: { + transform: 'rotate(180deg)', + }, + collapseContent: { + padding: '12px 16px', + }, +})); diff --git a/src/components/Dashboard/Tiles/Widgets/DraggableList.tsx b/src/components/Dashboard/Tiles/Widgets/Inputs/StaticDraggableList.tsx similarity index 91% rename from src/components/Dashboard/Tiles/Widgets/DraggableList.tsx rename to src/components/Dashboard/Tiles/Widgets/Inputs/StaticDraggableList.tsx index 7622e203b..21decac2d 100644 --- a/src/components/Dashboard/Tiles/Widgets/DraggableList.tsx +++ b/src/components/Dashboard/Tiles/Widgets/Inputs/StaticDraggableList.tsx @@ -3,7 +3,7 @@ import { useDisclosure } from '@mantine/hooks'; import { IconChevronDown, IconGripVertical } from '@tabler/icons'; import { Reorder, useDragControls } from 'framer-motion'; import { FC, ReactNode, useEffect, useRef } from 'react'; -import { IDraggableListInputValue } from '../../../../widgets/widgets'; +import { IDraggableListInputValue } from '../../../../../widgets/widgets'; const useStyles = createStyles((theme) => ({ container: { @@ -43,14 +43,14 @@ const useStyles = createStyles((theme) => ({ }, })); -type DraggableListParams = { +type StaticDraggableListParams = { value: IDraggableListInputValue['defaultValue']; onChange: (value: IDraggableListInputValue['defaultValue']) => void; labels: Record; children?: Record; }; -export const DraggableList: FC = (props) => { +export const StaticDraggableList: FC = (props) => { const keys = props.value.map((v) => v.key); return ( @@ -64,10 +64,10 @@ export const DraggableList: FC = (props) => { as="div" > {props.value.map((item) => ( - - {props.children?.[item.key]} - - ))} + + {props.children?.[item.key]} + + ))} ); diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx index 34d7d985d..0fd7e8f88 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx @@ -1,6 +1,8 @@ import { Alert, Button, + Card, + Flex, Group, MultiSelect, NumberInput, @@ -10,9 +12,10 @@ import { Switch, Text, TextInput, + Title, } from '@mantine/core'; import { ContextModalProps } from '@mantine/modals'; -import { IconAlertTriangle } from '@tabler/icons'; +import { IconAlertTriangle, IconPlaylistX, IconPlus } from '@tabler/icons'; import { Trans, useTranslation } from 'next-i18next'; import { FC, useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; @@ -22,7 +25,8 @@ import { useColorTheme } from '../../../../tools/color'; import Widgets from '../../../../widgets'; import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets'; import { IWidget } from '../../../../widgets/widgets'; -import { DraggableList } from './DraggableList'; +import { DraggableList } from './Inputs/DraggableList'; +import { StaticDraggableList } from './Inputs/StaticDraggableList'; export type WidgetEditModalInnerProps = { widgetId: string; @@ -222,7 +226,7 @@ const WidgetOptionTypeSwitch: FC<{ return ( {t(`descriptor.settings.${key}.label`)} - handleChange(key, v)} labels={mapObject(option.items, (liName) => @@ -241,7 +245,7 @@ const WidgetOptionTypeSwitch: FC<{ /> )) )} - + ); case 'multiple-text': @@ -263,6 +267,46 @@ const WidgetOptionTypeSwitch: FC<{ } /> ); + case 'draggable-editable-list': + const { t: translateDraggableList } = useTranslation('widgets/draggable-list'); + return ( + + {t(`descriptor.settings.${key}.label`)} + ({ + data: v, + }))} + value={value} + onChange={(v) => handleChange(key, v)} + options={option} + /> + + {Array.from(value).length === 0 && ( + + + + + {translateDraggableList('noEntries.title')} + {translateDraggableList('noEntries.text')} + + + + )} + + + + + + ); /* eslint-enable no-case-declarations */ default: return null; diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/Shared/DebouncedAppIcon.tsx b/src/components/IconSelector/DebouncedImage.tsx similarity index 51% rename from src/components/Dashboard/Modals/EditAppModal/Tabs/Shared/DebouncedAppIcon.tsx rename to src/components/IconSelector/DebouncedImage.tsx index 90e575fa1..852a3bcac 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/Shared/DebouncedAppIcon.tsx +++ b/src/components/IconSelector/DebouncedImage.tsx @@ -1,42 +1,38 @@ -// disabled due to too many dynamic targets for next image cache -/* eslint-disable @next/next/no-img-element */ -import Image from 'next/image'; -import { createStyles, Loader } from '@mantine/core'; -import { UseFormReturnType } from '@mantine/form'; +import { Image, Loader, createStyles } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; -import { AppType } from '../../../../../../types/app'; +import { IconPhotoOff } from '@tabler/icons'; -interface DebouncedAppIconProps { +interface DebouncedImageProps { width: number; height: number; - form: UseFormReturnType AppType>; + src: string; debouncedWaitPeriod?: number; } -export const DebouncedAppIcon = ({ - form, +export const DebouncedImage = ({ + src, width, height, debouncedWaitPeriod = 1000, -}: DebouncedAppIconProps) => { +}: DebouncedImageProps) => { const { classes } = useStyles(); - const [debouncedIconImageUrl] = useDebouncedValue( - form.values.appearance.iconUrl, - debouncedWaitPeriod - ); + const [debouncedIconImageUrl] = useDebouncedValue(src, debouncedWaitPeriod); - if (debouncedIconImageUrl !== form.values.appearance.iconUrl) { + if (debouncedIconImageUrl !== src) { return ; } if (debouncedIconImageUrl.length > 0) { return ( - } className={classes.iconImage} src={debouncedIconImageUrl} width={width} height={height} + fit="contain" alt="" + withPlaceholder /> ); } @@ -47,7 +43,9 @@ export const DebouncedAppIcon = ({ src="/imgs/logo/logo.png" width={width} height={height} + fit="contain" alt="" + withPlaceholder /> ); }; diff --git a/src/components/IconSelector/IconSelector.tsx b/src/components/IconSelector/IconSelector.tsx new file mode 100644 index 000000000..307ee80ea --- /dev/null +++ b/src/components/IconSelector/IconSelector.tsx @@ -0,0 +1,177 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { + Autocomplete, + CloseButton, + Stack, + Title, + Text, + Group, + Loader, + createStyles, + Box, + Image, + SelectItemProps, + ScrollArea, +} from '@mantine/core'; +import { IconSearch } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useGetDashboardIcons } from '../../hooks/icons/useGetDashboardIcons'; +import { humanFileSize } from '../../tools/humanFileSize'; +import { DebouncedImage } from './DebouncedImage'; + +export const IconSelector = forwardRef( + ( + { + defaultValue, + value, + onChange, + }: { + defaultValue: string; + value?: string; + onChange: (debouncedValue: string | undefined) => void; + }, + ref + ) => { + const { t } = useTranslation('layout/modals/add-app'); + const { classes } = useStyles(); + + const { data, isLoading } = useGetDashboardIcons(); + const [currentValue, setValue] = useState(value ?? defaultValue); + + const flatIcons = + data === undefined + ? [] + : data.flatMap((repository) => + repository.entries.map((entry) => ({ + url: entry.url, + label: entry.name, + size: entry.size, + value: entry.url, + group: repository.name, + copyright: repository.copyright, + })) + ); + + useImperativeHandle(ref, () => ({ + chooseFirstOrDefault(searchTerm: string) { + const match = flatIcons.find((icon) => + icon.label.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (!match) { + return; + } + + onChange(match.url); + }, + })); + + return ( + + + + + {t('appearance.icon.autocomplete.title')} + + + {t('appearance.icon.autocomplete.text')} + + + } + icon={} + rightSection={ + (value ?? currentValue).length > 0 ? ( + onChange(undefined)} /> + ) : null + } + itemComponent={AutoCompleteItem} + className={classes.textInput} + data={flatIcons} + limit={25} + label={t('appearance.icon.label')} + description={t('appearance.icon.description', { + suggestionsCount: data?.reduce((a, b) => a + b.count, 0) ?? 0, + })} + filter={(search, item) => + item.value + .toLowerCase() + .replaceAll('_', '') + .replaceAll(' ', '-') + .includes(search.toLowerCase().replaceAll('_', '').replaceAll(' ', '-')) + } + dropdownComponent={(props: any) => } + onChange={(event) => { + onChange(event); + setValue(event); + }} + dropdownPosition="bottom" + variant="default" + value={value} + withAsterisk + withinPortal + required + /> + {(!data || isLoading) && ( + + + + + {t('appearance.icon.noItems.title')} + + + {t('appearance.icon.noItems.text')} + + + + )} + + ); + } +); + +const useStyles = createStyles(() => ({ + textInput: { + flexGrow: 1, + }, +})); + +const AutoCompleteItem = forwardRef( + ({ label, size, copyright, url, ...others }: ItemProps, ref) => ( +
+ + ({ + backgroundColor: + theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2], + borderRadius: theme.radius.md, + })} + p={2} + > + + + + {label} + + + {humanFileSize(size, false)} + + {copyright && ( + + © {copyright} + + )} + + + +
+ ) +); + +interface ItemProps extends SelectItemProps { + url: string; + group: string; + size: number; + copyright: string | undefined; +} diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 233d4f59b..cafad1cba 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -40,7 +40,9 @@ export const dashboardNamespaces = [ 'modules/media-requests-stats', 'modules/dns-hole-summary', 'modules/dns-hole-controls', + 'modules/bookmark', 'widgets/error-boundary', + 'widgets/draggable-list', ]; export const loginNamespaces = ['authentication/login']; diff --git a/src/widgets/bookmark/BookmarkWidgetTile.tsx b/src/widgets/bookmark/BookmarkWidgetTile.tsx new file mode 100644 index 000000000..1e6f9f899 --- /dev/null +++ b/src/widgets/bookmark/BookmarkWidgetTile.tsx @@ -0,0 +1,260 @@ +import { + Alert, + Box, + Button, + Card, + Flex, + Group, + Image, + ScrollArea, + Stack, + Text, + TextInput, + Title, + createStyles, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { + IconAlertTriangle, + IconBookmark, + IconLink, + IconPlaylistX, + IconTrash, + IconTypography, +} from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useEffect } from 'react'; +import { v4 } from 'uuid'; +import { z } from 'zod'; +import { IconSelector } from '../../components/IconSelector/IconSelector'; +import { defineWidget } from '../helper'; +import { IDraggableEditableListInputValue, IWidget } from '../widgets'; + +interface BookmarkItem { + id: string; + name: string; + href: string; + iconUrl: string; +} + +const definition = defineWidget({ + id: 'bookmark', + icon: IconBookmark, + options: { + items: { + type: 'draggable-editable-list', + defaultValue: [], + getLabel(data) { + return data.name; + }, + create() { + return { + id: v4(), + name: 'Homarr Documentation', + href: 'https://homarr.dev', + iconUrl: '/imgs/logo/logo.png', + }; + }, + itemComponent({ data, onChange, delete: deleteData }) { + const form = useForm({ + initialValues: data, + validate: { + name: (value) => { + const validation = z.string().min(1).max(100).safeParse(value); + if (validation.success) { + return undefined; + } + + return 'Length must be between 1 and 100'; + }, + href: (value) => { + if (!z.string().min(1).max(200).safeParse(value).success) { + return 'Length must be between 1 and 200'; + } + + if (!z.string().url().safeParse(value).success) { + return 'Not a valid link'; + } + + return undefined; + }, + iconUrl: (value) => { + if (z.string().min(1).max(400).safeParse(value).success) { + return undefined; + } + + return 'Length must be between 1 and 100'; + }, + }, + validateInputOnChange: true, + validateInputOnBlur: true, + }); + + useEffect(() => { + if (!form.isValid()) { + return; + } + + onChange(form.values); + }, [form.values]); + + return ( +
+ + } + {...form.getInputProps('name')} + label="Name" + withAsterisk + /> + } + {...form.getInputProps('href')} + label="URL" + withAsterisk + /> + { + form.setFieldValue('iconUrl', value ?? ''); + }} + /> + + {!form.isValid() && ( + }> + Did not save, because there were validation errors. Please adust your inputs + + )} + +
+ ); + }, + } satisfies IDraggableEditableListInputValue, + layout: { + type: 'select', + data: [ + { + label: 'Auto Grid', + value: 'autoGrid', + }, + { + label: 'Horizontal', + value: 'horizontal', + }, + { + label: 'Vertical', + value: 'vertical', + }, + ], + defaultValue: 'autoGrid', + }, + }, + gridstack: { + minWidth: 1, + minHeight: 1, + maxWidth: 24, + maxHeight: 24, + }, + component: BookmarkWidgetTile, +}); + +export type IBookmarkWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface BookmarkWidgetTileProps { + widget: IBookmarkWidget; +} + +function BookmarkWidgetTile({ widget }: BookmarkWidgetTileProps) { + const { t } = useTranslation('modules/bookmark'); + const { classes } = useStyles(); + + if (widget.properties.items.length === 0) { + return ( + + + + {t('card.noneFound.title')} + {t('card.noneFound.text')} + + + ); + } + + switch (widget.properties.layout) { + case 'autoGrid': + return ( + + {widget.properties.items.map((item: BookmarkItem, index) => ( + + + + ))} + + ); + case 'horizontal': + case 'vertical': + return ( + + + {widget.properties.items.map((item: BookmarkItem, index) => ( + + + + ))} + + + ); + default: + return null; + } +} + +const BookmarkItemContent = ({ item }: { item: BookmarkItem }) => ( + + + + {item.name} + + {new URL(item.href).hostname} + + + +); + +const useStyles = createStyles(() => ({ + grid: { + display: 'grid', + gap: 20, + gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', + }, + autoGridItem: { + flex: '1 1 auto', + }, +})); + +export default definition; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index a80026c84..870058fed 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -13,6 +13,7 @@ import mediaRequestsList from './media-requests/MediaRequestListTile'; import mediaRequestsStats from './media-requests/MediaRequestStatsTile'; import dnsHoleSummary from './dnshole/DnsHoleSummary'; import dnsHoleControls from './dnshole/DnsHoleControls'; +import bookmark from './bookmark/BookmarkWidgetTile'; export default { calendar, @@ -30,4 +31,5 @@ export default { 'media-requests-stats': mediaRequestsStats, 'dns-hole-summary': dnsHoleSummary, 'dns-hole-controls': dnsHoleControls, + bookmark, }; diff --git a/src/widgets/widgets.ts b/src/widgets/widgets.ts index a5b6b9729..a5d458533 100644 --- a/src/widgets/widgets.ts +++ b/src/widgets/widgets.ts @@ -39,6 +39,7 @@ export type IWidgetOptionValue = | ISelectOptionValue | INumberInputOptionValue | IDraggableListInputValue + | IDraggableEditableListInputValue | IMultipleTextInputOptionValue; // Interface for data type @@ -107,6 +108,18 @@ export type IDraggableListInputValue = { >; }; +export type IDraggableEditableListInputValue = { + type: 'draggable-editable-list'; + defaultValue: TData[]; + create: () => TData; + getLabel: (data: TData) => string | JSX.Element; + itemComponent: (props: { + data: TData; + onChange: (data: TData) => void; + delete: () => void; + }) => JSX.Element; +}; + // will show a text-input with a button to add a new line export type IMultipleTextInputOptionValue = { type: 'multiple-text';