diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 4f2e4f0ff..c62f22acf 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -10,6 +10,7 @@ "changePosition": "Change position", "remove": "Remove", "removeConfirm": "Are you sure that you want to remove {{item}}?", + "createItem": "+ create {{item}}", "sections": { "settings": "Settings", "dangerZone": "Danger zone" diff --git a/public/locales/en/modules/rss.json b/public/locales/en/modules/rss.json index df52af4af..3c50414d2 100644 --- a/public/locales/en/modules/rss.json +++ b/public/locales/en/modules/rss.json @@ -5,15 +5,19 @@ "settings": { "title": "Settings for RSS widget", "rssFeedUrl": { - "label": "RSS feed url" + "label": "RSS feeds urls", + "description": "The urls of the RSS feeds you want to display from." + }, + "refreshInterval": { + "label": "Refresh interval (in minutes)" } - } - }, - "card": { - "errors": { - "general": { - "title": "Unable to retrieve RSS feed", - "text": "There was a problem reaching out the RSS feed. Make sure that you have correctly configured the RSS feed using a valid URL. URLs should match the official specification. After updating the feed, you may need to refresh the dashboard." + }, + "card": { + "errors": { + "general": { + "title": "Unable to retrieve RSS feed", + "text": "There was a problem reaching out the RSS feed. Make sure that you have correctly configured the RSS feed using a valid URL. URLs should match the official specification. After updating the feed, you may need to refresh the dashboard." + } } } } diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx index e89d32176..03c2d6b34 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx @@ -184,6 +184,7 @@ const WidgetOptionTypeSwitch: FC<{ case 'slider': return ( + {t(`descriptor.settings.${key}.label`)} ); + case 'multiple-text': + return ( + ({ value: name, label: name }))} + label={t(`descriptor.settings.${key}.label`)} + description={t(`descriptor.settings.${key}.description`)} + defaultValue={value as string[]} + withinPortal + searchable + creatable + getCreateLabel={(query) => t('common:createItem', query)} + onChange={(values) => + handleChange( + key, + values.map((item: string) => item) + ) + } + /> + ); /* eslint-enable no-case-declarations */ default: return null; diff --git a/src/pages/api/modules/rss/index.ts b/src/pages/api/modules/rss/index.ts index 7ea57572b..9c4284356 100644 --- a/src/pages/api/modules/rss/index.ts +++ b/src/pages/api/modules/rss/index.ts @@ -1,17 +1,13 @@ -import Consola from 'consola'; - -import { getCookie } from 'cookies-next'; - -import { decode } from 'html-entities'; - import { NextApiRequest, NextApiResponse } from 'next'; - +import Consola from 'consola'; +import { getCookie } from 'cookies-next'; +import { decode } from 'html-entities'; import Parser from 'rss-parser'; - import { z } from 'zod'; + import { getConfig } from '../../../../tools/config/getConfig'; -import { IRssWidget } from '../../../../widgets/rss/RssWidgetTile'; import { Stopwatch } from '../../../../tools/shared/time/stopwatch.tool'; +import { IRssWidget } from '../../../../widgets/rss/RssWidgetTile'; type CustomItem = { 'media:content': string; @@ -28,6 +24,7 @@ const parser: Parser = new Parser({ const getQuerySchema = z.object({ widgetId: z.string().uuid(), + feedUrl: z.string(), }); export const Get = async (request: NextApiRequest, response: NextApiResponse) => { @@ -44,7 +41,6 @@ export const Get = async (request: NextApiRequest, response: NextApiResponse) => const rssWidget = config.widgets.find( (x) => x.type === 'rss' && x.id === parseResult.data.widgetId ) as IRssWidget | undefined; - if ( !rssWidget || !rssWidget.properties.rssFeedUrl || @@ -54,9 +50,9 @@ export const Get = async (request: NextApiRequest, response: NextApiResponse) => return; } - Consola.info('Requesting RSS feed...'); + Consola.info(`Requesting RSS feed at url ${parseResult.data.feedUrl}`); const stopWatch = new Stopwatch(); - const feed = await parser.parseURL(rssWidget.properties.rssFeedUrl); + const feed = await parser.parseURL(parseResult.data.feedUrl); Consola.info(`Retrieved RSS feed after ${stopWatch.getEllapsedMilliseconds()} milliseconds`); const orderedFeed = { diff --git a/src/widgets/rss/RssWidgetTile.tsx b/src/widgets/rss/RssWidgetTile.tsx index b1eab57cd..132c07b81 100644 --- a/src/widgets/rss/RssWidgetTile.tsx +++ b/src/widgets/rss/RssWidgetTile.tsx @@ -1,44 +1,42 @@ +import Link from 'next/link'; import { ActionIcon, Badge, Card, Center, - createStyles, Flex, Group, Image, Loader, - LoadingOverlay, MediaQuery, ScrollArea, Stack, Text, Title, + createStyles, } from '@mantine/core'; -import { - IconBulldozer, - IconCalendarTime, - IconClock, - IconCopyright, - IconRefresh, - IconRss, - IconSpeakerphone, -} from '@tabler/icons'; +import { IconClock, IconRefresh, IconRss } from '@tabler/icons'; import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { useTranslation } from 'next-i18next'; -import Link from 'next/link'; -import { useState } from 'react'; -import { IWidget } from '../widgets'; + import { defineWidget } from '../helper'; +import { IWidget } from '../widgets'; const definition = defineWidget({ id: 'rss', icon: IconRss, options: { rssFeedUrl: { - type: 'text', - defaultValue: '', + type: 'multiple-text', + defaultValue: ['https://github.com/ajnart/homarr/tags.atom'], + }, + refreshInterval: { + type: 'slider', + defaultValue: 30, + min: 15, + max: 300, + step: 15, }, }, gridstack: { @@ -56,34 +54,45 @@ interface RssTileProps { widget: IRssWidget; } -export const useGetRssFeed = (feedUrl: string, widgetId: string) => +export const useGetRssFeeds = (feedUrls: string[], refreshInterval: number, widgetId: string) => useQuery({ - queryKey: ['rss-feed', feedUrl], + queryKey: ['rss-feeds', feedUrls], + // Cache the results for 24 hours + cacheTime: 1000 * 60 * 60 * 24, + staleTime: 1000 * 60 * refreshInterval, queryFn: async () => { - const response = await fetch(`/api/modules/rss?widgetId=${widgetId}`); - return response.json(); + const responses = await Promise.all( + feedUrls.map((feedUrl) => + fetch( + `/api/modules/rss?widgetId=${widgetId}&feedUrl=${encodeURIComponent(feedUrl)}` + ).then((response) => response.json()) + ) + ); + return responses; }, }); function RssTile({ widget }: RssTileProps) { const { t } = useTranslation('modules/rss'); - const { data, isLoading, isFetching, isError, refetch } = useGetRssFeed( + const { data, isLoading, isFetching, isError, refetch } = useGetRssFeeds( widget.properties.rssFeedUrl, + widget.properties.refreshInterval, widget.id ); const { classes } = useStyles(); - const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false); function formatDate(input: string): string { // Parse the input date as a local date - const inputDate = dayjs(new Date(input)); - const now = dayjs(); // Current date and time - - // The difference between the input date and now - const difference = now.diff(inputDate, 'ms'); - const duration = dayjs.duration(difference, 'ms'); - const humanizedDuration = duration.humanize(); - return `${humanizedDuration} ago`; + try { + const inputDate = dayjs(new Date(input)); + const now = dayjs(); // Current date and time + const difference = now.diff(inputDate, 'ms'); + const duration = dayjs.duration(difference, 'ms'); + const humanizedDuration = duration.humanize(); + return `${humanizedDuration} ago`; + } catch (e) { + return 'Error'; + } } if (!data || isLoading) { @@ -94,7 +103,7 @@ function RssTile({ widget }: RssTileProps) { ); } - if (!data.success || isError) { + if (data.length < 1 || isError) { return (
@@ -108,133 +117,95 @@ function RssTile({ widget }: RssTileProps) { return ( - - {data.feed.title && {data.feed.title}} - - - {data.feed.items.map((item: any, index: number) => ( - - {item.enclosure && ( - // eslint-disable-next-line @next/next/no-img-element - backdrop - )} - - + + {data.map((feed, index) => ( + + {feed.feed.items.map((item: any, index: number) => ( + {item.enclosure && ( - - - + // eslint-disable-next-line @next/next/no-img-element + backdrop )} - - {item.categories && ( - - {item.categories.map((category: any, categoryIndex: number) => ( - {category} - ))} - + + + {item.enclosure && ( + + + )} + + {item.categories && ( + + {item.categories.map((category: any, categoryIndex: number) => ( + {category} + ))} + + )} - {item.title} - - {item.content} - + {item.title} + + {item.content} + - {item.pubDate && } + {item.pubDate && ( + + )} + - - - ))} - + + ))} + + ))} - - {data.feed.copyright && ( - - - - {data.feed.copyright} - - - )} - {data.feed.pubDate && ( - - - - {data.feed.pubDate} - - - )} - {data.feed.lastBuildDate && ( - - - - {formatDate(data.feed.lastBuildDate)} - - - )} - {data.feed.feedUrl && ( - - - - Feed URL - - - )} - refetch()} - bottom={10} - styles={{ - root: { - borderColor: 'red', - }, - }} - > - {data.feed.image ? ( - {data.feed.image.title} - ) : ( - - )} - - + refetch()} + bottom={10} + styles={{ + root: { + borderColor: 'red', + }, + }} + > + {isFetching ? : } + ); } -const TimeDisplay = ({ date }: { date: string }) => ( +const InfoDisplay = ({ date, title }: { date: string; title: string | undefined }) => ( {date} + {title && ( + + {title} + + )} ); diff --git a/src/widgets/widgets.ts b/src/widgets/widgets.ts index 998413fea..a5b6b9729 100644 --- a/src/widgets/widgets.ts +++ b/src/widgets/widgets.ts @@ -1,3 +1,4 @@ +import React from 'react'; import { MultiSelectProps, NumberInputProps, @@ -7,7 +8,7 @@ import { TextInputProps, } from '@mantine/core'; import { TablerIcon } from '@tabler/icons'; -import React from 'react'; + import { AreaType } from '../types/area'; import { ShapeType } from '../types/shape'; @@ -37,7 +38,8 @@ export type IWidgetOptionValue = | ISliderInputOptionValue | ISelectOptionValue | INumberInputOptionValue - | IDraggableListInputValue; + | IDraggableListInputValue + | IMultipleTextInputOptionValue; // Interface for data type interface DataType { @@ -105,6 +107,13 @@ export type IDraggableListInputValue = { >; }; +// will show a text-input with a button to add a new line +export type IMultipleTextInputOptionValue = { + type: 'multiple-text'; + defaultValue: string[]; + inputProps?: Partial; +}; + // is used to type the widget definitions which will be used to display all widgets export type IWidgetDefinition = { id: TKey;