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");
+ });
+});