Bookmark widget (#890)

* 🚧 Bookmark widget

*  Add input type

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>

*  Add content display and input fields

* 🐛 Fix delete button updating to invalid schema

* 🌐 Add translations for options

*  Add field for image

* ♻️ Refactor IconSelector and add forward ref

* 🦺 Add form validation

* 🦺 Add validation for icon url and fix state for icon picker

* 🌐 PR feedback

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2023-05-15 09:54:50 +02:00
committed by GitHub
parent 194da2b6e5
commit c52acd2913
14 changed files with 708 additions and 210 deletions

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,7 @@
{
"noEntries": {
"title": "No entries",
"text": "Use the buttons below to add more entries"
},
"buttonAdd": "Add"
}

View File

@@ -15,13 +15,13 @@ import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
import { AppType } from '../../../../types/app'; import { AppType } from '../../../../types/app';
import { DebouncedImage } from '../../../IconSelector/DebouncedImage';
import { useEditModeStore } from '../../Views/useEditModeStore'; import { useEditModeStore } from '../../Views/useEditModeStore';
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab'; import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab'; import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab';
import { GeneralTab } from './Tabs/GeneralTab/GeneralTab'; import { GeneralTab } from './Tabs/GeneralTab/GeneralTab';
import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab'; import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab';
import { NetworkTab } from './Tabs/NetworkTab/NetworkTab'; import { NetworkTab } from './Tabs/NetworkTab/NetworkTab';
import { DebouncedAppIcon } from './Tabs/Shared/DebouncedAppIcon';
import { EditAppModalTab } from './Tabs/type'; import { EditAppModalTab } from './Tabs/type';
const appUrlRegex = const appUrlRegex =
@@ -138,7 +138,7 @@ export const EditAppModal = ({
</Alert> </Alert>
))} ))}
<Stack spacing={0} align="center" my="lg"> <Stack spacing={0} align="center" my="lg">
<DebouncedAppIcon form={form} width={120} height={120} /> <DebouncedImage src={form.values.appearance.iconUrl} width={120} height={120} />
<Text align="center" weight="bold" size="lg" mt="md"> <Text align="center" weight="bold" size="lg" mt="md">
{form.values.name ?? 'New App'} {form.values.name ?? 'New App'}

View File

