From 3efe18d06f02cae39e683b382f1cfaff5270a819 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 15 May 2023 16:20:48 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20Invalidate=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds the functionality of query invalidation - when a config is created - when the calendar options are changed It also makes it so the calendar doesn't update if the widget is currently being edited --- src/tools/config/mutations/useCopyConfigMutation.tsx | 7 +++++-- src/widgets/calendar/CalendarTile.tsx | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/tools/config/mutations/useCopyConfigMutation.tsx b/src/tools/config/mutations/useCopyConfigMutation.tsx index 2aad78a68..d5cc40f1a 100644 --- a/src/tools/config/mutations/useCopyConfigMutation.tsx +++ b/src/tools/config/mutations/useCopyConfigMutation.tsx @@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query'; import { useTranslation } from 'next-i18next'; import { useConfigContext } from '../../../config/provider'; import { ConfigType } from '../../../types/config'; +import { queryClient } from '../../server/configurations/tanstack/queryClient.tool'; export const useCopyConfigMutation = (configName: string) => { const { config } = useConfigContext(); @@ -14,13 +15,15 @@ export const useCopyConfigMutation = (configName: string) => { mutationFn: () => fetchCopy(configName, config), onSuccess() { showNotification({ - title: t('modal.events.configCopied.title'), + title: t('modal.copy.events.configCopied.title'), icon: , color: 'green', autoClose: 1500, radius: 'md', - message: t('modal.events.configCopied.message', { configName }), + message: t('modal.copy.events.configCopied.message', { configName }), }); + // Invalidate a query to fetch new config + queryClient.invalidateQueries(['config/get-all']); }, onError() { showNotification({ diff --git a/src/widgets/calendar/CalendarTile.tsx b/src/widgets/calendar/CalendarTile.tsx index af4f3ae2d..d20b5d70e 100644 --- a/src/widgets/calendar/CalendarTile.tsx +++ b/src/widgets/calendar/CalendarTile.tsx @@ -10,6 +10,7 @@ import { IWidget } from '../widgets'; import { CalendarDay } from './CalendarDay'; import { getBgColorByDateAndTheme } from './bg-calculator'; import { MediasType } from './type'; +import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore'; const definition = defineWidget({ id: 'calendar', @@ -52,10 +53,15 @@ function CalendarTile({ widget }: CalendarTileProps) { const { colorScheme } = useMantineTheme(); const { name: configName } = useConfigContext(); const [month, setMonth] = useState(new Date()); + const isEditMode = useEditModeStore((x) => x.enabled); const { data: medias } = useQuery({ - queryKey: ['calendar/medias', { month: month.getMonth(), year: month.getFullYear() }], + queryKey: [ + 'calendar/medias', + { month: month.getMonth(), year: month.getFullYear(), v4: widget.properties.useSonarrv4 }, + ], staleTime: 1000 * 60 * 60 * 5, + enabled: isEditMode === false, queryFn: async () => (await ( await fetch( From f34d1d0096f77412df51699f3ed3e843294c1a82 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 15 May 2023 16:22:13 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=99=88=20Add=20all=20languages=20ot?= =?UTF-8?q?her=20than=20EN=20to=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This will allow for easier search in IDEs - vscode hides gitignored files - new contributors won't push translations using JSON anymore --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 991371dfe..a7129a95d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,8 @@ data/configs !.yarn/versions #envfiles -.env \ No newline at end of file +.env + +#Languages other than 'en' +public/locales/* +!public/locales/en \ No newline at end of file From 86913d2244e02f1d48ec6813580a67cd7e77ea70 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 15 May 2023 16:22:33 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=8C=90=20Fix=20missing=20string=20i?= =?UTF-8?q?n=20`add-app.json`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/layout/modals/add-app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/en/layout/modals/add-app.json b/public/locales/en/layout/modals/add-app.json index 403babeaa..551f84dad 100644 --- a/public/locales/en/layout/modals/add-app.json +++ b/public/locales/en/layout/modals/add-app.json @@ -39,7 +39,7 @@ "appearance": { "icon": { "label": "App Icon", - "description": "", + "description": "Start typing to find an icon. You can also paste an image URL to use a custom icon.", "autocomplete": { "title": "No results found", "text": "Try to use a more specific search term. If you can't find your desired icon, paste the image URL above for a custom icon" From 6901c985fdc4b8fb77d53ef8a0dd8e302ddf5d5c Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 15 May 2023 16:22:58 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=A8=20Add=20notifications=20when=20?= =?UTF-8?q?chaning=20configs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Config/ConfigChanger.tsx | 31 ++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/components/Config/ConfigChanger.tsx b/src/components/Config/ConfigChanger.tsx index 640c9a386..08ab2f9d9 100644 --- a/src/components/Config/ConfigChanger.tsx +++ b/src/components/Config/ConfigChanger.tsx @@ -5,6 +5,8 @@ import { setCookie } from 'cookies-next'; import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; import { useState } from 'react'; +import { notifications } from '@mantine/notifications'; +import { IconCheck } from '@tabler/icons'; import { useConfigContext } from '../../config/provider'; export default function ConfigChanger() { @@ -23,10 +25,33 @@ export default function ConfigChanger() { sameSite: 'strict', }); setActiveConfig(value); - toggle(); - router.push(`/${value}`); - setConfigName(value); + notifications.show({ + id: 'load-data', + loading: true, + title: t('configSelect.loadingNew'), + radius: 'md', + withCloseButton: false, + message: t('configSelect.pleaseWait'), + autoClose: false, + }); + + setTimeout(() => { + notifications.update({ + id: 'load-data', + color: 'teal', + radius: 'md', + withCloseButton: false, + title: t('configSelect.loadingNew'), + message: t('configSelect.pleaseWait'), + icon: , + autoClose: 2000, + }); + }, 3000); + setTimeout(() => { + router.push(`/${value}`); + setConfigName(value); + }, 500); }; // If configlist is empty, return a loading indicator From c2c0d0bb55b05fd14e3b45dcb419ae1a2dabcc6e Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 15 May 2023 16:23:36 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=90=9B=20Fix=20`IconSelector`=20siz?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the `withinPortal` option so that it doesn't clip into the underlying modal --- .../Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx index c9c01035d..d9fc60ad3 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx @@ -56,6 +56,7 @@ export const IconSelector = ({ return ( From 9f4f3794b049d3286251bf53be824c8af3f10125 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 15 May 2023 16:24:05 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=90=9B=20Fix=20small=20bug=20when?= =?UTF-8?q?=20deleting=20a=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It would always return error previously. That has been fixed. --- src/components/Settings/Common/Config/ConfigActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Settings/Common/Config/ConfigActions.tsx b/src/components/Settings/Common/Config/ConfigActions.tsx index 6ff6ac4db..abf3bdfcc 100644 --- a/src/components/Settings/Common/Config/ConfigActions.tsx +++ b/src/components/Settings/Common/Config/ConfigActions.tsx @@ -63,7 +63,7 @@ export default function ConfigActions() { onConfirm: async () => { const response = await mutateAsync(); - if (response.message) { + if (response.error) { showNotification({ title: t('buttons.delete.notifications.deleteFailedDefaultConfig.title'), message: t('buttons.delete.notifications.deleteFailedDefaultConfig.message'), From 599ccda1ed59e8dd88f0728c6ade7476d3a9cbc8 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 15 May 2023 16:24:22 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=E2=9C=A8=20Add=20stale=20time=20to=20`us?= =?UTF-8?q?eGetDashboardIcons`=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/icons/useGetDashboardIcons.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/icons/useGetDashboardIcons.tsx b/src/hooks/icons/useGetDashboardIcons.tsx index 707461eec..bd1cdc6f7 100644 --- a/src/hooks/icons/useGetDashboardIcons.tsx +++ b/src/hooks/icons/useGetDashboardIcons.tsx @@ -12,5 +12,6 @@ export const useGetDashboardIcons = () => refetchOnMount: false, // Cache for infinity, refetch every so often. cacheTime: Infinity, + staleTime: 1000 * 60 * 5, // 5 minutes refetchOnWindowFocus: false, }); From 27c0ef608e9477dd50b717d7ada1956f8f9d2894 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 25 Apr 2023 16:06:15 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=90=9B=20Fix=20a=20small=20bug=20wi?= =?UTF-8?q?th=20the=20ping=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/api/modules/ping.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/api/modules/ping.ts b/src/pages/api/modules/ping.ts index 7c2a1bbe1..11f43abdc 100644 --- a/src/pages/api/modules/ping.ts +++ b/src/pages/api/modules/ping.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import https from 'https'; import Consola from 'consola'; import { NextApiRequest, NextApiResponse } from 'next'; @@ -12,14 +12,15 @@ async function Get(req: NextApiRequest, res: NextApiResponse) { .then((response) => { res.status(response.status).json(response.statusText); }) - .catch((error) => { + .catch((error: AxiosError) => { if (error.response) { - Consola.error(`Unexpected response: ${error.response.data}`); + Consola.warn(`Unexpected response: ${error.message}`); res.status(error.response.status).json(error.response.statusText); } else if (error.code === 'ECONNABORTED') { res.status(408).json('Request Timeout'); } else { - res.status(error.response ? error.response.status : 500).json('Server Error'); + Consola.error(`Unexpected error: ${error.message}`); + res.status(500).json('Internal Server Error'); } }); // // Make a request to the URL From a982773c0db895e7d89679726841598fc2e2e0ab Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Mon, 15 May 2023 09:54:50 +0200 Subject: [PATCH 09/11] =?UTF-8?q?=E2=9C=A8=20Bookmark=20widget=20(#890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚧 Bookmark widget * ✨ Add input type Co-authored-by: Meier Lukas * ✨ 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 --- public/locales/en/modules/bookmark.json | 21 ++ public/locales/en/widgets/draggable-list.json | 7 + .../Modals/EditAppModal/EditAppModal.tsx | 4 +- .../Tabs/AppereanceTab/AppereanceTab.tsx | 32 +-- .../Tabs/AppereanceTab/IconSelector.tsx | 165 ----------- .../Tiles/Widgets/Inputs/DraggableList.tsx | 138 ++++++++++ .../StaticDraggableList.tsx} | 14 +- .../Tiles/Widgets/WidgetsEditModal.tsx | 52 +++- .../DebouncedImage.tsx} | 32 +-- src/components/IconSelector/IconSelector.tsx | 177 ++++++++++++ src/tools/server/translation-namespaces.ts | 2 + src/widgets/bookmark/BookmarkWidgetTile.tsx | 260 ++++++++++++++++++ src/widgets/index.ts | 2 + src/widgets/widgets.ts | 13 + 14 files changed, 708 insertions(+), 211 deletions(-) create mode 100644 public/locales/en/modules/bookmark.json create mode 100644 public/locales/en/widgets/draggable-list.json delete mode 100644 src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx create mode 100644 src/components/Dashboard/Tiles/Widgets/Inputs/DraggableList.tsx rename src/components/Dashboard/Tiles/Widgets/{DraggableList.tsx => Inputs/StaticDraggableList.tsx} (91%) rename src/components/{Dashboard/Modals/EditAppModal/Tabs/Shared/DebouncedAppIcon.tsx => IconSelector/DebouncedImage.tsx} (51%) create mode 100644 src/components/IconSelector/IconSelector.tsx create mode 100644 src/widgets/bookmark/BookmarkWidgetTile.tsx diff --git a/public/locales/en/modules/bookmark.json b/public/locales/en/modules/bookmark.json new file mode 100644 index 000000000..377e4bf9d --- /dev/null +++ b/public/locales/en/modules/bookmark.json @@ -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" + } + } +} diff --git a/public/locales/en/widgets/draggable-list.json b/public/locales/en/widgets/draggable-list.json new file mode 100644 index 000000000..ed1676e86 --- /dev/null +++ b/public/locales/en/widgets/draggable-list.json @@ -0,0 +1,7 @@ +{ + "noEntries": { + "title": "No entries", + "text": "Use the buttons below to add more entries" + }, + "buttonAdd": "Add" +} diff --git a/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx b/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx index 752f08732..ce48985f8 100644 --- a/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/EditAppModal.tsx @@ -15,13 +15,13 @@ import { useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; import { AppType } from '../../../../types/app'; +import { DebouncedImage } from '../../../IconSelector/DebouncedImage'; import { useEditModeStore } from '../../Views/useEditModeStore'; import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab'; import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab'; import { GeneralTab } from './Tabs/GeneralTab/GeneralTab'; import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab'; import { NetworkTab } from './Tabs/NetworkTab/NetworkTab'; -import { DebouncedAppIcon } from './Tabs/Shared/DebouncedAppIcon'; import { EditAppModalTab } from './Tabs/type'; const appUrlRegex = @@ -138,7 +138,7 @@ export const EditAppModal = ({ ))} - + {form.values.name ?? 'New App'} diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx index 04aba2e63..0ffd0a7cf 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/AppereanceTab.tsx @@ -1,10 +1,9 @@ import { Flex, Tabs } from '@mantine/core'; import { UseFormReturnType } from '@mantine/form'; import { useDebouncedValue } from '@mantine/hooks'; -import { useEffect } from 'react'; -import { useGetDashboardIcons } from '../../../../../../hooks/icons/useGetDashboardIcons'; +import { useEffect, useRef } from 'react'; import { AppType } from '../../../../../../types/app'; -import { IconSelector } from './IconSelector'; +import { IconSelector } from '../../../../../IconSelector/IconSelector'; interface AppearanceTabProps { form: UseFormReturnType AppType>; @@ -17,8 +16,7 @@ export const AppearanceTab = ({ disallowAppNameProgagation, allowAppNamePropagation, }: AppearanceTabProps) => { - const { data, isLoading } = useGetDashboardIcons(); - + const iconSelectorRef = useRef(); const [debouncedValue] = useDebouncedValue(form.values.name, 500); useEffect(() => { @@ -26,26 +24,28 @@ export const AppearanceTab = ({ return; } - const matchingDebouncedIcon = data - ?.flatMap((x) => x.entries) - .find((x) => replaceCharacters(x.name.split('.')[0]) === replaceCharacters(debouncedValue)); - - if (!matchingDebouncedIcon) { + if (!iconSelectorRef.current) { return; } - form.setFieldValue('appearance.iconUrl', matchingDebouncedIcon.url); + const currentRef = iconSelectorRef.current as { + chooseFirstOrDefault: (debouncedValue: string) => void; + }; + + currentRef.chooseFirstOrDefault(debouncedValue); }, [debouncedValue]); return ( { + form.setFieldValue('appearance.iconUrl', value); + disallowAppNameProgagation(); + }} + value={form.values.appearance.iconUrl} + ref={iconSelectorRef} /> diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx deleted file mode 100644 index d9fc60ad3..000000000 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/AppereanceTab/IconSelector.tsx +++ /dev/null @@ -1,165 +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>; - 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 ( - - - - - {t('appearance.icon.autocomplete.title')} - - - {t('appearance.icon.autocomplete.text')} - - - } - icon={} - rightSection={ - form.values.appearance.iconUrl.length > 0 ? ( - 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) => } - dropdownPosition="bottom" - required - onChange={(event) => { - if (allowAppNamePropagation) { - disallowAppNameProgagation(); - } - form.setFieldValue('appearance.iconUrl', event); - }} - value={form.values.appearance.iconUrl} - /> - {(!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/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 ( +
+ + } + {...form.getInputProps('name')} + label="Name" + withAsterisk + /> + } + {...form.getInputProps('href')} + label="URL" + withAsterisk + /> + { + form.setFieldValue('iconUrl', value ?? ''); + }} + /> + + {!form.isValid() && ( + }> + Did not save, because there were validation errors. Please adust your inputs + + )} + +
+ ); + }, + } 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'; From 45db7dfcb0beffc3fe9484cf2cca1b3ced385294 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 24 Apr 2023 08:01:40 +0000 Subject: [PATCH 10/11] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..39a2b6e9a --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} From 109e53df5dadabc426b2089524d3b061e91196ce Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 16 May 2023 15:00:35 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=9A=A8=20Fix=20compilation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/components/Config/ConfigChanger.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 317f56169..9a7c39a6e 100644 --- a/package.json +++ b/package.json @@ -121,4 +121,4 @@ "minimumChangeThreshold": 0, "showDetails": true } -} \ No newline at end of file +} diff --git a/src/components/Config/ConfigChanger.tsx b/src/components/Config/ConfigChanger.tsx index 08ab2f9d9..4826fcdd8 100644 --- a/src/components/Config/ConfigChanger.tsx +++ b/src/components/Config/ConfigChanger.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; import { useState } from 'react'; import { notifications } from '@mantine/notifications'; -import { IconCheck } from '@tabler/icons'; +import { IconCheck } from '@tabler/icons-react'; import { useConfigContext } from '../../config/provider'; export default function ConfigChanger() { @@ -44,7 +44,7 @@ export default function ConfigChanger() { withCloseButton: false, title: t('configSelect.loadingNew'), message: t('configSelect.pleaseWait'), - icon: , + icon: , autoClose: 2000, }); }, 3000);