mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-26 05:51:17 -07:00
component
This commit is contained in:
@@ -1258,5 +1258,8 @@
|
|||||||
"wanted": "Wanted",
|
"wanted": "Wanted",
|
||||||
"queued": "Queued",
|
"queued": "Queued",
|
||||||
"leagues": "Leagues"
|
"leagues": "Leagues"
|
||||||
|
},
|
||||||
|
"feed": {
|
||||||
|
"noItems": "No items"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,6 +353,9 @@ export function cleanServiceGroups(groups) {
|
|||||||
expandOneStreamToTwoRows,
|
expandOneStreamToTwoRows,
|
||||||
showEpisodeNumber,
|
showEpisodeNumber,
|
||||||
|
|
||||||
|
// feed
|
||||||
|
layout,
|
||||||
|
|
||||||
// frigate
|
// frigate
|
||||||
enableRecentEvents,
|
enableRecentEvents,
|
||||||
|
|
||||||
@@ -675,6 +678,9 @@ export function cleanServiceGroups(groups) {
|
|||||||
if (showTime) widget.showTime = showTime;
|
if (showTime) widget.showTime = showTime;
|
||||||
if (timezone) widget.timezone = timezone;
|
if (timezone) widget.timezone = timezone;
|
||||||
}
|
}
|
||||||
|
if (type === "feed") {
|
||||||
|
if (layout) widget.layout = layout;
|
||||||
|
}
|
||||||
if (type === "dockhand") {
|
if (type === "dockhand") {
|
||||||
if (environment) widget.environment = environment;
|
if (environment) widget.environment = environment;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const components = {
|
|||||||
emby: dynamic(() => import("./emby/component")),
|
emby: dynamic(() => import("./emby/component")),
|
||||||
esphome: dynamic(() => import("./esphome/component")),
|
esphome: dynamic(() => import("./esphome/component")),
|
||||||
evcc: dynamic(() => import("./evcc/component")),
|
evcc: dynamic(() => import("./evcc/component")),
|
||||||
|
feed: dynamic(() => import("./feed/component")),
|
||||||
filebrowser: dynamic(() => import("./filebrowser/component")),
|
filebrowser: dynamic(() => import("./filebrowser/component")),
|
||||||
fileflows: dynamic(() => import("./fileflows/component")),
|
fileflows: dynamic(() => import("./fileflows/component")),
|
||||||
firefly: dynamic(() => import("./firefly/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