diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ba4ee5f55..b29a4452a 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1258,5 +1258,8 @@ "wanted": "Wanted", "queued": "Queued", "leagues": "Leagues" + }, + "feed": { + "noItems": "No items" } } diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 9a9270d3d..20303af7d 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -353,6 +353,9 @@ export function cleanServiceGroups(groups) { expandOneStreamToTwoRows, showEpisodeNumber, + // feed + layout, + // frigate enableRecentEvents, @@ -675,6 +678,9 @@ export function cleanServiceGroups(groups) { if (showTime) widget.showTime = showTime; if (timezone) widget.timezone = timezone; } + if (type === "feed") { + if (layout) widget.layout = layout; + } if (type === "dockhand") { if (environment) widget.environment = environment; } diff --git a/src/widgets/components.js b/src/widgets/components.js index dc1c87c9f..ae8327578 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -37,6 +37,7 @@ const components = { emby: dynamic(() => import("./emby/component")), esphome: dynamic(() => import("./esphome/component")), evcc: dynamic(() => import("./evcc/component")), + feed: dynamic(() => import("./feed/component")), filebrowser: dynamic(() => import("./filebrowser/component")), fileflows: dynamic(() => import("./fileflows/component")), firefly: dynamic(() => import("./firefly/component")), diff --git a/src/widgets/feed/component.jsx b/src/widgets/feed/component.jsx new file mode 100644 index 000000000..f8a052799 --- /dev/null +++ b/src/widgets/feed/component.jsx @@ -0,0 +1,123 @@ +/* eslint-disable @next/next/no-img-element */ +import classNames from "classnames"; +import { useTranslation } from "next-i18next/pages"; +import { useContext } from "react"; + +import Container from "components/services/widget/container"; +import { SettingsContext } from "utils/contexts/settings"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +const rowClassName = "rounded-md bg-theme-200/50 dark:bg-theme-900/20 text-theme-700 dark:text-theme-200 text-xs"; +const hideBrokenImage = (e) => { + e.currentTarget.style.visibility = "hidden"; +}; + +function FeedLink({ item, target, className, children }) { + if (!item.link) return
{children}
; + return ( + + {children} + + ); +} + +function RelativeDate({ date }) { + const { t } = useTranslation(); + if (!date) return null; + return ( + + {t("common.relativeDate", { value: date, formatParams: { value: { style: "narrow", numeric: "auto" } } })} + + ); +} + +function ListItem({ item, target }) { + return ( + + {item.image && ( + + )} + {item.title} + + + ); +} + +function GridItem({ item, target }) { + return ( + +
+ {item.image && ( + + )} +
+
+ {item.title} + +
+
+ ); +} + +export default function Component({ service }) { + const { t } = useTranslation(); + const { settings } = useContext(SettingsContext); + const { widget } = service; + + const { data, error } = useWidgetAPI(widget, undefined, undefined, { refreshInterval: 10 * 60 * 1000 }); + + if (error) { + return ; + } + + const target = service.target ?? settings?.target ?? "_blank"; + const isGrid = widget.layout === "grid"; + const Item = isGrid ? GridItem : ListItem; + + let content; + if (!data) { + content = [0, 1, 2].map((i) => ( +
+ )); + } else if (!data.items?.length) { + content =
{t("feed.noItems")}
; + } else { + content = data.items.map((item, i) => ); + } + + return ( + +
+ {content} +
+
+ ); +} diff --git a/src/widgets/feed/component.test.jsx b/src/widgets/feed/component.test.jsx new file mode 100644 index 000000000..480a9affc --- /dev/null +++ b/src/widgets/feed/component.test.jsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "test-utils/render-with-providers"; + +const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); +vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); + +import Component from "./component"; + +const items = [ + { + title: "With image", + link: "https://example.com/1", + date: "2020-01-01T00:00:00Z", + image: "https://example.com/1.jpg", + }, + { title: "No link", link: null, date: null }, +]; + +function render(widget = {}, settings = {}) { + return renderWithProviders(, { + settings: { hideErrors: false, ...settings }, + }); +} + +describe("widgets/feed/component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders placeholders while loading", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); + const { container } = render(); + expect(container.querySelectorAll(".animate-pulse")).toHaveLength(3); + }); + + it("renders an error", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "Invalid feed" } }); + render(); + expect(screen.getAllByText(/Invalid feed/).length).toBeGreaterThan(0); + }); + + it("renders an empty feed", () => { + useWidgetAPI.mockReturnValue({ data: { items: [] }, error: undefined }); + render(); + expect(screen.getByText("feed.noItems")).toBeInTheDocument(); + }); + + it("renders list items with links and images", () => { + useWidgetAPI.mockReturnValue({ data: { items }, error: undefined }); + const { container } = render({}, { target: "_self" }); + + const link = screen.getByText("With image").closest("a"); + expect(link).toHaveAttribute("href", "https://example.com/1"); + expect(link).toHaveAttribute("target", "_self"); + expect(link.querySelector("img")).toHaveAttribute("src", "https://example.com/1.jpg"); + expect(screen.getByText("No link").closest("a")).toBeNull(); + expect(container.querySelectorAll("img")).toHaveLength(1); + }); + + it("renders the grid layout with image placeholders", () => { + useWidgetAPI.mockReturnValue({ data: { items }, error: undefined }); + const { container } = render({ layout: "grid" }); + + expect(container.querySelector(".grid")).not.toBeNull(); + expect(container.querySelectorAll(".aspect-video")).toHaveLength(2); + expect(container.querySelectorAll("img")).toHaveLength(1); + }); + + it("hides images that fail to load", () => { + useWidgetAPI.mockReturnValue({ data: { items }, error: undefined }); + const { container } = render(); + const img = container.querySelector("img"); + img.dispatchEvent(new Event("error")); + expect(img.style.visibility).toBe("hidden"); + }); +});