mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
refactor: replace serverdata with suspense query (#1265)
* refactor: replace serverdata with suspense query * fix: deepsource issues
This commit is contained in:
@@ -28,7 +28,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
isBoardContentPage: true,
|
||||
}),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
page: async () => {
|
||||
|
||||
@@ -8,5 +8,4 @@ export default createBoardLayout<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
},
|
||||
isBoardContentPage: false,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
import { GlobalItemServerDataRunner } from "@homarr/widgets";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
@@ -18,13 +17,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
isBoardContentPage: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
isBoardContentPage,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
const Layout = async ({
|
||||
params,
|
||||
@@ -42,21 +39,19 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
});
|
||||
|
||||
return (
|
||||
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardMantineProvider>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardMantineProvider>
|
||||
</BoardProvider>
|
||||
</GlobalItemServerDataRunner>
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardMantineProvider>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardMantineProvider>
|
||||
</BoardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
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 type { Item } from "~/app/[locale]/boards/_types";
|
||||
@@ -53,7 +53,6 @@ interface InnerContentProps {
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const serverData = useServerDataFor(item.id);
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
||||
const newItem = { ...item, options };
|
||||
@@ -61,8 +60,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
|
||||
if (!serverData?.isReady) return null;
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
@@ -79,8 +76,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
serverData={serverData?.data as never}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
|
||||
@@ -9,11 +9,12 @@ export const calendarRouter = createTRPCRouter({
|
||||
findAllEvents: publicProcedure
|
||||
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
const result = await Promise.all(
|
||||
ctx.integrations.flatMap(async (integration) => {
|
||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
||||
return await cache.getAsync();
|
||||
}),
|
||||
);
|
||||
return result.filter((item) => item !== null).flatMap((item) => item.data);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IconApps, IconDeviceDesktopX } from "@tabler/icons-react";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("app", {
|
||||
export const { definition, componentLoader } = createWidgetDefinition("app", {
|
||||
icon: IconApps,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
appId: factory.app(),
|
||||
|
||||
@@ -11,7 +11,20 @@ import type { WidgetComponentProps } from "../definition";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
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 params = useParams();
|
||||
const locale = params.locale as string;
|
||||
@@ -68,7 +81,7 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
|
||||
},
|
||||
}}
|
||||
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} />;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "@homarr/validation";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", {
|
||||
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
filterPastMonths: factory.number({
|
||||
@@ -19,6 +19,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,57 +8,22 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import type { WidgetImports } from ".";
|
||||
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
||||
|
||||
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
|
||||
default: (props: WidgetProps<TKind>) => Promise<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
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) =>
|
||||
<TServerDataLoader extends ServerDataLoader<TKind>>(serverDataLoader: TServerDataLoader) => ({
|
||||
(componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>) => ({
|
||||
definition: {
|
||||
...definition,
|
||||
kind,
|
||||
},
|
||||
kind,
|
||||
serverDataLoader,
|
||||
withDynamicImport: createWithDynamicImport(kind, definition, serverDataLoader),
|
||||
componentLoader,
|
||||
});
|
||||
|
||||
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
|
||||
kind: TKind,
|
||||
definition: TDefinition,
|
||||
) => ({
|
||||
withServerData: createWithServerData(kind, definition),
|
||||
withDynamicImport: createWithDynamicImport(kind, definition, undefined),
|
||||
withDynamicImport: createWithDynamicImport(kind, definition),
|
||||
});
|
||||
|
||||
export interface WidgetDefinition {
|
||||
@@ -83,15 +48,7 @@ export interface WidgetProps<TKind extends WidgetKind> {
|
||||
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> & {
|
||||
serverData?: inferServerDataForKind<TKind>;
|
||||
} & {
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
isEditMode: boolean;
|
||||
setOptions: ({
|
||||
|
||||
@@ -39,16 +39,25 @@ export default function DnsHoleControlsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
serverData,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
// DnsHole integrations with interaction permissions
|
||||
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
|
||||
.map(({ id }) => id)
|
||||
.filter((id) => integrationIds.includes(id));
|
||||
|
||||
// Initial summaries, null summary means disconnected, undefined status means processing
|
||||
const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
|
||||
|
||||
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||
{
|
||||
widgetKind: "dnsHoleControls",
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const utils = clientApi.useUtils();
|
||||
// Subscribe to summary updates
|
||||
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
|
||||
{
|
||||
@@ -57,8 +66,20 @@ export default function DnsHoleControlsWidget({
|
||||
},
|
||||
{
|
||||
onData: (data) => {
|
||||
setSummaries((prevSummaries) =>
|
||||
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
|
||||
utils.widget.dnsHole.summary.setData(
|
||||
{
|
||||
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
|
||||
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
|
||||
onSettled: (_, error, { integrationId }) => {
|
||||
setSummaries((prevSummaries) =>
|
||||
prevSummaries.map((data) => ({
|
||||
...data,
|
||||
summary:
|
||||
data.integration.id === integrationId && data.summary
|
||||
? { ...data.summary, status: error ? "disabled" : "enabled" }
|
||||
: data.summary,
|
||||
})),
|
||||
utils.widget.dnsHole.summary.setData(
|
||||
{
|
||||
widgetKind: "dnsHoleControls",
|
||||
integrationIds,
|
||||
},
|
||||
(prevData) => {
|
||||
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({
|
||||
onSettled: (_, error, { integrationId }) => {
|
||||
setSummaries((prevSummaries) =>
|
||||
prevSummaries.map((data) => ({
|
||||
...data,
|
||||
summary:
|
||||
data.integration.id === integrationId && data.summary
|
||||
? { ...data.summary, status: error ? "enabled" : "disabled" }
|
||||
: data.summary,
|
||||
})),
|
||||
utils.widget.dnsHole.summary.setData(
|
||||
{
|
||||
widgetKind: "dnsHoleControls",
|
||||
integrationIds,
|
||||
},
|
||||
(prevData) => {
|
||||
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 integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
|
||||
if (!integrationStatus?.summary?.status) return;
|
||||
setSummaries((prevSummaries) =>
|
||||
prevSummaries.map((data) => ({
|
||||
...data,
|
||||
summary:
|
||||
data.integration.id === integrationId && data.summary ? { ...data.summary, status: undefined } : data.summary,
|
||||
})),
|
||||
utils.widget.dnsHole.summary.setData(
|
||||
{
|
||||
widgetKind: "dnsHoleControls",
|
||||
integrationIds,
|
||||
},
|
||||
(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") {
|
||||
disableDns({ integrationId, duration: 0 });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const widgetKind = "dnsHoleControls";
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
icon: IconDeviceGamepad,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
showToggleAllButtons: factory.switch({
|
||||
@@ -21,6 +21,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
@@ -20,12 +20,20 @@ import { widgetKind } from ".";
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
import { NoIntegrationSelectedError } from "../../errors";
|
||||
|
||||
export default function DnsHoleSummaryWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
serverData,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
|
||||
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
|
||||
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||
{
|
||||
widgetKind,
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
@@ -36,8 +44,21 @@ export default function DnsHoleSummaryWidget({
|
||||
},
|
||||
{
|
||||
onData: (data) => {
|
||||
setSummaries((prevSummaries) =>
|
||||
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
|
||||
utils.widget.dnsHole.summary.setData(
|
||||
{
|
||||
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(
|
||||
() =>
|
||||
summaries
|
||||
.filter(
|
||||
(
|
||||
pair,
|
||||
): pair is {
|
||||
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],
|
||||
.filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000)
|
||||
.flatMap(({ summary }) => summary)
|
||||
.filter((summary) => summary !== null),
|
||||
[summaries],
|
||||
);
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const widgetKind = "dnsHoleSummary";
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
icon: IconAd,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
usePiHoleColors: factory.switch({
|
||||
@@ -28,6 +28,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure, useListState, useTimeout } from "@mantine/hooks";
|
||||
import { useDisclosure, useTimeout } from "@mantine/hooks";
|
||||
import type { IconProps } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
@@ -40,9 +40,6 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
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 type {
|
||||
DownloadClientJobsAndStatus,
|
||||
@@ -91,30 +88,35 @@ export default function DownloadClientsWidget({
|
||||
isEditMode,
|
||||
integrationIds,
|
||||
options,
|
||||
serverData,
|
||||
setOptions,
|
||||
}: WidgetComponentProps<"downloads">) {
|
||||
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
|
||||
integrationIds.includes(id) ? [id] : [],
|
||||
);
|
||||
|
||||
const [currentItems, currentItemsHandlers] = useListState<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
||||
timestamp: Date;
|
||||
data: DownloadClientJobsAndStatus | null;
|
||||
}>(
|
||||
//Automatically invalidate data older than 30 seconds
|
||||
serverData?.initialData?.map((item) =>
|
||||
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null },
|
||||
) ?? [],
|
||||
const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
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
|
||||
const invalidationTimer = useTimeout(
|
||||
() => {
|
||||
currentItemsHandlers.applyWhere(
|
||||
() => true,
|
||||
(item) => ({ ...item, timestamp: new Date(0), data: null }),
|
||||
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
|
||||
prevData?.map((item) => ({ ...item, timestamp: new Date(0), data: null })),
|
||||
);
|
||||
},
|
||||
invalidateTime,
|
||||
@@ -146,20 +148,24 @@ export default function DownloadClientsWidget({
|
||||
//Don't update already invalid data (new Date (0))
|
||||
.filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0))
|
||||
.map(({ integration }) => integration.id);
|
||||
currentItemsHandlers.applyWhere(
|
||||
({ integration }) => invalidIndexes.includes(integration.id),
|
||||
//Set date to now so it won't update that integration for at least 30 seconds
|
||||
(item) => ({ ...item, timestamp: new Date(0), data: null }),
|
||||
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
|
||||
prevData?.map((item) =>
|
||||
invalidIndexes.includes(item.integration.id) ? item : { ...item, timestamp: new Date(0), data: null },
|
||||
),
|
||||
);
|
||||
//Find id to update
|
||||
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
|
||||
if (updateIndex >= 0) {
|
||||
//Update found index
|
||||
currentItemsHandlers.setItem(updateIndex, data);
|
||||
} else if (integrationIds.includes(data.integration.id)) {
|
||||
//Append index not found (new integration)
|
||||
currentItemsHandlers.append(data);
|
||||
}
|
||||
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => {
|
||||
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
|
||||
if (updateIndex >= 0) {
|
||||
//Update found index
|
||||
return prevData?.map((pair, index) => (index === updateIndex ? data : pair));
|
||||
} else if (integrationIds.includes(data.integration.id)) {
|
||||
//Append index not found (new integration)
|
||||
return [...(prevData ?? []), data];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
//Reset no update timer
|
||||
invalidationTimer.clear();
|
||||
invalidationTimer.start();
|
||||
|
||||
@@ -31,7 +31,7 @@ const columnsSort = columnsList.filter((column) =>
|
||||
sortingExclusion.some((exclusion) => exclusion !== column),
|
||||
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", {
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
options: optionsBuilder.from(
|
||||
(factory) => ({
|
||||
@@ -105,6 +105,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
},
|
||||
),
|
||||
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure, useElementSize, useListState } from "@mantine/hooks";
|
||||
import { useDisclosure, useElementSize } from "@mantine/hooks";
|
||||
import {
|
||||
IconBrain,
|
||||
IconClock,
|
||||
@@ -30,19 +30,27 @@ import {
|
||||
IconVersions,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationSelectedError } from "../errors";
|
||||
|
||||
export default function HealthMonitoringWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
serverData,
|
||||
}: WidgetComponentProps<"healthMonitoring">) {
|
||||
export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
|
||||
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);
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("healthMonitoring", {
|
||||
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
|
||||
icon: IconHeartRateMonitor,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
fahrenheit: factory.switch({
|
||||
@@ -26,6 +26,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
message: (t) => t("widget.healthMonitoring.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,6 @@ export { reduceWidgetOptionsWithDefaultValues } from "./options";
|
||||
|
||||
export type { WidgetDefinition } from "./definition";
|
||||
export { WidgetEditModal } from "./modals/widget-edit-modal";
|
||||
export { useServerDataFor } from "./server/provider";
|
||||
export { GlobalItemServerDataRunner } from "./server/runner";
|
||||
export type { WidgetComponentProps };
|
||||
|
||||
export const widgetImports = {
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core";
|
||||
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { Indexer } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationSelectedError } from "../errors";
|
||||
|
||||
export default function IndexerManagerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
serverData,
|
||||
}: WidgetComponentProps<"indexerManager">) {
|
||||
export default function IndexerManagerWidget({ options, integrationIds }: WidgetComponentProps<"indexerManager">) {
|
||||
const t = useI18n();
|
||||
const [indexersData, setIndexersData] = useState<{ integrationId: string; indexers: Indexer[] }[]>(
|
||||
serverData?.initialData ?? [],
|
||||
const [indexersData] = clientApi.widget.indexerManager.getIndexersStatus.useSuspenseQuery(
|
||||
{ integrationIds },
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation();
|
||||
|
||||
@@ -27,11 +28,11 @@ export default function IndexerManagerWidget({
|
||||
{ integrationIds },
|
||||
{
|
||||
onData(newData) {
|
||||
setIndexersData((prevData) => {
|
||||
return prevData.map((item) =>
|
||||
utils.widget.indexerManager.getIndexersStatus.setData({ integrationIds }, (previousData) =>
|
||||
previousData?.map((item) =>
|
||||
item.integrationId === newData.integrationId ? { ...item, indexers: newData.indexers } : item,
|
||||
);
|
||||
});
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("indexerManager", {
|
||||
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
|
||||
icon: IconReportSearch,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
openIndexerSiteInNewTab: factory.switch({
|
||||
@@ -19,6 +19,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
message: (t) => t("widget.indexerManager.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,30 +15,26 @@ export default function MediaServerWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
options,
|
||||
serverData,
|
||||
itemId,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||
const isQueryEnabled = Boolean(itemId);
|
||||
const { data: mediaRequests, isError: _isError } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
|
||||
const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
|
||||
{
|
||||
integrationIds,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemId!,
|
||||
},
|
||||
{
|
||||
initialData: !serverData ? undefined : serverData.initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: integrationIds.length > 0 && isQueryEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
const sortedMediaRequests = useMemo(
|
||||
() =>
|
||||
mediaRequests
|
||||
?.filter((group) => group != null)
|
||||
.filter((group) => group != null)
|
||||
.flatMap((group) => group.data)
|
||||
.flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id })))
|
||||
.sort(({ status: statusA }, { status: statusB }) => {
|
||||
@@ -49,7 +45,7 @@ export default function MediaServerWidget({
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}) ?? [],
|
||||
}),
|
||||
[mediaRequests, integrationIds],
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestList", {
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
|
||||
icon: IconZoomQuestion,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
linksTargetNewTab: factory.switch({
|
||||
@@ -13,6 +13,4 @@ export const { componentLoader, definition, serverDataLoader } = createWidgetDef
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -27,30 +27,26 @@ import classes from "./component.module.css";
|
||||
export default function MediaServerWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
serverData,
|
||||
itemId,
|
||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||
const t = useScopedI18n("widget.mediaRequests-requestStats");
|
||||
const isQueryEnabled = Boolean(itemId);
|
||||
const { data: requestStats, isError: _isError } = clientApi.widget.mediaRequests.getStats.useQuery(
|
||||
const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
|
||||
{
|
||||
integrationIds,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemId!,
|
||||
},
|
||||
{
|
||||
initialData: !serverData ? undefined : serverData.initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: integrationIds.length > 0 && isQueryEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
const { width, height, ref } = useElementSize();
|
||||
|
||||
const baseData = useMemo(
|
||||
() => requestStats?.filter((group) => group != null).flatMap((group) => group.data) ?? [],
|
||||
() => requestStats.filter((group) => group != null).flatMap((group) => group.data),
|
||||
[requestStats],
|
||||
);
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
|
||||
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestStats", {
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
|
||||
icon: IconChartBar,
|
||||
options: {},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Avatar, Box, Group, Text } from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import type { MRT_ColumnDef } 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";
|
||||
|
||||
export default function MediaServerWidget({
|
||||
serverData,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: WidgetComponentProps<"mediaServer">) {
|
||||
const [currentStreams, currentStreamsHandlers] = useListState<{ integrationId: string; sessions: StreamSession[] }>(
|
||||
serverData?.initialData ?? [],
|
||||
export default function MediaServerWidget({ integrationIds, isEditMode }: WidgetComponentProps<"mediaServer">) {
|
||||
const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -62,15 +66,17 @@ export default function MediaServerWidget({
|
||||
{
|
||||
enabled: !isEditMode,
|
||||
onData(data) {
|
||||
currentStreamsHandlers.applyWhere(
|
||||
(pair) => pair.integrationId === data.integrationId,
|
||||
(pair) => {
|
||||
return {
|
||||
...pair,
|
||||
sessions: data.data,
|
||||
};
|
||||
},
|
||||
);
|
||||
utils.widget.mediaServer.getCurrentStreams.setData({ integrationIds }, (previousData) => {
|
||||
return previousData?.map((pair) => {
|
||||
if (pair.integrationId === data.integrationId) {
|
||||
return {
|
||||
...pair,
|
||||
sessions: data.data,
|
||||
};
|
||||
}
|
||||
return pair;
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,10 +2,8 @@ import { IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
|
||||
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaServer", {
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
|
||||
icon: IconVideo,
|
||||
options: {},
|
||||
supportedIntegrations: ["jellyfin"],
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -3,15 +3,29 @@ import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.scss";
|
||||
|
||||
export default function RssFeed({ serverData, options }: WidgetComponentProps<"rssFeed">) {
|
||||
if (serverData?.initialData === undefined) {
|
||||
return null;
|
||||
}
|
||||
export default function RssFeed({ options, itemId }: WidgetComponentProps<"rssFeed">) {
|
||||
const [rssFeeds] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery(
|
||||
{
|
||||
// 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)
|
||||
.flatMap((feedGroup) => feedGroup.feed.entries)
|
||||
.filter((entry) => entry !== undefined)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { optionsBuilder } from "../options";
|
||||
* - https://datatracker.ietf.org/doc/html/rfc5023
|
||||
* - https://www.jsonfeed.org/version/1.1/
|
||||
*/
|
||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("rssFeed", {
|
||||
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
|
||||
icon: IconRss,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
feedUrls: factory.multiText({
|
||||
@@ -27,6 +27,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
validate: z.number().min(1).max(9999),
|
||||
}),
|
||||
})),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}, []);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
Reference in New Issue
Block a user