@@ -1,10 +1,9 @@
import { Flex, Tabs } from '@mantine/core'; import { Flex, Tabs } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form'; import { UseFormReturnType } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useGetDashboardIcons } from '../../../../../../hooks/icons/useGetDashboardIcons';
import { AppType } from '../../../../../../types/app'; import { AppType } from '../../../../../../types/app';
import { IconSelector } from './IconSelector'; import { IconSelector } from '../../../../../IconSelector/IconSelector';
interface AppearanceTabProps { interface AppearanceTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>; form: UseFormReturnType<AppType, (values: AppType) => AppType>;
@@ -17,8 +16,7 @@ export const AppearanceTab = ({
disallowAppNameProgagation, disallowAppNameProgagation,
allowAppNamePropagation, allowAppNamePropagation,
}: AppearanceTabProps) => { }: AppearanceTabProps) => {
const { data, isLoading } = useGetDashboardIcons(); const iconSelectorRef = useRef();
const [debouncedValue] = useDebouncedValue(form.values.name, 500); const [debouncedValue] = useDebouncedValue(form.values.name, 500);
useEffect(() => { useEffect(() => {
@@ -26,26 +24,28 @@ export const AppearanceTab = ({
return; return;
} }
const matchingDebouncedIcon = data if (!iconSelectorRef.current) {
?.flatMap((x) => x.entries)
.find((x) => replaceCharacters(x.name.split('.')[0]) === replaceCharacters(debouncedValue));
if (!matchingDebouncedIcon) {
return; return;
} }
form.setFieldValue('appearance.iconUrl', matchingDebouncedIcon.url); const currentRef = iconSelectorRef.current as {
chooseFirstOrDefault: (debouncedValue: string) => void;
};
currentRef.chooseFirstOrDefault(debouncedValue);
}, [debouncedValue]); }, [debouncedValue]);
return ( return (
<Tabs.Panel value="appearance" pt="lg"> <Tabs.Panel value="appearance" pt="lg">
<Flex gap={5}> <Flex gap={5}>
<IconSelector <IconSelector
form={form} defaultValue={form.values.appearance.iconUrl}
data={data} onChange={(value) => {
isLoading={isLoading} form.setFieldValue('appearance.iconUrl', value);
allowAppNamePropagation={allowAppNamePropagation} disallowAppNameProgagation();
disallowAppNameProgagation={disallowAppNameProgagation} }}
value={form.values.appearance.iconUrl}
ref={iconSelectorRef}
/> />
</Flex> </Flex>
</Tabs.Panel> </Tabs.Panel>

View File

@@ -1,164 +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, (values: AppType) => 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 (
<Stack w="100%">
<Autocomplete
nothingFound={
<Stack align="center" spacing="xs" my="lg">
<IconSearch />
<Title order={6} align="center">
{t('appearance.icon.autocomplete.title')}
</Title>
<Text align="center" maw={350}>
{t('appearance.icon.autocomplete.text')}
</Text>
</Stack>
}
icon={<DebouncedAppIcon form={form} width={20} height={20} />}
rightSection={
form.values.appearance.iconUrl.length > 0 ? (
<CloseButton onClick={() => 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) => <ScrollArea {...props} mah={250} />}
dropdownPosition="bottom"
required
onChange={(event) => {
if (allowAppNamePropagation) {
disallowAppNameProgagation();
}
form.setFieldValue('appearance.iconUrl', event);
}}
value={form.values.appearance.iconUrl}
/>
{(!data || isLoading) && (
<Group>
<Loader variant="oval" size="sm" />
<Stack spacing={0}>
<Text size="xs" weight="bold">
{t('appearance.icon.noItems.title')}
</Text>
<Text color="dimmed" size="xs">
{t('appearance.icon.noItems.text')}
</Text>
</Stack>
</Group>
)}
</Stack>
);
};
const useStyles = createStyles(() => ({
textInput: {
flexGrow: 1,
},
}));
const AutoCompleteItem = forwardRef<HTMLDivElement, ItemProps>(
({ label, size, copyright, url, ...others }: ItemProps, ref) => (
<div ref={ref} {...others}>
<Group noWrap>
<Box
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2],
borderRadius: theme.radius.md,
})}
p={2}
>
<Image src={url} width={30} height={30} fit="contain" />
</Box>
<Stack spacing={0}>
<Text>{label}</Text>
<Group>
<Text color="dimmed" size="xs">
{humanFileSize(size, false)}
</Text>
{copyright && (
<Text color="dimmed" size="xs">
© {copyright}
</Text>
)}
</Group>
</Stack>
</Group>
</div>
)
);
interface ItemProps extends SelectItemProps {
url: string;
group: string;
size: number;
copyright: string | undefined;
}

View File

@@ -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<any>['defaultValue'];
onChange: (value: IDraggableEditableListInputValue<any>['defaultValue']) => void;
options: IDraggableEditableListInputValue<any>;
}
export const DraggableList = ({ items, value, onChange, options }: DraggableListProps) => (
<div>
<Reorder.Group
axis="y"
values={items.map((x) => x.data.id)}
onReorder={(order) => onChange(order.map((id) => value.find((v) => v.id === id)!))}
as="div"
>
{items.map(({ data }) => (
<ListItem key={data.id} item={data} label={options.getLabel(data)}>
<options.itemComponent
data={data}
onChange={(data: any) => {
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));
}}
/>
</ListItem>
))}
</Reorder.Group>
</div>
);
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<HTMLDivElement>(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 (
<Reorder.Item value={item.id} dragListener={false} dragControls={controls} as="div">
<div className={classes.container}>
<div className={classes.row}>
<Flex ref={dragRef} onPointerDown={(e) => controls.start(e)}>
<IconGripVertical className={classes.clickableIcons} size={18} stroke={1.5} />
</Flex>
<div className={classes.middle}>
<Text className={classes.symbol}>{label}</Text>
</div>
<IconChevronDown
className={cx(classes.clickableIcons, { [classes.rotate]: opened })}
onClick={() => handlers.toggle()}
size={18}
stroke={1.5}
/>
</div>
<Collapse in={opened}>
<Stack className={classes.collapseContent}>{children}</Stack>
</Collapse>
</div>
</Reorder.Item>
);
};
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',
},
}));

View File

