feat: add app widget (#206)

* refactor: move server api to api package

* feat: add app widget

* refactor: add element size for widget components on board

* feat: add resize listener for widget width

* feat: add widget app input

* refactor: add better responsibe layout, add missing translations

* fix: ci issues

* fix: deepsource issues

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-03-12 21:23:25 +01:00
committed by GitHub
parent 7d5b999ab8
commit c4ff968cbc
31 changed files with 561 additions and 78 deletions

View File

@@ -1,7 +1,7 @@
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import { Container, Stack, Title } from "@homarr/ui";
import { api } from "~/trpc/server";
import { AppEditForm } from "./_app-edit-form";
interface AppEditPageProps {

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import {
ActionIcon,
@@ -18,7 +19,6 @@ import {
Title,
} from "@homarr/ui";
import { api } from "~/trpc/server";
import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() {

View File

@@ -1,8 +1,8 @@
import { api } from "@homarr/api/server";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { Container, Group, Stack, Title } from "@homarr/ui";
import { api } from "~/trpc/server";
import { IntegrationAvatar } from "../../_integration-avatar";
import { EditIntegrationForm } from "./_integration-edit-form";

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
@@ -32,7 +33,6 @@ import {
Title,
} from "@homarr/ui";
import { api } from "~/trpc/server";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons";

View File

@@ -1,4 +1,5 @@
import { api } from "~/trpc/server";
import { api } from "@homarr/api/server";
import { createBoardPage } from "../_creator";
export default createBoardPage<{ locale: string }>({

View File

@@ -1,4 +1,5 @@
import { api } from "~/trpc/server";
import { api } from "@homarr/api/server";
import { createBoardPage } from "../_creator";
export default createBoardPage<{ locale: string; name: string }>({

View File

@@ -1,5 +1,6 @@
import type { PropsWithChildren } from "react";
import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server";
@@ -20,7 +21,6 @@ import {
Title,
} from "@homarr/ui";
import { api } from "~/trpc/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { BackgroundSettingsContent } from "./_background";
import { ColorSettingsContent } from "./_colors";

View File

@@ -1,9 +1,9 @@
import React from "react";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Card, Grid, GridCol, Group, Text, Title } from "@homarr/ui";
import { api } from "~/trpc/server";
import { CreateBoardButton } from "./_components/create-board-button";
import { DeleteBoardButton } from "./_components/delete-board-button";

View File

@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import {
Accordion,
@@ -17,7 +18,6 @@ import {
Title,
} from "@homarr/ui";
import { api } from "~/trpc/server";
import { DangerZoneAccordion } from "./_components/dangerZone.accordion";
import { ProfileAccordion } from "./_components/profile.accordion";
import { SecurityAccordionComponent } from "./_components/security.accordion";

View File

@@ -1,6 +1,6 @@
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { api } from "~/trpc/server";
import { UserListComponent } from "./_components/user-list.component";
export async function generateMetadata() {

View File

@@ -8,6 +8,7 @@ import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
import { PreviewDimensionsModal } from "./widgets/[kind]/_dimension-modal";
export const [ModalsManager, modalEvents] = createModalManager({
categoryEditModal: CategoryEditModal,
@@ -15,4 +16,5 @@ export const [ModalsManager, modalEvents] = createModalManager({
itemSelectModal: ItemSelectModal,
addBoardModal: AddBoardModal,
boardRenameModal: BoardRenameModal,
dimensionsModal: PreviewDimensionsModal,
});

View File

@@ -1,9 +1,19 @@
"use client";
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { ActionIcon, Affix, IconPencil } from "@homarr/ui";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import {
ActionIcon,
Affix,
Card,
IconDimensions,
IconPencil,
IconToggleLeft,
IconToggleRight,
} from "@homarr/ui";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
@@ -11,6 +21,7 @@ import {
} from "@homarr/widgets";
import { modalEvents } from "../../modals";
import type { Dimensions } from "./_dimension-modal";
interface WidgetPreviewPageContentProps {
kind: WidgetKind;
@@ -26,7 +37,16 @@ export const WidgetPreviewPageContent = ({
kind,
integrationData,
}: WidgetPreviewPageContentProps) => {
const currentDefinition = widgetImports[kind].definition;
const t = useScopedI18n("widgetPreview");
const currentDefinition = useMemo(
() => widgetImports[kind].definition,
[kind],
);
const [editMode, setEditMode] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({
width: 128,
height: 128,
});
const [state, setState] = useState<{
options: Record<string, unknown>;
integrations: string[];
@@ -37,44 +57,97 @@ export const WidgetPreviewPageContent = ({
const Comp = loadWidgetDynamic(kind);
const openWitgetEditModal = useCallback(() => {
return modalEvents.openManagedModal({
modal: "widgetEditModal",
innerProps: {
kind,
value: state,
onSuccessfulEdit: (value) => {
setState(value);
},
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some(
(kind) => kind === integration.kind,
),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
},
});
}, [kind, state, integrationData, currentDefinition]);
const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode);
showSuccessNotification({
message: editMode ? t("toggle.disabled") : t("toggle.enabled"),
});
}, [editMode, t]);
const openDimensionsModal = useCallback(() => {
modalEvents.openManagedModal({
modal: "dimensionsModal",
title: t("dimensions.title"),
innerProps: {
dimensions,
setDimensions,
},
});
}, [dimensions, t]);
return (
<>
<Comp
options={state.options as never}
integrations={state.integrations.map(
(id) => integrationData.find((x) => x.id === id)!,
)}
/>
<Card
withBorder
w={dimensions.width}
h={dimensions.height}
p={dimensions.height >= 96 ? undefined : 4}
>
<Comp
options={state.options as never}
integrations={state.integrations.map(
(id) => integrationData.find((x) => x.id === id)!,
)}
width={dimensions.width}
height={dimensions.height}
isEditMode={editMode}
/>
</Card>
<Affix bottom={12} right={72}>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={() => {
return modalEvents.openManagedModal({
modal: "widgetEditModal",
innerProps: {
kind,
value: state,
onSuccessfulEdit: (value) => {
setState(value);
},
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some(
(kind) => kind === integration.kind,
),
),
integrationSupport:
"supportedIntegrations" in currentDefinition,
},
});
}}
onClick={openWitgetEditModal}
>
<IconPencil size={24} />
</ActionIcon>
</Affix>
<Affix bottom={12} right={72 + 60}>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={toggleEditMode}
>
{editMode ? (
<IconToggleLeft size={24} />
) : (
<IconToggleRight size={24} />
)}
</ActionIcon>
</Affix>
<Affix bottom={12} right={72 + 120}>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={openDimensionsModal}
>
<IconDimensions size={24} />
</ActionIcon>
</Affix>
</>
);
};

View File

@@ -0,0 +1,61 @@
"use client";
import type { ManagedModal } from "mantine-modal-manager";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { Button, Group, InputWrapper, Slider, Stack } from "@homarr/ui";
interface InnerProps {
dimensions: Dimensions;
setDimensions: (dimensions: Dimensions) => void;
}
export const PreviewDimensionsModal: ManagedModal<InnerProps> = ({
actions,
innerProps,
}) => {
const t = useI18n();
const form = useForm({
initialValues: innerProps.dimensions,
});
const handleSubmit = (values: Dimensions) => {
innerProps.setDimensions(values);
actions.closeModal();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<InputWrapper label={t("item.move.field.width.label")}>
<Slider
min={64}
max={1024}
step={64}
{...form.getInputProps("width")}
/>
</InputWrapper>
<InputWrapper label={t("item.move.field.height.label")}>
<Slider
min={64}
max={1024}
step={64}
{...form.getInputProps("height")}
/>
</InputWrapper>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
);
};
export interface Dimensions {
width: number;
height: number;
}

View File

@@ -2,6 +2,7 @@
// Ignored because of gridstack attributes
import type { RefObject } from "react";
import { useElementSize } from "@mantine/hooks";
import cx from "clsx";
import { useAtomValue } from "jotai";
@@ -36,6 +37,8 @@ interface Props {
export const SectionContent = ({ items, refs }: Props) => {
const board = useRequiredBoard();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { ref, width, height } = useElementSize<HTMLDivElement>();
return (
<>
@@ -56,6 +59,7 @@ export const SectionContent = ({ items, refs }: Props) => {
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
>
<Card
ref={ref as RefObject<HTMLDivElement>}
className={cx(classes.itemCard, "grid-stack-item-content")}
withBorder
styles={{
@@ -63,8 +67,9 @@ export const SectionContent = ({ items, refs }: Props) => {
"--opacity": board.opacity / 100,
},
}}
p={width >= 96 ? undefined : "xs"}
>
<BoardItem item={item} />
<BoardItem item={item} width={width + 32} height={height + 32} />
</Card>
</div>
);
@@ -75,9 +80,12 @@ export const SectionContent = ({ items, refs }: Props) => {
interface ItemProps {
item: Item;
width: number;
height: number;
}
const BoardItem = ({ item }: ItemProps) => {
const BoardItem = ({ item, ...dimensions }: ItemProps) => {
const editMode = useAtomValue(editModeAtom);
const serverData = useServerDataFor(item.id);
const Comp = loadWidgetDynamic(item.kind);
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
@@ -92,6 +100,8 @@ const BoardItem = ({ item }: ItemProps) => {
options={options as never}
integrations={item.integrations}
serverData={serverData?.data as never}
isEditMode={editMode}
{...dimensions}
/>
</>
);

View File

@@ -46,7 +46,7 @@ export const useGridstack = ({
// reference of the gridstack object for modifications after initialization
const gridRef = useRef<GridStack>();
useCssVariableConfiguration({ section, mainRef, gridRef });
useCssVariableConfiguration({ mainRef, gridRef });
const board = useRequiredBoard();
@@ -146,7 +146,6 @@ export const useGridstack = ({
};
interface UseCssVariableConfiguration {
section: Section;
mainRef?: RefObject<HTMLDivElement>;
gridRef: UseGridstackRefs["gridstack"];
}
@@ -155,12 +154,10 @@ interface UseCssVariableConfiguration {
* This hook is used to configure the css variables for the gridstack
* Those css variables are used to define the size of the gridstack items
* @see gridstack.scss
* @param section section of the board
* @param mainRef reference to the main div wrapping all sections
* @param gridRef reference to the gridstack object
*/
const useCssVariableConfiguration = ({
section,
mainRef,
gridRef,
}: UseCssVariableConfiguration) => {
@@ -175,14 +172,25 @@ const useCssVariableConfiguration = ({
// Define widget-width by calculating the width of one column with mainRef width and column count
useEffect(() => {
if (!mainRef?.current) return;
const widgetWidth = mainRef.current.clientWidth / board.columnCount;
// widget width is used to define sizes of gridstack items within global.scss
root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
gridRef.current?.cellHeight(widgetWidth);
// gridRef.current is required otherwise the cellheight is run on production as undefined
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [board.columnCount, root, section.kind, mainRef, gridRef.current]);
if (typeof document === "undefined") return;
const onResize = () => {
if (!mainRef?.current) return;
const widgetWidth = mainRef.current.clientWidth / board.columnCount;
// widget width is used to define sizes of gridstack items within global.scss
root?.style.setProperty(
"--gridstack-widget-width",
widgetWidth.toString(),
);
gridRef.current?.cellHeight(widgetWidth);
};
onResize();
if (typeof window === "undefined") return;
window.addEventListener("resize", onResize);
return () => {
if (typeof window === "undefined") return;
window.removeEventListener("resize", onResize);
};
}, [board.columnCount, mainRef, root, gridRef]);
// Define column count by using the sectionColumnCount
useEffect(() => {

View File

@@ -3,7 +3,8 @@
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts"
"./client": "./src/client.ts",
"./server": "./src/server.ts"
},
"private": true,
"main": "./index.ts",

View File

@@ -12,6 +12,16 @@ export const appRouter = createTRPCRouter({
orderBy: asc(apps.name),
});
}),
selectable: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.apps.findMany({
columns: {
id: true,
name: true,
iconUrl: true,
},
orderBy: asc(apps.name),
});
}),
byId: publicProcedure
.input(validation.app.byId)
.query(async ({ ctx, input }) => {

View File

@@ -19,7 +19,6 @@ import {
} from "@homarr/validation";
import { zodUnionFromArray } from "../../../validation/src/enums";
import type { WidgetComponentProps } from "../../../widgets/src/definition";
import { createTRPCRouter, publicProcedure } from "../trpc";
const filterAddedItems = <TInput extends { id: string }>(
@@ -387,21 +386,8 @@ const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
const forKind = <T extends WidgetKind>(kind: T) =>
z.object({
kind: z.literal(kind),
options: z.custom<Partial<WidgetComponentProps<T>["options"]>>(),
}) as UnionizeSpecificItemSchemaForWidgetKind<T>;
type SpecificItemSchemaForWidgetKind<TKind extends WidgetKind> = z.ZodObject<{
kind: z.ZodLiteral<TKind>;
options: z.ZodType<
Partial<WidgetComponentProps<TKind>["options"]>,
z.ZodTypeDef,
Partial<WidgetComponentProps<TKind>["options"]>
>;
}>;
type UnionizeSpecificItemSchemaForWidgetKind<T> = T extends WidgetKind
? SpecificItemSchemaForWidgetKind<T>
: never;
options: z.record(z.unknown()),
});
const outputItemSchema = zodUnionFromArray(
widgetKinds.map((kind) => forKind(kind)),

View File

@@ -1,2 +1,2 @@
export const widgetKinds = ["clock", "weather"] as const;
export const widgetKinds = ["clock", "weather", "app"] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -288,6 +288,16 @@ export default {
title: "Choose item to add",
addToBoard: "Add to board",
},
move: {
field: {
width: {
label: "Width",
},
height: {
label: "Height",
},
},
},
edit: {
title: "Edit item",
field: {
@@ -302,6 +312,27 @@ export default {
},
},
widget: {
app: {
name: "App",
description: "Embeds an app into the board.",
option: {
appId: {
label: "Choose app",
},
openInNewTab: {
label: "Open in new tab",
},
showDescriptionTooltip: {
label: "Show description tooltip",
},
},
error: {
notFound: {
label: "No app",
tooltip: "You have no valid app selected",
},
},
},
clock: {
name: "Date and time",
description: "Displays the current date and time.",
@@ -351,6 +382,15 @@ export default {
},
},
},
widgetPreview: {
toggle: {
enabled: "Edit mode enabled",
disabled: "Edit mode disabled",
},
dimensions: {
title: "Change dimensions",
},
},
board: {
action: {
edit: {

View File

@@ -1,4 +1,5 @@
import type { WidgetOptionType } from "../options";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
import { WidgetNumberInput } from "./widget-number-input";
import { WidgetSelectInput } from "./widget-select-input";
@@ -15,6 +16,7 @@ const mapping = {
select: WidgetSelectInput,
slider: WidgetSliderInput,
switch: WidgetSwitchInput,
app: WidgetAppInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(

View File

@@ -0,0 +1,97 @@
"use client";
import { memo, useMemo } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SelectProps } from "@homarr/ui";
import { Group, IconCheck, Loader, Select } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetAppInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"app">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
const currentApp = useMemo(
() => apps?.find((app) => app.id === form.values.options.appId),
[apps, form.values.options.appId],
);
return (
<Select
label={t("label")}
searchable
limit={10}
leftSection={
<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />
}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(`options.${property}`)}
/>
);
};
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({
option,
checked,
}) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group>
);
interface LeftSectionProps {
isPending: boolean;
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
}
const size = 20;
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
if (isPending) {
return <Loader size={size} />;
}
if (currentApp) {
return (
<img
width={size}
height={size}
src={currentApp.iconUrl}
alt={currentApp.name}
/>
);
}
return null;
};
const MemoizedLeftSection = memo(LeftSection);

View File

@@ -0,0 +1,13 @@
.appIcon {
max-height: 100%;
max-width: 100%;
overflow: auto;
flex: 1;
object-fit: contain;
scale: 0.8;
transition: scale 0.2s ease-in-out;
}
.appIcon:hover {
scale: 0.9;
}

View File

@@ -0,0 +1,135 @@
"use client";
import type { PropsWithChildren } from "react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import {
Center,
Flex,
IconDeviceDesktopX,
Loader,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
export default function AppWidget({
options,
serverData,
isEditMode,
width,
height,
}: WidgetComponentProps<"app">) {
const t = useScopedI18n("widget.app");
const {
data: app,
isPending,
isError,
} = clientApi.app.byId.useQuery(
{
id: options.appId,
},
{
initialData:
// We need to check if the id's match because otherwise the same initialData for a changed id will be used
serverData?.app.id === options.appId ? serverData?.app : undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
if (isPending) {
return (
<Center h="100%">
<Loader />
</Center>
);
}
if (isError) {
return (
<Tooltip.Floating label={t("error.notFound.tooltip")}>
<Stack gap="xs" align="center" justify="center" h="100%" w="100%">
<IconDeviceDesktopX size={width >= 96 ? "2rem" : "1.5rem"} />
{width >= 96 && (
<Text ta="center" size="sm">
{t("error.notFound.label")}
</Text>
)}
</Stack>
</Tooltip.Floating>
);
}
return (
<AppLink
href={app?.href ?? ""}
openInNewTab={options.openInNewTab}
enabled={Boolean(app?.href) && !isEditMode}
>
<Flex align="center" justify="center" h="100%">
<Tooltip.Floating
label={app?.description}
position="right-start"
multiline
disabled={!options.showDescriptionTooltip || !app?.description}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
h="100%"
direction="column"
align="center"
gap={0}
style={{
overflow: "visible",
flexGrow: 5,
}}
>
{height >= 96 && (
<Text fw={700} ta="center">
{app?.name}
</Text>
)}
<img
src={app?.iconUrl}
alt={app?.name}
className={classes.appIcon}
/>
</Flex>
</Tooltip.Floating>
</Flex>
</AppLink>
);
}
interface AppLinkProps {
href: string;
openInNewTab: boolean;
enabled: boolean;
}
const AppLink = ({
href,
openInNewTab,
enabled,
children,
}: PropsWithChildren<AppLinkProps>) =>
enabled ? (
<UnstyledButton
component="a"
href={href}
target={openInNewTab ? "_blank" : undefined}
h="100%"
w="100%"
>
{children}
</UnstyledButton>
) : (
children
);

View File

@@ -0,0 +1,16 @@
import { IconApps } from "@homarr/ui";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } =
createWidgetDefinition("app", {
icon: IconApps,
options: optionsBuilder.from((factory) => ({
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
})),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,10 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerData({ options }: WidgetProps<"app">) {
const app = await api.app.byId({ id: options.appId });
return { app };
}

View File

@@ -104,6 +104,10 @@ type inferServerDataForKind<TKind extends WidgetKind> =
export type WidgetComponentProps<TKind extends WidgetKind> =
WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
isEditMode: boolean;
width: number;
height: number;
};
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> =

View File

@@ -5,6 +5,7 @@ import type { Loader } from "next/dynamic";
import type { WidgetKind } from "@homarr/definitions";
import { Loader as UiLoader } from "@homarr/ui";
import * as app from "./app";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import type { WidgetImportRecord } from "./import";
@@ -19,6 +20,7 @@ export { useServerDataFor } from "./server/provider";
export const widgetImports = {
clock,
weather,
app,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -104,6 +104,11 @@ const optionsFactory = {
defaultValue: input?.defaultValue ?? [],
withDescription: input?.withDescription ?? false,
}),
app: (input?: Omit<CommonInput<string>, "defaultValue">) => ({
type: "app" as const,
defaultValue: "",
withDescription: input?.withDescription ?? false,
}),
};
type WidgetOptionFactory = typeof optionsFactory;

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import type { RouterOutputs } from "@homarr/api";
import { widgetImports } from "..";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "..";
import { ClientServerDataInitalizer } from "./client";
import { GlobalItemServerDataProvider } from "./provider";
@@ -32,13 +32,19 @@ interface ItemDataLoaderProps {
item: Board["sections"][number]["items"][number];
}
const ItemDataLoader = /*async*/ ({ item }: ItemDataLoaderProps) => {
const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const widgetImport = widgetImports[item.kind];
if (!("serverDataLoader" in widgetImport)) {
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
}
//const loader = await widgetImport.serverDataLoader();
//const data = await loader.default(item as never);
//return <ClientServerDataInitalizer id={item.id} serverData={data} />;
return null;
const loader = await widgetImport.serverDataLoader();
const optionsWithDefault = reduceWidgetOptionsWithDefaultValues(
item.kind,
item.options,
);
const data = await loader.default({
...item,
options: optionsWithDefault as never,
});
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
};