mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 23:15:46 +01:00
✨ 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:
21
public/locales/en/modules/bookmark.json
Normal file
21
public/locales/en/modules/bookmark.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
public/locales/en/widgets/draggable-list.json
Normal file
7
public/locales/en/widgets/draggable-list.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"noEntries": {
|
||||||
|
"title": "No entries",
|
||||||
|
"text": "Use the buttons below to add more entries"
|
||||||
|
},
|
||||||
|
"buttonAdd": "Add"
|
||||||
|
}
|
||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
138
src/components/Dashboard/Tiles/Widgets/Inputs/DraggableList.tsx
Normal file
138
src/components/Dashboard/Tiles/Widgets/Inputs/DraggableList.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 (
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
177
src/components/IconSelector/IconSelector.tsx
Normal file
177
src/components/IconSelector/IconSelector.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
|
|||||||
260
src/widgets/bookmark/BookmarkWidgetTile.tsx
Normal file
260
src/widgets/bookmark/BookmarkWidgetTile.tsx
Normal 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;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user