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,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 { 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<string, string>;
children?: Record<string, ReactNode>;
};
export const DraggableList: FC<DraggableListParams> = (props) => {
export const StaticDraggableList: FC<StaticDraggableListParams> = (props) => {
const keys = props.value.map((v) => v.key);
return (
@@ -64,10 +64,10 @@ export const DraggableList: FC<DraggableListParams> = (props) => {
as="div"
>
{props.value.map((item) => (
<ListItem key={item.key} item={item} label={props.labels[item.key]}>
{props.children?.[item.key]}
</ListItem>
))}
<ListItem key={item.key} item={item} label={props.labels[item.key]}>
{props.children?.[item.key]}
</ListItem>
))}
</Reorder.Group>
</div>
);

View File

@@ -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 (
<Stack spacing="xs">
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
<DraggableList
<StaticDraggableList
value={typedVal}
onChange={(v) => handleChange(key, v)}
labels={mapObject(option.items, (liName) =>
@@ -241,7 +245,7 @@ const WidgetOptionTypeSwitch: FC<{
/>
))
)}
</DraggableList>
</StaticDraggableList>
</Stack>
);
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 */
default:
return null;