feat: add rss widget (#760)
Co-authored-by: SeDemal <demal.sebastien@bluewin.ch>
This commit is contained in:
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
|
||||
import "@homarr/ui/styles.css";
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "~/styles/scroll-area.scss";
|
||||
|
||||
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
|
||||
|
||||
|
||||
4
apps/nextjs/src/styles/scroll-area.scss
Normal file
4
apps/nextjs/src/styles/scroll-area.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.scroll-area-w100 .mantine-ScrollArea-viewport > div:nth-of-type(1) {
|
||||
width: 100%;
|
||||
display: inherit !important;
|
||||
}
|
||||
29
packages/api/src/middlewares/item.ts
Normal file
29
packages/api/src/middlewares/item.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export const createOneItemMiddleware = (kind: WidgetKind) => {
|
||||
return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: and(eq(items.id, input.itemId), eq(items.kind, kind)),
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Item with id ${input.itemId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
item,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { calendarRouter } from "./calendar";
|
||||
import { dnsHoleRouter } from "./dns-hole";
|
||||
import { mediaServerRouter } from "./media-server";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
import { weatherRouter } from "./weather";
|
||||
|
||||
@@ -15,4 +16,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
smartHome: smartHomeRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
calendar: calendarRouter,
|
||||
rssFeed: rssFeedRouter,
|
||||
});
|
||||
|
||||
12
packages/api/src/router/widgets/rssFeed.ts
Normal file
12
packages/api/src/router/widgets/rssFeed.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RssFeed } from "@homarr/cron-jobs";
|
||||
import { createItemChannel } from "@homarr/redis";
|
||||
|
||||
import { createOneItemMiddleware } from "../../middlewares/item";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const rssFeedRouter = createTRPCRouter({
|
||||
getFeeds: publicProcedure.unstable_concat(createOneItemMiddleware("rssFeed")).query(async ({ input }) => {
|
||||
const channel = createItemChannel<RssFeed[]>(input.itemId);
|
||||
return await channel.getAsync();
|
||||
}),
|
||||
});
|
||||
@@ -38,7 +38,8 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
);
|
||||
await creatorOptions.onCallbackSuccess?.(name);
|
||||
} catch (error) {
|
||||
creatorOptions.logger.logError(error);
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`);
|
||||
await creatorOptions.onCallbackError?.(name, error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,18 +21,20 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@extractus/feed-extractor": "^7.1.3",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs-core": "workspace:^0.1.0",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import type { RssFeed } from "./jobs/rss-feeds";
|
||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||
import { createCronJobGroup } from "./lib";
|
||||
|
||||
export const jobGroup = createCronJobGroup({
|
||||
@@ -13,6 +15,8 @@ export const jobGroup = createCronJobGroup({
|
||||
smartHomeEntityState: smartHomeEntityStateJob,
|
||||
mediaServer: mediaServerJob,
|
||||
mediaOrganizer: mediaOrganizerJob,
|
||||
rssFeeds: rssFeedsJob,
|
||||
});
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
export type { RssFeed };
|
||||
|
||||
135
packages/cron-jobs/src/jobs/rss-feeds.ts
Normal file
135
packages/cron-jobs/src/jobs/rss-feeds.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { FeedData, FeedEntry } from "@extractus/feed-extractor";
|
||||
import { extract } from "@extractus/feed-extractor";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import { logger } from "@homarr/log";
|
||||
import { createItemChannel } from "@homarr/redis";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import type { WidgetComponentProps } from "../../../widgets";
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
export const rssFeedsJob = createCronJob("rssFeeds", EVERY_5_MINUTES).withCallback(async () => {
|
||||
const itemsForIntegration = await db.query.items.findMany({
|
||||
where: eq(items.kind, "rssFeed"),
|
||||
});
|
||||
|
||||
for (const item of itemsForIntegration) {
|
||||
const options = SuperJSON.parse<WidgetComponentProps<"rssFeed">["options"]>(item.options);
|
||||
|
||||
const feeds = await Promise.all(
|
||||
options.feedUrls.map(async (feedUrl) => ({
|
||||
feedUrl,
|
||||
feed: (await extract(feedUrl, {
|
||||
getExtraEntryFields: (feedEntry) => {
|
||||
const media = attemptGetImageFromEntry(feedUrl, feedEntry);
|
||||
if (!media) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
enclosure: media,
|
||||
};
|
||||
},
|
||||
})) as ExtendedFeedData,
|
||||
})),
|
||||
);
|
||||
|
||||
const channel = createItemChannel<RssFeed[]>(item.id);
|
||||
await channel.publishAndUpdateLastStateAsync(feeds);
|
||||
}
|
||||
});
|
||||
|
||||
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
|
||||
const media = getFirstMediaProperty(entry);
|
||||
if (media !== null) {
|
||||
return media;
|
||||
}
|
||||
return getImageFromStringAsFallback(feedUrl, JSON.stringify(entry));
|
||||
};
|
||||
|
||||
const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
|
||||
const regex = /https?:\/\/\S+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)/i;
|
||||
const result = regex.exec(content);
|
||||
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`,
|
||||
);
|
||||
return result[0];
|
||||
};
|
||||
|
||||
const mediaProperties = [
|
||||
{
|
||||
path: ["enclosure", "@_url"],
|
||||
},
|
||||
{
|
||||
path: ["media:content", "@_url"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The RSS and Atom standards are poorly adhered to in most of the web.
|
||||
* We want to show pretty background images on the posts and therefore need to extract
|
||||
* the enclosure (aka. media images). This function uses the dynamic properties defined above
|
||||
* to search through the possible paths and detect valid image URLs.
|
||||
* @param feedObject The object to scan for.
|
||||
* @returns the value of the first path that is found within the object
|
||||
*/
|
||||
const getFirstMediaProperty = (feedObject: object) => {
|
||||
for (const mediaProperty of mediaProperties) {
|
||||
let propertyIndex = 0;
|
||||
let objectAtPath: object = feedObject;
|
||||
while (propertyIndex < mediaProperty.path.length) {
|
||||
const key = mediaProperty.path[propertyIndex];
|
||||
if (key === undefined) {
|
||||
break;
|
||||
}
|
||||
const propertyEntries = Object.entries(objectAtPath);
|
||||
const propertyEntry = propertyEntries.find(([entryKey]) => entryKey === key);
|
||||
if (!propertyEntry) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const [_, propertyEntryValue] = propertyEntry;
|
||||
objectAtPath = propertyEntryValue as object;
|
||||
propertyIndex++;
|
||||
}
|
||||
|
||||
const validationResult = z.string().url().safeParse(objectAtPath);
|
||||
if (!validationResult.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
|
||||
return validationResult.data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* We extend the feed with custom properties.
|
||||
* This interface adds properties on top of the default ones.
|
||||
*/
|
||||
interface ExtendedFeedEntry extends FeedEntry {
|
||||
enclosure?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* We extend the feed with custom properties.
|
||||
* This interface omits the default entries with our custom definition.
|
||||
*/
|
||||
interface ExtendedFeedData extends Omit<FeedData, "entries"> {
|
||||
entries?: ExtendedFeedEntry;
|
||||
}
|
||||
|
||||
export interface RssFeed {
|
||||
feedUrl: string;
|
||||
feed: ExtendedFeedData;
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export const widgetKinds = [
|
||||
"smartHome-executeAutomation",
|
||||
"mediaServer",
|
||||
"calendar",
|
||||
"rssFeed",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
|
||||
|
||||
export { createCacheChannel, createItemAndIntegrationChannel } from "./lib/channel";
|
||||
export { createCacheChannel, createItemAndIntegrationChannel, createItemChannel } from "./lib/channel";
|
||||
|
||||
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
||||
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
|
||||
|
||||
@@ -163,6 +163,14 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
|
||||
|
||||
export const createItemAndIntegrationChannel = <TData>(kind: WidgetKind, integrationId: string) => {
|
||||
const channelName = `item:${kind}:integration:${integrationId}`;
|
||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
||||
};
|
||||
|
||||
export const createItemChannel = <TData>(itemId: string) => {
|
||||
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
||||
};
|
||||
|
||||
const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
|
||||
return {
|
||||
subscribe: (callback: (data: TData) => void) => {
|
||||
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
||||
|
||||
@@ -1033,6 +1033,21 @@ export default {
|
||||
description: "Show the current streams on your media servers",
|
||||
option: {},
|
||||
},
|
||||
rssFeed: {
|
||||
name: "RSS feeds",
|
||||
description: "Monitor and display one or more generic RSS, ATOM or JSON feeds",
|
||||
option: {
|
||||
feedUrls: {
|
||||
label: "Feed URLs",
|
||||
},
|
||||
textLinesClamp: {
|
||||
label: "Description line clamp",
|
||||
},
|
||||
maximumAmountPosts: {
|
||||
label: "Amount posts limit",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
widgetPreview: {
|
||||
toggle: {
|
||||
@@ -1494,6 +1509,9 @@ export default {
|
||||
mediaOrganizer: {
|
||||
label: "Media Organizers",
|
||||
},
|
||||
rssFeeds: {
|
||||
label: "RSS feeds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extractus/feed-extractor": "^7.1.3",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WidgetOptionType } from "../options";
|
||||
import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
import { WidgetMultiTextInput } from "./widget-multi-text-input";
|
||||
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
|
||||
import { WidgetNumberInput } from "./widget-number-input";
|
||||
import { WidgetSelectInput } from "./widget-select-input";
|
||||
@@ -12,7 +13,7 @@ const mapping = {
|
||||
text: WidgetTextInput,
|
||||
location: WidgetLocationInput,
|
||||
multiSelect: WidgetMultiSelectInput,
|
||||
multiText: () => null,
|
||||
multiText: WidgetMultiTextInput,
|
||||
number: WidgetNumberInput,
|
||||
select: WidgetSelectInput,
|
||||
slider: WidgetSliderInput,
|
||||
|
||||
101
packages/widgets/src/_inputs/widget-multi-text-input.tsx
Normal file
101
packages/widgets/src/_inputs/widget-multi-text-input.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useState } from "react";
|
||||
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetInputProps<"multiText">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const tCommon = useScopedI18n("common");
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const form = useFormContext();
|
||||
const inputProps = form.getInputProps(`options.${property}`);
|
||||
const values = inputProps.value as string[];
|
||||
const onChange = inputProps.onChange as (values: string[]) => void;
|
||||
|
||||
const handleRemove = (optionIndex: number) => {
|
||||
onChange(values.filter((_, index) => index !== optionIndex));
|
||||
};
|
||||
|
||||
const currentValidationResult = React.useMemo(() => {
|
||||
if (!options.validate) {
|
||||
return {
|
||||
success: false,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = options.validate.safeParse(search);
|
||||
return {
|
||||
success: validationResult.success,
|
||||
result: validationResult,
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
const error = React.useMemo(() => {
|
||||
/* hide the error when nothing is being typed since "" is not valid but is not an explicit error */
|
||||
if (!currentValidationResult.success && currentValidationResult.result && search.length !== 0) {
|
||||
return currentValidationResult.result.error?.issues[0]?.message;
|
||||
}
|
||||
return null;
|
||||
}, [currentValidationResult, search]);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
label={t("label")}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
error={error}
|
||||
>
|
||||
<Pill.Group>
|
||||
{values.map((option, index) => (
|
||||
<Pill key={option} onRemove={() => handleRemove(index)} withRemoveButton>
|
||||
{option}
|
||||
</Pill>
|
||||
))}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
value={search}
|
||||
placeholder={tCommon("multiText.placeholder")}
|
||||
onChange={(event) => {
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Backspace" && search.length === 0) {
|
||||
event.preventDefault();
|
||||
onChange(values.slice(0, -1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (search.length === 0 || !currentValidationResult.success) {
|
||||
return;
|
||||
}
|
||||
if (values.includes(search)) {
|
||||
return;
|
||||
}
|
||||
onChange([...values, search]);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Pill.Group>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import * as iframe from "./iframe";
|
||||
import type { WidgetImportRecord } from "./import";
|
||||
import * as mediaServer from "./media-server";
|
||||
import * as notebook from "./notebook";
|
||||
import * as rssFeed from "./rssFeed";
|
||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||
import * as video from "./video";
|
||||
@@ -37,6 +38,7 @@ export const widgetImports = {
|
||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||
mediaServer,
|
||||
calendar,
|
||||
rssFeed,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { z } from "@homarr/validation";
|
||||
import type { z, ZodType } from "@homarr/validation";
|
||||
|
||||
import { widgetImports } from ".";
|
||||
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
||||
@@ -91,10 +91,12 @@ const optionsFactory = {
|
||||
},
|
||||
withDescription: input?.withDescription ?? false,
|
||||
}),
|
||||
multiText: (input?: CommonInput<string[]>) => ({
|
||||
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
|
||||
type: "multiText" as const,
|
||||
defaultValue: input?.defaultValue ?? [],
|
||||
withDescription: input?.withDescription ?? false,
|
||||
values: [] as string[],
|
||||
validate: input?.validate,
|
||||
}),
|
||||
app: (input?: Omit<CommonInput<string>, "defaultValue">) => ({
|
||||
type: "app" as const,
|
||||
|
||||
11
packages/widgets/src/rssFeed/component.module.scss
Normal file
11
packages/widgets/src/rssFeed/component.module.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.backgroundImage {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: blur(5px);
|
||||
transform: scaleX(-1.05) scaleZ(-1.05);
|
||||
opacity: 0.25;
|
||||
top: 0;
|
||||
left: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
74
packages/widgets/src/rssFeed/component.tsx
Normal file
74
packages/widgets/src/rssFeed/component.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.scss";
|
||||
|
||||
export default function RssFeed({ serverData, options }: WidgetComponentProps<"rssFeed">) {
|
||||
if (serverData?.initialData === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = serverData.initialData
|
||||
.filter((feedGroup) => feedGroup.feed.entries !== undefined)
|
||||
.flatMap((feedGroup) => feedGroup.feed.entries)
|
||||
.filter((entry) => entry !== undefined)
|
||||
.sort((entryA, entryB) => {
|
||||
if (!entryA.published || !entryB.published) {
|
||||
return -1;
|
||||
}
|
||||
return new Date(entryB.published).getTime() - new Date(entryA.published).getTime();
|
||||
})
|
||||
.slice(0, options.maximumAmountPosts as number);
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" p="4cqmin">
|
||||
<Stack w={"100%"} gap="4cqmin">
|
||||
{entries.map((feedEntry) => (
|
||||
<Card
|
||||
key={feedEntry.id}
|
||||
withBorder
|
||||
component={"a"}
|
||||
href={feedEntry.link}
|
||||
radius="2.5cqmin"
|
||||
target="_blank"
|
||||
w="100%"
|
||||
p="2.5cqmin"
|
||||
>
|
||||
{feedEntry.enclosure && (
|
||||
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
|
||||
)}
|
||||
|
||||
<Flex gap="2.5cqmin" direction="column" w="100%">
|
||||
<Text fz="4cqmin" lh="5cqmin" lineClamp={2}>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{feedEntry.description && (
|
||||
<Text
|
||||
className={feedEntry.description}
|
||||
c="dimmed"
|
||||
size="3.5cqmin"
|
||||
lineClamp={options.textLinesClamp as number}
|
||||
dangerouslySetInnerHTML={{ __html: feedEntry.description }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
const InfoDisplay = ({ date }: { date: string }) => (
|
||||
<Group gap="2.5cqmin">
|
||||
<IconClock size="2.5cqmin" />
|
||||
<Text size="2.5cqmin" c="dimmed" pt="1cqmin">
|
||||
{date}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
32
packages/widgets/src/rssFeed/index.ts
Normal file
32
packages/widgets/src/rssFeed/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { IconRss } from "@tabler/icons-react";
|
||||
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
/**
|
||||
* Feed must conform to one of the following standards:
|
||||
* - https://www.rssboard.org/rss-specification (https://web.resource.org/rss/1.0/spec)
|
||||
* - https://datatracker.ietf.org/doc/html/rfc5023
|
||||
* - https://www.jsonfeed.org/version/1.1/
|
||||
*/
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("rssFeed", {
|
||||
icon: IconRss,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
feedUrls: factory.multiText({
|
||||
defaultValue: [],
|
||||
validate: z.string().url(),
|
||||
}),
|
||||
textLinesClamp: factory.number({
|
||||
defaultValue: 5,
|
||||
validate: z.number().min(1).max(50),
|
||||
}),
|
||||
maximumAmountPosts: factory.number({
|
||||
defaultValue: 100,
|
||||
validate: z.number().min(1).max(9999),
|
||||
}),
|
||||
})),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
21
packages/widgets/src/rssFeed/serverData.ts
Normal file
21
packages/widgets/src/rssFeed/serverData.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import type { WidgetProps } from "../definition";
|
||||
|
||||
export default async function getServerDataAsync({ itemId }: WidgetProps<"rssFeed">) {
|
||||
if (!itemId) {
|
||||
return {
|
||||
initialData: undefined,
|
||||
lastUpdatedAt: null,
|
||||
};
|
||||
}
|
||||
const data = await api.widget.rssFeed.getFeeds({
|
||||
itemId,
|
||||
});
|
||||
return {
|
||||
initialData: data?.data,
|
||||
lastUpdatedAt: data?.timestamp,
|
||||
};
|
||||
}
|
||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -671,6 +671,9 @@ importers:
|
||||
|
||||
packages/cron-jobs:
|
||||
dependencies:
|
||||
'@extractus/feed-extractor':
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
'@homarr/analytics':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../analytics
|
||||
@@ -707,6 +710,9 @@ importers:
|
||||
'@homarr/translation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../translation
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../validation
|
||||
devDependencies:
|
||||
'@homarr/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
@@ -1248,6 +1254,9 @@ importers:
|
||||
|
||||
packages/widgets:
|
||||
dependencies:
|
||||
'@extractus/feed-extractor':
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
'@homarr/api':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../api
|
||||
@@ -2047,6 +2056,10 @@ packages:
|
||||
resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@extractus/feed-extractor@7.1.3':
|
||||
resolution: {integrity: sha512-USRVpGw4fWlnz8O8gB95UDJJaU5wg2EFESDe9nut0mHFJ8bOxDKGoo3g6EaKU24YzaPUBweKENdEJfuTUgr/uA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@floating-ui/core@1.6.0':
|
||||
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
|
||||
|
||||
@@ -3285,6 +3298,10 @@ packages:
|
||||
resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
bellajs@11.2.0:
|
||||
resolution: {integrity: sha512-Wjss+Bc674ZABPr+SCKWTqA4V1pyYFhzDTjNBJy4jdmgOv0oGIGXeKBRJyINwP5tIy+iIZD9SfgZpztduzQ5QA==}
|
||||
engines: {node: '>= 18.4'}
|
||||
|
||||
better-sqlite3@11.1.2:
|
||||
resolution: {integrity: sha512-gujtFwavWU4MSPT+h9B+4pkvZdyOUkH54zgLdIrMmmmd4ZqiBIrRNBzNzYVFO417xo882uP5HBu4GjOfaSrIQw==}
|
||||
|
||||
@@ -3545,6 +3562,9 @@ packages:
|
||||
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||
hasBin: true
|
||||
|
||||
cross-fetch@4.0.0:
|
||||
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
|
||||
|
||||
cross-spawn@7.0.3:
|
||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -4116,6 +4136,10 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-xml-parser@4.4.0:
|
||||
resolution: {integrity: sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==}
|
||||
hasBin: true
|
||||
|
||||
fastq@1.17.1:
|
||||
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
|
||||
|
||||
@@ -4384,6 +4408,9 @@ packages:
|
||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
html-entities@2.5.2:
|
||||
resolution: {integrity: sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
@@ -5976,6 +6003,9 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strnum@1.0.5:
|
||||
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
|
||||
|
||||
styled-jsx@5.1.1:
|
||||
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -7119,6 +7149,15 @@ snapshots:
|
||||
|
||||
'@eslint/object-schema@2.1.4': {}
|
||||
|
||||
'@extractus/feed-extractor@7.1.3':
|
||||
dependencies:
|
||||
bellajs: 11.2.0
|
||||
cross-fetch: 4.0.0
|
||||
fast-xml-parser: 4.4.0
|
||||
html-entities: 2.5.2
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@floating-ui/core@1.6.0':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.1
|
||||
@@ -8556,6 +8595,8 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
bellajs@11.2.0: {}
|
||||
|
||||
better-sqlite3@11.1.2:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
@@ -8836,6 +8877,12 @@ snapshots:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.3
|
||||
|
||||
cross-fetch@4.0.0:
|
||||
dependencies:
|
||||
node-fetch: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
cross-spawn@7.0.3:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -9584,6 +9631,10 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-xml-parser@4.4.0:
|
||||
dependencies:
|
||||
strnum: 1.0.5
|
||||
|
||||
fastq@1.17.1:
|
||||
dependencies:
|
||||
reusify: 1.0.4
|
||||
@@ -9873,6 +9924,8 @@ snapshots:
|
||||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
html-entities@2.5.2: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
@@ -11639,6 +11692,8 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strnum@1.0.5: {}
|
||||
|
||||
styled-jsx@5.1.1(@babel/core@7.24.6)(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
|
||||
Reference in New Issue
Block a user