refactor: replace serverdata with suspense query (#1265)

* refactor: replace serverdata with suspense query

* fix: deepsource issues
This commit is contained in:
Meier Lukas
2024-10-11 23:47:07 +02:00
committed by GitHub
parent 511c9a4dbb
commit 0f8d9edb3e
41 changed files with 288 additions and 646 deletions

View File

@@ -28,7 +28,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />, headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
isBoardContentPage: true,
}), }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
page: async () => { page: async () => {

View File

@@ -8,5 +8,4 @@ export default createBoardLayout<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) { async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name }); return await api.board.getBoardByName({ name });
}, },
isBoardContentPage: false,
}); });

View File

@@ -4,7 +4,6 @@ import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
@@ -18,13 +17,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({ const Layout = async ({
params, params,
@@ -42,21 +39,19 @@ export const createBoardLayout = <TParams extends Params>({
}); });
return ( return (
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}> <BoardProvider initialBoard={initialBoard}>
<BoardProvider initialBoard={initialBoard}> <BoardMantineProvider>
<BoardMantineProvider> <CustomCss />
<CustomCss /> <ClientShell hasNavigation={false}>
<ClientShell hasNavigation={false}> <MainHeader
<MainHeader logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} actions={headerActions}
actions={headerActions} hasNavigation={false}
hasNavigation={false} />
/> <AppShellMain>{children}</AppShellMain>
<AppShellMain>{children}</AppShellMain> </ClientShell>
</ClientShell> </BoardMantineProvider>
</BoardMantineProvider> </BoardProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
); );
}; };

View File

@@ -4,7 +4,7 @@ import { QueryErrorResetBoundary } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, useServerDataFor } from "@homarr/widgets"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
@@ -53,7 +53,6 @@ interface InnerContentProps {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const serverData = useServerDataFor(item.id);
const Comp = loadWidgetDynamic(item.kind); const Comp = loadWidgetDynamic(item.kind);
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const newItem = { ...item, options }; const newItem = { ...item, options };
@@ -61,8 +60,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) => const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions }); updateItemOptions({ itemId: item.id, newOptions });
if (!serverData?.isReady) return null;
return ( return (
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
@@ -79,8 +76,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
<Comp <Comp
options={options as never} options={options as never}
integrationIds={item.integrationIds} integrationIds={item.integrationIds}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
serverData={serverData?.data as never}
isEditMode={isEditMode} isEditMode={isEditMode}
boardId={board.id} boardId={board.id}
itemId={item.id} itemId={item.id}

View File

@@ -9,11 +9,12 @@ export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure findAllEvents: publicProcedure
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( const result = await Promise.all(
ctx.integrations.flatMap(async (integration) => { ctx.integrations.flatMap(async (integration) => {
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id); const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
return await cache.getAsync(); return await cache.getAsync();
}), }),
); );
return result.filter((item) => item !== null).flatMap((item) => item.data);
}), }),
}); });

View File

@@ -3,7 +3,7 @@ import { IconApps, IconDeviceDesktopX } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("app", { export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps, icon: IconApps,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
appId: factory.app(), appId: factory.app(),

View File

@@ -11,7 +11,20 @@ import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day"; import { CalendarDay } from "./calender-day";
import classes from "./component.module.css"; import classes from "./component.module.css";
export default function CalendarWidget({ isEditMode, serverData }: WidgetComponentProps<"calendar">) { export default function CalendarWidget({ isEditMode, integrationIds, itemId }: WidgetComponentProps<"calendar">) {
const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const [month, setMonth] = useState(new Date()); const [month, setMonth] = useState(new Date());
const params = useParams(); const params = useParams();
const locale = params.locale as string; const locale = params.locale as string;
@@ -68,7 +81,7 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
}, },
}} }}
renderDay={(date) => { renderDay={(date) => {
const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day")); const eventsForDate = events.filter((event) => dayjs(event.date).isSame(date, "day"));
return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />; return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />;
}} }}
/> />

View File

