component

This commit is contained in:
shamoon
2026-09-22 23:31:58 -07:00
parent 8619f93ab4
commit 802201d2a9
5 changed files with 213 additions and 0 deletions
+3
View File
@@ -1258,5 +1258,8 @@
"wanted": "Wanted",
"queued": "Queued",
"leagues": "Leagues"
},
"feed": {
"noItems": "No items"
}
}
+6
View File
@@ -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;
}
+1
View File
@@ -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")),
+123
View File
@@ -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 <div className={className}>{children}</div>;
return (
<a
href={item.link}
target={target}
rel="noopener noreferrer"
className={classNames(className, "hover:bg-theme-300/50 dark:hover:bg-theme-800/20")}
>
{children}
</a>
);
}
function RelativeDate({ date }) {
const { t } = useTranslation();
if (!date) return null;
return (
<span className="shrink-0 text-theme-500 dark:text-theme-400">
{t("common.relativeDate", { value: date, formatParams: { value: { style: "narrow", numeric: "auto" } } })}
</span>
);
}
function ListItem({ item, target }) {
return (
<FeedLink
item={item}
target={target}
className={classNames(rowClassName, "flex items-center gap-2 px-2", item.image ? "py-1" : "h-5")}
>
{item.image && (
<img
src={item.image}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={hideBrokenImage}
className="w-12 h-8 shrink-0 rounded-sm object-cover"
/>
)}
<span className="grow truncate text-left">{item.title}</span>
<RelativeDate date={item.date} />
</FeedLink>
);
}
function GridItem({ item, target }) {
return (
<FeedLink item={item} target={target} className={classNames(rowClassName, "flex flex-col overflow-hidden")}>
<div className="aspect-video w-full bg-theme-200/50 dark:bg-theme-900/30">
{item.image && (
<img
src={item.image}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={hideBrokenImage}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-0.5 px-1.5 py-1 text-left">
<span className="line-clamp-2">{item.title}</span>
<RelativeDate date={item.date} />
</div>
</FeedLink>
);
}
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 <Container service={service} error={error} />;
}
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) => (
<div key={i} className={classNames(rowClassName, "animate-pulse", isGrid ? "aspect-video" : "h-5")} />
));
} else if (!data.items?.length) {
content = <div className={classNames(rowClassName, "h-5 px-2 flex items-center")}>{t("feed.noItems")}</div>;
} else {
content = data.items.map((item, i) => <Item key={`${item.link ?? item.title}-${i}`} item={item} target={target} />);
}
return (
<Container service={service}>
<div
className={classNames(
"w-full p-1 gap-1",
isGrid ? "grid grid-cols-[repeat(auto-fill,minmax(8rem,1fr))]" : "flex flex-col",
)}
>
{content}
</div>
</Container>
);
}
+80
View File
@@ -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(<Component service={{ widget: { type: "feed", ...widget } }} />, {
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");
});
});