@@ -3,7 +3,7 @@ import { useDisclosure } from '@mantine/hooks';
import { IconChevronDown, IconGripVertical } from '@tabler/icons'; import { IconChevronDown, IconGripVertical } from '@tabler/icons';
import { Reorder, useDragControls } from 'framer-motion'; import { Reorder, useDragControls } from 'framer-motion';
import { FC, ReactNode, useEffect, useRef } from 'react'; import { FC, ReactNode, useEffect, useRef } from 'react';
import { IDraggableListInputValue } from '../../../../widgets/widgets'; import { IDraggableListInputValue } from '../../../../../widgets/widgets';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
container: { container: {
@@ -43,14 +43,14 @@ const useStyles = createStyles((theme) => ({
}, },
})); }));
type DraggableListParams = { type StaticDraggableListParams = {
value: IDraggableListInputValue['defaultValue']; value: IDraggableListInputValue['defaultValue'];
onChange: (value: IDraggableListInputValue['defaultValue']) => void; onChange: (value: IDraggableListInputValue['defaultValue']) => void;
labels: Record<string, string>; labels: Record<string, string>;
children?: Record<string, ReactNode>; children?: Record<string, ReactNode>;
}; };
export const DraggableList: FC<DraggableListParams> = (props) => { export const StaticDraggableList: FC<StaticDraggableListParams> = (props) => {
const keys = props.value.map((v) => v.key); const keys = props.value.map((v) => v.key);
return ( return (

View File

@@ -1,6 +1,8 @@
import { import {
Alert, Alert,
Button, Button,
Card,
Flex,
Group, Group,
MultiSelect, MultiSelect,
NumberInput, NumberInput,
@@ -10,9 +12,10 @@ import {
Switch, Switch,
Text, Text,
TextInput, TextInput,
Title,
} from '@mantine/core'; } from '@mantine/core';
import { ContextModalProps } from '@mantine/modals'; import { ContextModalProps } from '@mantine/modals';
import { IconAlertTriangle } from '@tabler/icons'; import { IconAlertTriangle, IconPlaylistX, IconPlus } from '@tabler/icons';
import { Trans, useTranslation } from 'next-i18next'; import { Trans, useTranslation } from 'next-i18next';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
@@ -22,7 +25,8 @@ import { useColorTheme } from '../../../../tools/color';
import Widgets from '../../../../widgets'; import Widgets from '../../../../widgets';
import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets'; import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets';
import { IWidget } 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 = { export type WidgetEditModalInnerProps = {
widgetId: string; widgetId: string;
@@ -222,7 +226,7 @@ const WidgetOptionTypeSwitch: FC<{
return ( return (
<Stack spacing="xs"> <Stack spacing="xs">
<Text>{t(`descriptor.settings.${key}.label`)}</Text> <Text>{t(`descriptor.settings.${key}.label`)}</Text>
<DraggableList <StaticDraggableList
value={typedVal} value={typedVal}
onChange={(v) => handleChange(key, v)} onChange={(v) => handleChange(key, v)}
labels={mapObject(option.items, (liName) => labels={mapObject(option.items, (liName) =>
@@ -241,7 +245,7 @@ const WidgetOptionTypeSwitch: FC<{
/> />
)) ))
)} )}
</DraggableList> </StaticDraggableList>
</Stack> </Stack>
); );
case 'multiple-text': case 'multiple-text':
@@ -263,6 +267,46 @@ const WidgetOptionTypeSwitch: FC<{
} }
/> />
); );
case 'draggable-editable-list':
const { t: translateDraggableList } = useTranslation('widgets/draggable-list');
return (
<Stack spacing="xs">
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
<DraggableList
items={Array.from(value).map((v: any) => ({
data: v,
}))}
value={value}
onChange={(v) => handleChange(key, v)}
options={option}
/>
{Array.from(value).length === 0 && (
<Card>
<Stack align="center">
<IconPlaylistX size="2rem" />
<Stack align="center" spacing={0}>
<Title order={5}>{translateDraggableList('noEntries.title')}</Title>
<Text>{translateDraggableList('noEntries.text')}</Text>
</Stack>
</Stack>
</Card>
)}
<Flex gap="md">
<Button
onClick={() => {
handleChange('items', [...value, option.create()]);
}}
leftIcon={<IconPlus size={16} />}
variant="default"
fullWidth
>
{translateDraggableList('buttonAdd')}
</Button>
</Flex>
</Stack>
);
/* eslint-enable no-case-declarations */ /* eslint-enable no-case-declarations */
default: default:
return null; return null;

View File

@@ -1,42 +1,38 @@
// disabled due to too many dynamic targets for next image cache import { Image, Loader, createStyles } from '@mantine/core';
/* eslint-disable @next/next/no-img-element */
import Image from 'next/image';
import { createStyles, Loader } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { AppType } from '../../../../../../types/app'; import { IconPhotoOff } from '@tabler/icons';
interface DebouncedAppIconProps { interface DebouncedImageProps {
width: number; width: number;
height: number; height: number;
form: UseFormReturnType<AppType, (values: AppType) => AppType>; src: string;
debouncedWaitPeriod?: number; debouncedWaitPeriod?: number;
} }
export const DebouncedAppIcon = ({ export const DebouncedImage = ({
form, src,
width, width,
height, height,
debouncedWaitPeriod = 1000, debouncedWaitPeriod = 1000,
}: DebouncedAppIconProps) => { }: DebouncedImageProps) => {
const { classes } = useStyles(); const { classes } = useStyles();
const [debouncedIconImageUrl] = useDebouncedValue( const [debouncedIconImageUrl] = useDebouncedValue(src, debouncedWaitPeriod);
form.values.appearance.iconUrl,
debouncedWaitPeriod
);
if (debouncedIconImageUrl !== form.values.appearance.iconUrl) { if (debouncedIconImageUrl !== src) {
return <Loader width={width} height={height} />; return <Loader width={width} height={height} />;
} }
if (debouncedIconImageUrl.length > 0) { if (debouncedIconImageUrl.length > 0) {
return ( return (
<img <Image
placeholder={<IconPhotoOff />}
className={classes.iconImage} className={classes.iconImage}
src={debouncedIconImageUrl} src={debouncedIconImageUrl}
width={width} width={width}
height={height} height={height}
fit="contain"
alt="" alt=""
withPlaceholder
/> />
); );
} }
@@ -47,7 +43,9 @@ export const DebouncedAppIcon = ({
src="/imgs/logo/logo.png" src="/imgs/logo/logo.png"
width={width} width={width}
height={height} height={height}
fit="contain"
alt="" alt=""
withPlaceholder
/> />
); );
}; };

View File

@@ -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 (
<Stack w="100%">
<Autocomplete
nothingFound={
<Stack align="center" spacing="xs" my="lg">
<IconSearch />
<Title order={6} align="center">
{t('appearance.icon.autocomplete.title')}
</Title>
<Text align="center" maw={350}>
{t('appearance.icon.autocomplete.text')}
</Text>
</Stack>
}
icon={<DebouncedImage src={value ?? currentValue} width={20} height={20} />}
rightSection={
(value ?? currentValue).length > 0 ? (
<CloseButton onClick={() => 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) => <ScrollArea {...props} mah={250} />}
onChange={(event) => {
onChange(event);
setValue(event);
}}
dropdownPosition="bottom"
variant="default"
value={value}
withAsterisk
withinPortal
required
/>
{(!data || isLoading) && (
<Group>
<Loader variant="oval" size="sm" />
<Stack spacing={0}>
<Text size="xs" weight="bold">
{t('appearance.icon.noItems.title')}
</Text>
<Text color="dimmed" size="xs">
{t('appearance.icon.noItems.text')}
</Text>
</Stack>
</Group>
)}
</Stack>
);
}
);
const useStyles = createStyles(() => ({
textInput: {
flexGrow: 1,
},
}));
const AutoCompleteItem = forwardRef<HTMLDivElement, ItemProps>(
({ label, size, copyright, url, ...others }: ItemProps, ref) => (
<div ref={ref} {...others}>
<Group noWrap>
<Box
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2],
borderRadius: theme.radius.md,
})}
p={2}
>
<Image src={url} width={30} height={30} fit="contain" />
</Box>
<Stack spacing={0}>
<Text>{label}</Text>
<Group>
<Text color="dimmed" size="xs">
{humanFileSize(size, false)}
</Text>
{copyright && (
<Text color="dimmed" size="xs">
© {copyright}
</Text>
)}
</Group>
</Stack>
</Group>
</div>
)
);
interface ItemProps extends SelectItemProps {
url: string;
group: string;
size: number;
copyright: string | undefined;
}