@@ -6,7 +6,7 @@ import { z } from "@homarr/validation";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", { export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar, icon: IconCalendar,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
filterPastMonths: factory.number({ filterPastMonths: factory.number({
@@ -19,6 +19,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
}), }),
})), })),
supportedIntegrations: getIntegrationKindsByCategory("calendar"), supportedIntegrations: getIntegrationKindsByCategory("calendar"),
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,35 +0,0 @@
"use server";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"calendar">) {
if (!itemId) {
return {
initialData: [],
};
}
try {
const data = await api.widget.calendar.findAllEvents({
integrationIds,
itemId,
});
return {
initialData: data
.filter(
(
item,
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
item !== null,
)
.flatMap((item) => item.data),
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -8,57 +8,22 @@ import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from "."; import type { WidgetImports } from ".";
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options"; import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
default: (props: WidgetProps<TKind>) => Promise<Record<string, unknown>>;
}>;
const createWithDynamicImport = const createWithDynamicImport =
<
TKind extends WidgetKind,
TDefinition extends WidgetDefinition,
TServerDataLoader extends ServerDataLoader<TKind> | undefined,
>(
kind: TKind,
definition: TDefinition,
serverDataLoader: TServerDataLoader,
) =>
(
componentLoader: () => LoaderComponent<
WidgetComponentProps<TKind> &
(TServerDataLoader extends ServerDataLoader<TKind>
? {
serverData: Awaited<ReturnType<Awaited<ReturnType<TServerDataLoader>>["default"]>>;
}
: never)
>,
) => ({
definition: {
...definition,
kind,
},
kind,
serverDataLoader,
componentLoader,
});
const createWithServerData =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) => <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
<TServerDataLoader extends ServerDataLoader<TKind>>(serverDataLoader: TServerDataLoader) => ({ (componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>) => ({
definition: { definition: {
...definition, ...definition,
kind, kind,
}, },
kind, kind,
serverDataLoader, componentLoader,
withDynamicImport: createWithDynamicImport(kind, definition, serverDataLoader),
}); });
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>( export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
kind: TKind, kind: TKind,
definition: TDefinition, definition: TDefinition,
) => ({ ) => ({
withServerData: createWithServerData(kind, definition), withDynamicImport: createWithDynamicImport(kind, definition),
withDynamicImport: createWithDynamicImport(kind, definition, undefined),
}); });
export interface WidgetDefinition { export interface WidgetDefinition {
@@ -83,15 +48,7 @@ export interface WidgetProps<TKind extends WidgetKind> {
itemId: string | undefined; // undefined when in preview mode itemId: string | undefined; // undefined when in preview mode
} }
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
serverDataLoader: ServerDataLoader<TKind>;
}
? Awaited<ReturnType<Awaited<ReturnType<WidgetImports[TKind]["serverDataLoader"]>>["default"]>>
: undefined;
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & { export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
boardId: string | undefined; // undefined when in preview mode boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean; isEditMode: boolean;
setOptions: ({ setOptions: ({

View File

@@ -39,16 +39,25 @@ export default function DnsHoleControlsWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
serverData,
}: WidgetComponentProps<typeof widgetKind>) { }: WidgetComponentProps<typeof widgetKind>) {
// DnsHole integrations with interaction permissions // DnsHole integrations with interaction permissions
const integrationsWithInteractions = useIntegrationsWithInteractAccess() const integrationsWithInteractions = useIntegrationsWithInteractAccess()
.map(({ id }) => id) .map(({ id }) => id)
.filter((id) => integrationIds.includes(id)); .filter((id) => integrationIds.includes(id));
// Initial summaries, null summary means disconnected, undefined status means processing const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
const [summaries, setSummaries] = useState(serverData?.initialData ?? []); {
widgetKind: "dnsHoleControls",
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
// Subscribe to summary updates // Subscribe to summary updates
clientApi.widget.dnsHole.subscribeToSummary.useSubscription( clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{ {
@@ -57,8 +66,20 @@ export default function DnsHoleControlsWidget({
}, },
{ {
onData: (data) => { onData: (data) => {
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)), {
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return undefined;
const newData = prevData.map((summary) =>
summary.integration.id === data.integration.id ? { ...summary, summary: data.summary } : summary,
);
return newData;
},
); );
}, },
}, },
@@ -67,39 +88,77 @@ export default function DnsHoleControlsWidget({
// Mutations for dnsHole state, set to undefined on click, and change again on settle // Mutations for dnsHole state, set to undefined on click, and change again on settle
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({ const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
onSettled: (_, error, { integrationId }) => { onSettled: (_, error, { integrationId }) => {
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((data) => ({ {
...data, widgetKind: "dnsHoleControls",
summary: integrationIds,
data.integration.id === integrationId && data.summary },
? { ...data.summary, status: error ? "disabled" : "enabled" } (prevData) => {
: data.summary, if (!prevData) return [];
})),
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: error ? "disabled" : "enabled",
},
}
: item,
);
},
); );
}, },
}); });
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({ const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
onSettled: (_, error, { integrationId }) => { onSettled: (_, error, { integrationId }) => {
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((data) => ({ {
...data, widgetKind: "dnsHoleControls",
summary: integrationIds,
data.integration.id === integrationId && data.summary },
? { ...data.summary, status: error ? "enabled" : "disabled" } (prevData) => {
: data.summary, if (!prevData) return [];
})),
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: error ? "enabled" : "disabled",
},
}
: item,
);
},
); );
}, },
}); });
const toggleDns = (integrationId: string) => { const toggleDns = (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId); const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary?.status) return; if (!integrationStatus?.summary?.status) return;
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((data) => ({ {
...data, widgetKind: "dnsHoleControls",
summary: integrationIds,
data.integration.id === integrationId && data.summary ? { ...data.summary, status: undefined } : data.summary, },
})), (prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: undefined,
},
}
: item,
);
},
); );
if (integrationStatus.summary.status === "enabled") { if (integrationStatus.summary.status === "enabled") {
disableDns({ integrationId, duration: 0 }); disableDns({ integrationId, duration: 0 });

View File

@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleControls"; export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad, icon: IconDeviceGamepad,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({ showToggleAllButtons: factory.switch({
@@ -21,6 +21,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.dnsHoleControls.error.internalServerError"), message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
}, },
}, },
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,29 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import { widgetKind } from ".";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentDns = await api.widget.dnsHole.summary({
widgetKind,
integrationIds,
});
return {
initialData: currentDns,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import type { BoxProps } from "@mantine/core"; import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core"; import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
@@ -20,12 +20,20 @@ import { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition"; import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors"; import { NoIntegrationSelectedError } from "../../errors";
export default function DnsHoleSummaryWidget({ export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
options, const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
integrationIds, {
serverData, widgetKind,
}: WidgetComponentProps<typeof widgetKind>) { integrationIds,
const [summaries, setSummaries] = useState(serverData?.initialData ?? []); },
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const t = useI18n(); const t = useI18n();
@@ -36,8 +44,21 @@ export default function DnsHoleSummaryWidget({
}, },
{ {
onData: (data) => { onData: (data) => {
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)), {
widgetKind,
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
const newData = prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
return newData;
},
); );
}, },
}, },
@@ -46,17 +67,10 @@ export default function DnsHoleSummaryWidget({
const data = useMemo( const data = useMemo(
() => () =>
summaries summaries
.filter( .filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000)
( .flatMap(({ summary }) => summary)
pair, .filter((summary) => summary !== null),
): pair is { [summaries],
integration: typeof pair.integration;
timestamp: typeof pair.timestamp;
summary: DnsHoleSummary;
} => pair.summary !== null && Math.abs(dayjs(pair.timestamp).diff()) < 30000,
)
.flatMap(({ summary }) => summary),
[summaries, serverData],
); );
if (integrationIds.length === 0) { if (integrationIds.length === 0) {

View File

@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleSummary"; export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd, icon: IconAd,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({ usePiHoleColors: factory.switch({
@@ -28,6 +28,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"), message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
}, },
}, },
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,29 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import { widgetKind } from ".";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentDns = await api.widget.dnsHole.summary({
widgetKind,
integrationIds,
});
return {
initialData: currentDns,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -21,7 +21,7 @@ import {
Title, Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useListState, useTimeout } from "@mantine/hooks"; import { useDisclosure, useTimeout } from "@mantine/hooks";
import type { IconProps } from "@tabler/icons-react"; import type { IconProps } from "@tabler/icons-react";
import { import {
IconAlertTriangle, IconAlertTriangle,
@@ -40,9 +40,6 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { import type {
DownloadClientJobsAndStatus, DownloadClientJobsAndStatus,
@@ -91,30 +88,35 @@ export default function DownloadClientsWidget({
isEditMode, isEditMode,
integrationIds, integrationIds,
options, options,
serverData,
setOptions, setOptions,
}: WidgetComponentProps<"downloads">) { }: WidgetComponentProps<"downloads">) {
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
integrationIds.includes(id) ? [id] : [], integrationIds.includes(id) ? [id] : [],
); );
const [currentItems, currentItemsHandlers] = useListState<{ const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>; {
timestamp: Date; integrationIds,
data: DownloadClientJobsAndStatus | null; },
}>( {
//Automatically invalidate data older than 30 seconds refetchOnMount: false,
serverData?.initialData?.map((item) => refetchOnWindowFocus: false,
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null }, refetchOnReconnect: false,
) ?? [], retry: false,
select(data) {
return data.map((item) =>
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null },
);
},
},
); );
const utils = clientApi.useUtils();
//Invalidate all data after no update for 30 seconds using timer //Invalidate all data after no update for 30 seconds using timer
const invalidationTimer = useTimeout( const invalidationTimer = useTimeout(
() => { () => {
currentItemsHandlers.applyWhere( utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
() => true, prevData?.map((item) => ({ ...item, timestamp: new Date(0), data: null })),
(item) => ({ ...item, timestamp: new Date(0), data: null }),
); );
}, },
invalidateTime, invalidateTime,
@@ -146,20 +148,24 @@ export default function DownloadClientsWidget({
//Don't update already invalid data (new Date (0)) //Don't update already invalid data (new Date (0))
.filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0)) .filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0))
.map(({ integration }) => integration.id); .map(({ integration }) => integration.id);
currentItemsHandlers.applyWhere( utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
({ integration }) => invalidIndexes.includes(integration.id), prevData?.map((item) =>
//Set date to now so it won't update that integration for at least 30 seconds invalidIndexes.includes(item.integration.id) ? item : { ...item, timestamp: new Date(0), data: null },
(item) => ({ ...item, timestamp: new Date(0), data: null }), ),
); );
//Find id to update utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => {
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id); const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
if (updateIndex >= 0) { if (updateIndex >= 0) {
//Update found index //Update found index
currentItemsHandlers.setItem(updateIndex, data); return prevData?.map((pair, index) => (index === updateIndex ? data : pair));
} else if (integrationIds.includes(data.integration.id)) { } else if (integrationIds.includes(data.integration.id)) {
//Append index not found (new integration) //Append index not found (new integration)
currentItemsHandlers.append(data); return [...(prevData ?? []), data];
} }
return undefined;
});
//Reset no update timer //Reset no update timer
invalidationTimer.clear(); invalidationTimer.clear();
invalidationTimer.start(); invalidationTimer.start();

