mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-23 20:45:49 -07:00
component
This commit is contained in:
@@ -1258,5 +1258,8 @@
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"leagues": "Leagues"
|
||||
},
|
||||
"feed": {
|
||||
"noItems": "No items"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user