Files
homarr/src/widgets/bookmark/BookmarkWidgetTile.tsx
2023-07-23 18:23:10 +02:00

320 lines
8.8 KiB
TypeScript

import {
Alert,
Box,
Button,
Card,
Flex,
Group,
Image,
ScrollArea,
Stack,
Switch,
Text,
TextInput,
Title,
createStyles,
useMantineTheme,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import {
IconAlertTriangle,
IconBookmark,
IconLink,
IconPlaylistX,
IconTrash,
IconTypography,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useEffect } from 'react';
import { v4 } from 'uuid';
import { z } from 'zod';
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
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;
openNewTab: boolean;
hideLink: boolean;
}
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',
openNewTab: false,
hideLink: false,
};
},
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, openNewTab: form.values.openNewTab });
}, [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 ?? '');
}}
/>
<Switch
{...form.getInputProps('openNewTab')}
label="Open in new tab"
checked={form.values.openNewTab}
/>
<Switch
{...form.getInputProps('hideLink')}
label="Hide link"
checked={form.values.hideLink}
/>
<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();
const { enabled: isEditModeEnabled } = useEditModeStore();
const { fn, colors, colorScheme } = useMantineTheme();
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} mr={isEditModeEnabled ? 'xl' : undefined} h="100%">
{widget.properties.items.map((item: BookmarkItem, index) => (
<Card
className={classes.autoGridItem}
key={index}
px="xl"
component="a"
href={item.href}
target={item.openNewTab ? '_blank' : undefined}
withBorder
sx={{
backgroundColor: colorScheme === 'dark' ? colors.dark[5].concat('80') : colors.blue[0].concat('80'),
'&:hover': { backgroundColor: fn.primaryColor().concat('80'),}, //'40' = 25% opacity
}}
>
<BookmarkItemContent item={item} />
</Card>
))}
</Box>
);
case 'horizontal':
case 'vertical':
return (
<ScrollArea
scrollbarSize={8}
type="auto"
h="100%"
mr={isEditModeEnabled ? 'xl' : undefined}
styles={{
viewport:{
//mantine being mantine again... this might break
'& div[style="min-width: 100%; display: table;"]':{
height:'100%',
},
},
}}
>
<Flex
style={{ flexDirection: widget.properties.layout === 'vertical' ? 'column' : 'row' }}
gap="0"
h="100%"
>
{widget.properties.items.map((item: BookmarkItem, index) => (
<Card
key={index}
px="md"
py="0"
component="a"
href={item.href}
target={item.openNewTab ? '_blank' : undefined}
sx={{
border:'0.1rem solid transparent',
borderRadius:'0',
borderBottomColor:(widget.properties.layout === 'vertical' && index < widget.properties.items.length - 1) ? '#343740' : 'transparent',
borderRightColor:(widget.properties.layout === 'horizontal' && index < widget.properties.items.length - 1) ? '#343740' : 'transparent',
backgroundColor: 'transparent',
'&:hover': { backgroundColor: fn.primaryColor().concat('40'),}, //'40' = 25% opacity
flex:'1 1 auto'
}}
display="flex"
>
<BookmarkItemContent item={item}/>
</Card>
))}
</Flex>
</ScrollArea>
);
default:
return null;
}
}
const BookmarkItemContent = ({ item }: { item: BookmarkItem }) => {
const { colorScheme } = useMantineTheme();
return (
<Group spacing="0rem 1rem">
<Image src={item.iconUrl} width={47} height={47} fit="contain" withPlaceholder />
<Stack spacing={0}>
<Text size="md">{item.name}</Text>
<Text
color={colorScheme === 'dark' ? "gray.6" : "gray.7"}
size="sm"
hidden={item.hideLink}
>
{new URL(item.href).hostname}
</Text>
</Stack>
</Group>
)};
const useStyles = createStyles(() => ({
grid: {
display: 'grid',
gap: 10,
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
},
autoGridItem: {
flex: '1 1 auto',
},
}));
export default definition;