View File

@@ -31,7 +31,7 @@ const columnsSort = columnsList.filter((column) =>
sortingExclusion.some((exclusion) => exclusion !== column), sortingExclusion.some((exclusion) => exclusion !== column),
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>; ) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", { export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload, icon: IconDownload,
options: optionsBuilder.from( options: optionsBuilder.from(
(factory) => ({ (factory) => ({
@@ -105,6 +105,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
}, },
), ),
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"), supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,21 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"downloads">) {
if (integrationIds.length === 0) {
return {
initialData: undefined,
};
}
const jobsAndStatuses = await api.widget.downloads.getJobsAndStatuses({
integrationIds,
});
return {
initialData: jobsAndStatuses,
};
}

View File

@@ -17,7 +17,7 @@ import {
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useElementSize, useListState } from "@mantine/hooks"; import { useDisclosure, useElementSize } from "@mantine/hooks";
import { import {
IconBrain, IconBrain,
IconClock, IconClock,
@@ -30,19 +30,27 @@ import {
IconVersions, IconVersions,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors"; import { NoIntegrationSelectedError } from "../errors";
export default function HealthMonitoringWidget({ export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
options,
integrationIds,
serverData,
}: WidgetComponentProps<"healthMonitoring">) {
const t = useI18n(); const t = useI18n();
const [healthData] = useListState(serverData?.initialData ?? []); const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
select: (data) => data.filter((health) => health !== null),
},
);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
if (integrationIds.length === 0) { if (integrationIds.length === 0) {

View File

@@ -3,7 +3,7 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("healthMonitoring", { export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor, icon: IconHeartRateMonitor,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
fahrenheit: factory.switch({ fahrenheit: factory.switch({
@@ -26,6 +26,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.healthMonitoring.error.internalServerError"), message: (t) => t("widget.healthMonitoring.error.internalServerError"),
}, },
}, },
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,27 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"healthMonitoring">) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentHealthInfo = await api.widget.healthMonitoring.getHealthStatus({
integrationIds,
});
return {
initialData: currentHealthInfo.filter((health) => health !== null),
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -30,8 +30,6 @@ export { reduceWidgetOptionsWithDefaultValues } from "./options";
export type { WidgetDefinition } from "./definition"; export type { WidgetDefinition } from "./definition";
export { WidgetEditModal } from "./modals/widget-edit-modal"; export { WidgetEditModal } from "./modals/widget-edit-modal";
export { useServerDataFor } from "./server/provider";
export { GlobalItemServerDataRunner } from "./server/runner";
export type { WidgetComponentProps }; export type { WidgetComponentProps };
export const widgetImports = { export const widgetImports = {

View File

@@ -1,25 +1,26 @@
"use client"; "use client";
import { useState } from "react";
import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core"; import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react"; import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { Indexer } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors"; import { NoIntegrationSelectedError } from "../errors";
export default function IndexerManagerWidget({ export default function IndexerManagerWidget({ options, integrationIds }: WidgetComponentProps<"indexerManager">) {
options,
integrationIds,
serverData,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n(); const t = useI18n();
const [indexersData, setIndexersData] = useState<{ integrationId: string; indexers: Indexer[] }[]>( const [indexersData] = clientApi.widget.indexerManager.getIndexersStatus.useSuspenseQuery(
serverData?.initialData ?? [], { integrationIds },
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
); );
const utils = clientApi.useUtils();
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation(); const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation();
@@ -27,11 +28,11 @@ export default function IndexerManagerWidget({
{ integrationIds }, { integrationIds },
{ {
onData(newData) { onData(newData) {
setIndexersData((prevData) => { utils.widget.indexerManager.getIndexersStatus.setData({ integrationIds }, (previousData) =>
return prevData.map((item) => previousData?.map((item) =>
item.integrationId === newData.integrationId ? { ...item, indexers: newData.indexers } : item, item.integrationId === newData.integrationId ? { ...item, indexers: newData.indexers } : item,
); ),
}); );
}, },
}, },
); );

View File

@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("indexerManager", { export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch, icon: IconReportSearch,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({ openIndexerSiteInNewTab: factory.switch({
@@ -19,6 +19,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.indexerManager.error.internalServerError"), message: (t) => t("widget.indexerManager.error.internalServerError"),
}, },
}, },
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,27 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"indexerManager">) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentIndexers = await api.widget.indexerManager.getIndexersStatus({
integrationIds,
});
return {
initialData: currentIndexers,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -15,30 +15,26 @@ export default function MediaServerWidget({
integrationIds, integrationIds,
isEditMode, isEditMode,
options, options,
serverData,
itemId, itemId,
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useScopedI18n("widget.mediaRequests-requestList"); const t = useScopedI18n("widget.mediaRequests-requestList");
const isQueryEnabled = Boolean(itemId); const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
const { data: mediaRequests, isError: _isError } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
{ {
integrationIds, integrationIds,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!, itemId: itemId!,
}, },
{ {
initialData: !serverData ? undefined : serverData.initialData,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
enabled: integrationIds.length > 0 && isQueryEnabled,
}, },
); );
const sortedMediaRequests = useMemo( const sortedMediaRequests = useMemo(
() => () =>
mediaRequests mediaRequests
?.filter((group) => group != null) .filter((group) => group != null)
.flatMap((group) => group.data) .flatMap((group) => group.data)
.flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id }))) .flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id })))
.sort(({ status: statusA }, { status: statusB }) => { .sort(({ status: statusA }, { status: statusB }) => {
@@ -49,7 +45,7 @@ export default function MediaServerWidget({
return 1; return 1;
} }
return 0; return 0;
}) ?? [], }),
[mediaRequests, integrationIds], [mediaRequests, integrationIds],
); );

View File

@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options"; import { optionsBuilder } from "../../options";
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestList", { export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion, icon: IconZoomQuestion,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({ linksTargetNewTab: factory.switch({
@@ -13,6 +13,4 @@ export const { componentLoader, definition, serverDataLoader } = createWidgetDef
}), }),
})), })),
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,22 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"mediaRequests-requestList">) {
if (integrationIds.length === 0 || !itemId) {
return {
initialData: undefined,
};
}
const requests = await api.widget.mediaRequests.getLatestRequests({
integrationIds,
itemId,
});
return {
initialData: requests.filter((group) => group != null),
};
}