View File

@@ -40,7 +40,9 @@ export const dashboardNamespaces = [
'modules/media-requests-stats', 'modules/media-requests-stats',
'modules/dns-hole-summary', 'modules/dns-hole-summary',
'modules/dns-hole-controls', 'modules/dns-hole-controls',
'modules/bookmark',
'widgets/error-boundary', 'widgets/error-boundary',
'widgets/draggable-list',
]; ];
export const loginNamespaces = ['authentication/login']; export const loginNamespaces = ['authentication/login'];

View File

@@ -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>
<Stack>
<TextInput
icon={<IconTypography size="1rem" />}
{...form.getInputProps('name')}
label="Name"
withAsterisk
/>
<TextInput
icon={<IconLink size="1rem" />}
{...form.getInputProps('href')}
label="URL"
withAsterisk
/>
<IconSelector
defaultValue={data.iconUrl}
value={form.values.iconUrl}
onChange={(value) => {
form.setFieldValue('iconUrl', value ?? '');
}}
/>
<Button
onClick={() => deleteData()}
leftIcon={<IconTrash size="1rem" />}
variant="light"
type="button"
>
Delete
</Button>
{!form.isValid() && (
<Alert color="red" icon={<IconAlertTriangle size="1rem" />}>
Did not save, because there were validation errors. Please adust your inputs
</Alert>
)}
</Stack>
</form>
);
},
} satisfies IDraggableEditableListInputValue<BookmarkItem>,
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 (
<Stack align="center">
<IconPlaylistX />
<Stack spacing={0}>
<Title order={5} align="center">{t('card.noneFound.title')}</Title>
<Text align="center" size="sm">{t('card.noneFound.text')}</Text>
</Stack>
</Stack>
);
}
switch (widget.properties.layout) {
case 'autoGrid':
return (
<Box className={classes.grid} display="grid">
{widget.properties.items.map((item: BookmarkItem, index) => (
<Card
className={classes.autoGridItem}
key={index}
px="xl"
component="a"
href={item.href}
withBorder
>
<BookmarkItemContent item={item} />
</Card>
))}
</Box>
);
case 'horizontal':
case 'vertical':
return (
<ScrollArea offsetScrollbars type="always" h="100%">
<Flex
style={{ flexDirection: widget.properties.layout === 'vertical' ? 'column' : 'row' }}
gap="md"
>
{widget.properties.items.map((item: BookmarkItem, index) => (
<Card
key={index}
w={widget.properties.layout === 'vertical' ? '100%' : undefined}
px="xl"
component="a"
href={item.href}
withBorder
>
<BookmarkItemContent item={item} />
</Card>
))}
</Flex>
</ScrollArea>
);
default:
return null;
}
}
const BookmarkItemContent = ({ item }: { item: BookmarkItem }) => (
<Group>
<Image src={item.iconUrl} width={30} height={30} fit="contain" withPlaceholder />
<Stack spacing={0}>
<Text>{item.name}</Text>
<Text color="dimmed" size="sm">
{new URL(item.href).hostname}
</Text>
</Stack>
</Group>
);
const useStyles = createStyles(() => ({
grid: {
display: 'grid',
gap: 20,
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
},
autoGridItem: {
flex: '1 1 auto',
},
}));
export default definition;

