feat: add rss widget (#760)
Co-authored-by: SeDemal <demal.sebastien@bluewin.ch>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user