View File

@@ -27,30 +27,26 @@ import classes from "./component.module.css";
export default function MediaServerWidget({ export default function MediaServerWidget({
integrationIds, integrationIds,
isEditMode, isEditMode,
serverData,
itemId, itemId,
}: WidgetComponentProps<"mediaRequests-requestStats">) { }: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats"); const t = useScopedI18n("widget.mediaRequests-requestStats");
const isQueryEnabled = Boolean(itemId); const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
const { data: requestStats, isError: _isError } = clientApi.widget.mediaRequests.getStats.useQuery(
{ {
integrationIds, integrationIds,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!, itemId: itemId!,
}, },
{ {
initialData: !serverData ? undefined : serverData.initialData,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
enabled: integrationIds.length > 0 && isQueryEnabled,
}, },
); );
const { width, height, ref } = useElementSize(); const { width, height, ref } = useElementSize();
const baseData = useMemo( const baseData = useMemo(
() => requestStats?.filter((group) => group != null).flatMap((group) => group.data) ?? [], () => requestStats.filter((group) => group != null).flatMap((group) => group.data),
[requestStats], [requestStats],
); );

View File

@@ -4,10 +4,8 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestStats", { export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar, icon: IconChartBar,
options: {}, options: {},
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,25 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({
integrationIds,
itemId,
}: WidgetProps<"mediaRequests-requestStats">) {
if (integrationIds.length === 0 || !itemId) {
return {
initialData: undefined,
};
}
const stats = await api.widget.mediaRequests.getStats({
integrationIds,
itemId,
});
return {
initialData: stats.filter((group) => group != null),
};
}

View File

@@ -2,7 +2,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Avatar, Box, Group, Text } from "@mantine/core"; import { Avatar, Box, Group, Text } from "@mantine/core";
import { useListState } from "@mantine/hooks";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
@@ -12,14 +11,19 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
export default function MediaServerWidget({ export default function MediaServerWidget({ integrationIds, isEditMode }: WidgetComponentProps<"mediaServer">) {
serverData, const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery(
integrationIds, {
isEditMode, integrationIds,
}: WidgetComponentProps<"mediaServer">) { },
const [currentStreams, currentStreamsHandlers] = useListState<{ integrationId: string; sessions: StreamSession[] }>( {
serverData?.initialData ?? [], refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
); );
const utils = clientApi.useUtils();
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>( const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
() => [ () => [
{ {
@@ -62,15 +66,17 @@ export default function MediaServerWidget({
{ {
enabled: !isEditMode, enabled: !isEditMode,
onData(data) { onData(data) {
currentStreamsHandlers.applyWhere( utils.widget.mediaServer.getCurrentStreams.setData({ integrationIds }, (previousData) => {
(pair) => pair.integrationId === data.integrationId, return previousData?.map((pair) => {
(pair) => { if (pair.integrationId === data.integrationId) {
return { return {
...pair, ...pair,
sessions: data.data, sessions: data.data,
}; };
}, }
); return pair;
});
});
}, },
}, },
); );

View File

@@ -2,10 +2,8 @@ import { IconVideo } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaServer", { export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo, icon: IconVideo,
options: {}, options: {},
supportedIntegrations: ["jellyfin"], supportedIntegrations: ["jellyfin"],
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,21 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"mediaServer">) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
const currentStreams = await api.widget.mediaServer.getCurrentStreams({
integrationIds,
});
return {
initialData: currentStreams,
};
}

View File

@@ -3,15 +3,29 @@ import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core
import { IconClock } from "@tabler/icons-react"; import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss"; import classes from "./component.module.scss";
export default function RssFeed({ serverData, options }: WidgetComponentProps<"rssFeed">) { export default function RssFeed({ options, itemId }: WidgetComponentProps<"rssFeed">) {
if (serverData?.initialData === undefined) { const [rssFeeds] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery(
return null; {
} // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
select(data) {
return data?.data ?? [];
},
},
);
const entries = serverData.initialData const entries = rssFeeds
.filter((feedGroup) => feedGroup.feed.entries !== undefined) .filter((feedGroup) => feedGroup.feed.entries !== undefined)
.flatMap((feedGroup) => feedGroup.feed.entries) .flatMap((feedGroup) => feedGroup.feed.entries)
.filter((entry) => entry !== undefined) .filter((entry) => entry !== undefined)

View File

@@ -11,7 +11,7 @@ import { optionsBuilder } from "../options";
* - https://datatracker.ietf.org/doc/html/rfc5023 * - https://datatracker.ietf.org/doc/html/rfc5023
* - https://www.jsonfeed.org/version/1.1/ * - https://www.jsonfeed.org/version/1.1/
*/ */
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("rssFeed", { export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss, icon: IconRss,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
feedUrls: factory.multiText({ feedUrls: factory.multiText({
@@ -27,6 +27,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
validate: z.number().min(1).max(9999), validate: z.number().min(1).max(9999),
}), }),
})), })),
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,21 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ itemId }: WidgetProps<"rssFeed">) {
if (!itemId) {
return {
initialData: undefined,
lastUpdatedAt: null,
};
}
const data = await api.widget.rssFeed.getFeeds({
itemId,
});
return {
initialData: data?.data,
lastUpdatedAt: data?.timestamp,
};
}

View File

@@ -1,13 +0,0 @@
"use client";
import { useServerDataInitializer } from "./provider";
interface Props {
id: string;
serverData: Record<string, unknown> | undefined;
}
export const ClientServerDataInitalizer = ({ id, serverData }: Props) => {
useServerDataInitializer(id, serverData);
return null;
};

View File

@@ -1,74 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useState } from "react";
type Data = Record<
string,
{
data: Record<string, unknown> | undefined;
isReady: boolean;
}
>;
interface GlobalItemServerDataContext {
setItemServerData: (id: string, data: Record<string, unknown> | undefined) => void;
data: Data;
initalItemIds: string[];
}
const GlobalItemServerDataContext = createContext<GlobalItemServerDataContext | null>(null);
interface Props {
initalItemIds: string[];
}
export const GlobalItemServerDataProvider = ({ children, initalItemIds }: PropsWithChildren<Props>) => {
const [data, setData] = useState<Data>({});
const setItemServerData = (id: string, itemData: Record<string, unknown> | undefined) => {
setData((prev) => ({
...prev,
[id]: {
data: itemData,
isReady: true,
},
}));
};
return (
<GlobalItemServerDataContext.Provider value={{ setItemServerData, data, initalItemIds }}>
{children}
</GlobalItemServerDataContext.Provider>
);
};
export const useServerDataFor = (id: string) => {
const context = useContext(GlobalItemServerDataContext);
if (!context) {
throw new Error("GlobalItemServerDataProvider is required");
}
// When the item is not in the initial list, it means the data can not come from the server
if (!context.initalItemIds.includes(id)) {
return {
data: undefined,
isReady: true,
};
}
return context.data[id];
};
export const useServerDataInitializer = (id: string, serverData: Record<string, unknown> | undefined) => {
const context = useContext(GlobalItemServerDataContext);
if (!context) {
throw new Error("GlobalItemServerDataProvider is required");
}
useEffect(() => {
context.setItemServerData(id, serverData);
}, []);
};

View File

@@ -1,51 +0,0 @@
import type { PropsWithChildren } from "react";
import { Suspense } from "react";
import type { RouterOutputs } from "@homarr/api";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "..";
import { ClientServerDataInitalizer } from "./client";
import { GlobalItemServerDataProvider } from "./provider";
type Board = RouterOutputs["board"]["getHomeBoard"];
type Props = PropsWithChildren<{
shouldRun: boolean;
board: Board;
}>;
export const GlobalItemServerDataRunner = ({ board, shouldRun, children }: Props) => {
if (!shouldRun) return children;
const allItems = board.sections.flatMap((section) => section.items);
return (
<GlobalItemServerDataProvider initalItemIds={allItems.map(({ id }) => id)}>
{allItems.map((item) => (
<Suspense key={item.id}>
<ItemDataLoader item={item} />
</Suspense>
))}
{children}
</GlobalItemServerDataProvider>
);
};
interface ItemDataLoaderProps {
item: Board["sections"][number]["items"][number];
}
const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const widgetImport = widgetImports[item.kind];
if (!("serverDataLoader" in widgetImport) || !widgetImport.serverDataLoader) {
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
}
const loader = await widgetImport.serverDataLoader();
const optionsWithDefault = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const data = await loader.default({
...item,
options: optionsWithDefault as never,
itemId: item.id,
});
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
};