View File

@@ -13,6 +13,7 @@ import mediaRequestsList from './media-requests/MediaRequestListTile';
import mediaRequestsStats from './media-requests/MediaRequestStatsTile'; import mediaRequestsStats from './media-requests/MediaRequestStatsTile';
import dnsHoleSummary from './dnshole/DnsHoleSummary'; import dnsHoleSummary from './dnshole/DnsHoleSummary';
import dnsHoleControls from './dnshole/DnsHoleControls'; import dnsHoleControls from './dnshole/DnsHoleControls';
import bookmark from './bookmark/BookmarkWidgetTile';
export default { export default {
calendar, calendar,
@@ -30,4 +31,5 @@ export default {
'media-requests-stats': mediaRequestsStats, 'media-requests-stats': mediaRequestsStats,
'dns-hole-summary': dnsHoleSummary, 'dns-hole-summary': dnsHoleSummary,
'dns-hole-controls': dnsHoleControls, 'dns-hole-controls': dnsHoleControls,
bookmark,
}; };

View File

@@ -39,6 +39,7 @@ export type IWidgetOptionValue =
| ISelectOptionValue | ISelectOptionValue
| INumberInputOptionValue | INumberInputOptionValue
| IDraggableListInputValue | IDraggableListInputValue
| IDraggableEditableListInputValue<any>
| IMultipleTextInputOptionValue; | IMultipleTextInputOptionValue;
// Interface for data type // Interface for data type
@@ -107,6 +108,18 @@ export type IDraggableListInputValue = {
>; >;
}; };
export type IDraggableEditableListInputValue<TData extends { id: string }> = {
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 // will show a text-input with a button to add a new line
export type IMultipleTextInputOptionValue = { export type IMultipleTextInputOptionValue = {
type: 'multiple-text'; type: 'multiple-text';