diff --git a/package.json b/package.json index a925ba9e5..a101594b1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "dockerode": "^3.3.2", "fily-publish-gridstack": "^0.0.13", "framer-motion": "^6.5.1", + "html-entities": "^2.3.3", "i18next": "^21.9.1", "js-file-download": "^0.4.12", "next": "^13.1.6", @@ -58,6 +59,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-simple-code-editor": "^0.13.1", + "rss-parser": "^3.12.0", "sabnzbd-api": "^1.5.0", "uuid": "^8.3.2", "yarn": "^1.22.19", diff --git a/public/locales/en/modules/rss.json b/public/locales/en/modules/rss.json new file mode 100644 index 000000000..26800307a --- /dev/null +++ b/public/locales/en/modules/rss.json @@ -0,0 +1,20 @@ +{ + "descriptor": { + "name": "RSS Widget", + "description": "Grabs the items from a RSS feed and displays them. Commonly used for online news", + "settings": { + "title": "Settings for RSS widget", + "rssFeedUrl": { + "label": "RSS feed url" + } + } + }, + "card": { + "errors": { + "general": { + "title": "Unable to retrieve RSS feed", + "text": "There was a problem reaching out the the RSS feed. Make sure that you've configured the feed correctly and use a valid RSS url, that matches the official standard specification. After updating the feed, you may need to save your dashboard and refresh the page." + } + } + } +} \ No newline at end of file diff --git a/src/hooks/widgets/rss/useGetRssFeed.tsx b/src/hooks/widgets/rss/useGetRssFeed.tsx new file mode 100644 index 000000000..2fc9e07e3 --- /dev/null +++ b/src/hooks/widgets/rss/useGetRssFeed.tsx @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; + +export const useGetRssFeed = (feedUrl: string) => + useQuery({ + queryKey: ['rss-feed', feedUrl], + queryFn: async () => { + const response = await fetch('/api/modules/rss'); + return response.json(); + }, + }); diff --git a/src/pages/api/modules/rss/index.ts b/src/pages/api/modules/rss/index.ts new file mode 100644 index 000000000..0a5623158 --- /dev/null +++ b/src/pages/api/modules/rss/index.ts @@ -0,0 +1,93 @@ +import Consola from 'consola'; + +import { getCookie } from 'cookies-next'; + +import { decode } from 'html-entities'; + +import { NextApiRequest, NextApiResponse } from 'next'; + +import Parser from 'rss-parser'; + +import { getConfig } from '../../../../tools/config/getConfig'; +import { Stopwatch } from '../../../../tools/shared/stopwatch'; +import { IRssWidget } from '../../../../widgets/rss/RssWidgetTile'; + +type CustomItem = { + 'media:content': string; + enclosure: { + url: string; + }; +}; + +const parser: Parser = new Parser({ + customFields: { + item: ['media:content', 'enclosure'], + }, +}); + +export const Get = async (request: NextApiRequest, response: NextApiResponse) => { + const configName = getCookie('config-name', { req: request }); + const config = getConfig(configName?.toString() ?? 'default'); + + const rssWidget = config.widgets.find((x) => x.id === 'rss') as IRssWidget | undefined; + + if ( + !rssWidget || + !rssWidget.properties.rssFeedUrl || + rssWidget.properties.rssFeedUrl.length < 1 + ) { + response.status(400).json({ message: 'required widget does not exist' }); + return; + } + + Consola.info('Requesting RSS feed...'); + const stopWatch = new Stopwatch(); + const feed = await parser.parseURL(rssWidget.properties.rssFeedUrl); + Consola.info(`Retrieved RSS feed after ${stopWatch.getEllapsedMilliseconds()} milliseconds`); + + const orderedFeed = { + ...feed, + items: feed.items + .map((item: { title: any; content: any }) => ({ + ...item, + title: item.title ? decode(item.title) : undefined, + content: decode(item.content), + enclosure: createEnclosure(item), + })) + .sort((a: { pubDate: number }, b: { pubDate: number }) => { + if (!a.pubDate || !b.pubDate) { + return 0; + } + + return a.pubDate - b.pubDate; + }) + .slice(0, 20), + }; + + response.status(200).json({ + feed: orderedFeed, + success: orderedFeed?.items !== undefined, + }); +}; + +const createEnclosure = (item: any) => { + if (item.enclosure) { + return item.enclosure; + } + + if (item['media:content']) { + return { + url: item['media:content'].$.url, + }; + } + + return undefined; +}; + +export default async (request: NextApiRequest, response: NextApiResponse) => { + if (request.method === 'GET') { + return Get(request, response); + } + + return response.status(405); +}; diff --git a/src/styles/global.scss b/src/styles/global.scss index ce1377510..13a8c5dda 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -92,3 +92,8 @@ height: 0px; min-height: 0px !important; } + +.scroll-area-w100 .mantine-ScrollArea-viewport > div:nth-of-type(1) { + width: 100%; + display: inherit !important; +} \ No newline at end of file diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index daa866df2..ad11ffe33 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -29,6 +29,7 @@ export const dashboardNamespaces = [ 'modules/torrents-status', 'modules/weather', 'modules/ping', + 'modules/rss', 'modules/docker', 'modules/dashdot', 'modules/overseerr', diff --git a/src/tools/shared/stopwatch.ts b/src/tools/shared/stopwatch.ts new file mode 100644 index 000000000..0d2b2e853 --- /dev/null +++ b/src/tools/shared/stopwatch.ts @@ -0,0 +1,11 @@ +export class Stopwatch { + private startTime: Date; + + constructor() { + this.startTime = new Date(); + } + + getEllapsedMilliseconds() { + return new Date().getTime() - this.startTime.getTime(); + } +} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index aac770ead..d431590ba 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -1,11 +1,12 @@ -import date from './date/DateTile'; import calendar from './calendar/CalendarTile'; import dashdot from './dashDot/DashDotTile'; -import usenet from './useNet/UseNetTile'; -import weather from './weather/WeatherTile'; -import torrent from './torrent/TorrentTile'; +import date from './date/DateTile'; import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile'; +import rss from './rss/RssWidgetTile'; +import torrent from './torrent/TorrentTile'; +import usenet from './useNet/UseNetTile'; import videoStream from './video/VideoStreamTile'; +import weather from './weather/WeatherTile'; export default { calendar, @@ -15,5 +16,6 @@ export default { 'torrents-status': torrent, dlspeed: torrentNetworkTraffic, date, + rss, 'video-stream': videoStream, }; diff --git a/src/widgets/rss/RssWidgetTile.tsx b/src/widgets/rss/RssWidgetTile.tsx new file mode 100644 index 000000000..39b03db68 --- /dev/null +++ b/src/widgets/rss/RssWidgetTile.tsx @@ -0,0 +1,236 @@ +import { + ActionIcon, + Badge, + Card, + Center, + createStyles, + Flex, + Group, + Image, + Loader, + LoadingOverlay, + MediaQuery, + ScrollArea, + Stack, + Text, + Title, + UnstyledButton, +} from '@mantine/core'; +import { useElementSize } from '@mantine/hooks'; +import { + IconBulldozer, + IconCalendarTime, + IconClock, + IconCopyright, + IconRefresh, + IconRss, + IconSpeakerphone, +} from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useGetRssFeed } from '../../hooks/widgets/rss/useGetRssFeed'; +import { sleep } from '../../tools/client/time'; +import { defineWidget } from '../helper'; +import { IWidget } from '../widgets'; + +const definition = defineWidget({ + id: 'rss', + icon: IconRss, + options: { + rssFeedUrl: { + type: 'text', + defaultValue: '', + }, + }, + gridstack: { + minWidth: 2, + minHeight: 2, + maxWidth: 12, + maxHeight: 12, + }, + component: RssTile, +}); + +export type IRssWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface RssTileProps { + widget: IRssWidget; +} + +function RssTile({ widget }: RssTileProps) { + const { t } = useTranslation('modules/rss'); + const { data, isLoading, isFetching, isError, refetch } = useGetRssFeed( + widget.properties.rssFeedUrl + ); + const { classes } = useStyles(); + const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false); + const { ref, height } = useElementSize(); + + if (!data || isLoading) { + return ( +
+ +
+ ); + } + + if (!data.success || isError) { + return ( +
+ + + {t('card.errors.general.title')} + {t('card.errors.general.text')} + +
+ ); + } + + return ( + + + + {data.feed.image ? ( + {data.feed.image.title} + ) : ( + {data.feed.title} + )} + { + setLoadingOverlayVisible(true); + await Promise.all([sleep(1500), refetch()]); + setLoadingOverlayVisible(false); + }} + disabled={isFetching || isLoading} + > + + + + + + + + {data.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.title} + + {item.content} + + + {item.pubDate && } + + + + ))} + + + + + + + + {data.feed.copyright} + + + + + + {data.feed.pubDate} + + + + + + {data.feed.lastBuildDate} + + + {data.feed.feedUrl && ( + + + + Feed URL + + + )} + + + ); +} + +const TimeDisplay = ({ date }: { date: string }) => ( + + + + {date} + + +); + +const useStyles = createStyles(({ colorScheme }) => ({ + backgroundImage: { + position: 'absolute', + width: '100%', + height: '100%', + filter: colorScheme === 'dark' ? 'blur(30px)' : 'blur(15px)', + transform: 'scaleX(-1)', + opacity: colorScheme === 'dark' ? 0.3 : 0.2, + transition: 'ease-in-out 0.2s', + + '&:hover': { + opacity: colorScheme === 'dark' ? 0.4 : 0.3, + filter: 'blur(40px) brightness(0.7)', + }, + }, +})); + +export default definition; diff --git a/yarn.lock b/yarn.lock index af9f4d7e0..07f8b81f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3751,7 +3751,7 @@ __metadata: languageName: node linkType: hard -"entities@npm:^2.0.0": +"entities@npm:^2.0.0, entities@npm:^2.0.3": version: 2.2.0 resolution: "entities@npm:2.2.0" checksum: 19010dacaf0912c895ea262b4f6128574f9ccf8d4b3b65c7e8334ad0079b3706376360e28d8843ff50a78aabcb8f08f0a32dbfacdc77e47ed77ca08b713669b3 @@ -4976,6 +4976,7 @@ __metadata: eslint-plugin-unused-imports: ^2.0.0 fily-publish-gridstack: ^0.0.13 framer-motion: ^6.5.1 + html-entities: ^2.3.3 i18next: ^21.9.1 jest: ^28.1.3 js-file-download: ^0.4.12 @@ -4987,6 +4988,7 @@ __metadata: react: ^18.2.0 react-dom: ^18.2.0 react-simple-code-editor: ^0.13.1 + rss-parser: ^3.12.0 sabnzbd-api: ^1.5.0 sass: ^1.56.1 turbo: ^1.7.4 @@ -5008,6 +5010,13 @@ __metadata: languageName: node linkType: hard +"html-entities@npm:^2.3.3": + version: 2.3.3 + resolution: "html-entities@npm:2.3.3" + checksum: 92521501da8aa5f66fee27f0f022d6e9ceae62667dae93aa6a2f636afa71ad530b7fb24a18d4d6c124c9885970cac5f8a52dbf1731741161002816ae43f98196 + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -7666,6 +7675,16 @@ __metadata: languageName: node linkType: hard +"rss-parser@npm:^3.12.0": + version: 3.12.0 + resolution: "rss-parser@npm:3.12.0" + dependencies: + entities: ^2.0.3 + xml2js: ^0.4.19 + checksum: aa0f0eb2e3a5c70677a1c7cb6c2e96420f12c8963a8bed922ec2ff1bb9dbbb725fc5783be31ca8140154c3d5589ccd31580ced7d32ebd0dda7572f78ce242a41 + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -7748,6 +7767,13 @@ __metadata: languageName: node linkType: hard +"sax@npm:>=0.6.0": + version: 1.2.4 + resolution: "sax@npm:1.2.4" + checksum: d3df7d32b897a2c2f28e941f732c71ba90e27c24f62ee918bd4d9a8cfb3553f2f81e5493c7f0be94a11c1911b643a9108f231dd6f60df3fa9586b5d2e3e9e1fe + languageName: node + linkType: hard + "scheduler@npm:^0.23.0": version: 0.23.0 resolution: "scheduler@npm:0.23.0" @@ -8842,6 +8868,23 @@ __metadata: languageName: node linkType: hard +"xml2js@npm:^0.4.19": + version: 0.4.23 + resolution: "xml2js@npm:0.4.23" + dependencies: + sax: ">=0.6.0" + xmlbuilder: ~11.0.0 + checksum: ca0cf2dfbf6deeaae878a891c8fbc0db6fd04398087084edf143cdc83d0509ad0fe199b890f62f39c4415cf60268a27a6aed0d343f0658f8779bd7add690fa98 + languageName: node + linkType: hard + +"xmlbuilder@npm:~11.0.0": + version: 11.0.1 + resolution: "xmlbuilder@npm:11.0.1" + checksum: 7152695e16f1a9976658215abab27e55d08b1b97bca901d58b048d2b6e106b5af31efccbdecf9b07af37c8377d8e7e821b494af10b3a68b0ff4ae60331b415b0 + languageName: node + linkType: hard + "xtend@npm:^4.0.0": version: 4.0.2 resolution: "xtend@npm:4.0.2"