(
- ({ 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 (
+
+ );
+ },
+ } 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';