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:
@@ -1,7 +1,7 @@
|
|||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { Container, Stack, Title } from "@homarr/ui";
|
import { Container, Stack, Title } from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { AppEditForm } from "./_app-edit-form";
|
import { AppEditForm } from "./_app-edit-form";
|
||||||
|
|
||||||
interface AppEditPageProps {
|
interface AppEditPageProps {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@homarr/ui";
|
} from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { AppDeleteButton } from "./_app-delete-button";
|
import { AppDeleteButton } from "./_app-delete-button";
|
||||||
|
|
||||||
export default async function AppsPage() {
|
export default async function AppsPage() {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Container, Group, Stack, Title } from "@homarr/ui";
|
import { Container, Group, Stack, Title } from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
import { IntegrationAvatar } from "../../_integration-avatar";
|
||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries } from "@homarr/common";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
@@ -32,7 +33,6 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@homarr/ui";
|
} from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
import { IntegrationAvatar } from "./_integration-avatar";
|
import { IntegrationAvatar } from "./_integration-avatar";
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from "~/trpc/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
|
||||||
import { createBoardPage } from "../_creator";
|
import { createBoardPage } from "../_creator";
|
||||||
|
|
||||||
export default createBoardPage<{ locale: string }>({
|
export default createBoardPage<{ locale: string }>({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from "~/trpc/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
|
||||||
import { createBoardPage } from "../_creator";
|
import { createBoardPage } from "../_creator";
|
||||||
|
|
||||||
export default createBoardPage<{ locale: string; name: string }>({
|
export default createBoardPage<{ locale: string; name: string }>({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { capitalize } from "@homarr/common";
|
import { capitalize } from "@homarr/common";
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@homarr/ui";
|
} from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||||
import { BackgroundSettingsContent } from "./_background";
|
import { BackgroundSettingsContent } from "./_background";
|
||||||
import { ColorSettingsContent } from "./_colors";
|
import { ColorSettingsContent } from "./_colors";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Card, Grid, GridCol, Group, Text, Title } from "@homarr/ui";
|
import { Card, Grid, GridCol, Group, Text, Title } from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { CreateBoardButton } from "./_components/create-board-button";
|
import { CreateBoardButton } from "./_components/create-board-button";
|
||||||
import { DeleteBoardButton } from "./_components/delete-board-button";
|
import { DeleteBoardButton } from "./_components/delete-board-button";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@homarr/ui";
|
} from "@homarr/ui";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { DangerZoneAccordion } from "./_components/dangerZone.accordion";
|
import { DangerZoneAccordion } from "./_components/dangerZone.accordion";
|
||||||
import { ProfileAccordion } from "./_components/profile.accordion";
|
import { ProfileAccordion } from "./_components/profile.accordion";
|
||||||
import { SecurityAccordionComponent } from "./_components/security.accordion";
|
import { SecurityAccordionComponent } from "./_components/security.accordion";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { UserListComponent } from "./_components/user-list.component";
|
import { UserListComponent } from "./_components/user-list.component";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
|||||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
||||||
|
import { PreviewDimensionsModal } from "./widgets/[kind]/_dimension-modal";
|
||||||
|
|
||||||
export const [ModalsManager, modalEvents] = createModalManager({
|
export const [ModalsManager, modalEvents] = createModalManager({
|
||||||
categoryEditModal: CategoryEditModal,
|
categoryEditModal: CategoryEditModal,
|
||||||
@@ -15,4 +16,5 @@ export const [ModalsManager, modalEvents] = createModalManager({
|
|||||||
itemSelectModal: ItemSelectModal,
|
itemSelectModal: ItemSelectModal,
|
||||||
addBoardModal: AddBoardModal,
|
addBoardModal: AddBoardModal,
|
||||||
boardRenameModal: BoardRenameModal,
|
boardRenameModal: BoardRenameModal,
|
||||||
|
dimensionsModal: PreviewDimensionsModal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
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 {
|
import {
|
||||||
loadWidgetDynamic,
|
loadWidgetDynamic,
|
||||||
reduceWidgetOptionsWithDefaultValues,
|
reduceWidgetOptionsWithDefaultValues,
|
||||||
@@ -11,6 +21,7 @@ import {
|
|||||||
} from "@homarr/widgets";
|
} from "@homarr/widgets";
|
||||||
|
|
||||||
import { modalEvents } from "../../modals";
|
import { modalEvents } from "../../modals";
|
||||||
|
import type { Dimensions } from "./_dimension-modal";
|
||||||
|
|
||||||
interface WidgetPreviewPageContentProps {
|
interface WidgetPreviewPageContentProps {
|
||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
@@ -26,7 +37,16 @@ export const WidgetPreviewPageContent = ({
|
|||||||
kind,
|
kind,
|
||||||
integrationData,
|
integrationData,
|
||||||
}: WidgetPreviewPageContentProps) => {
|
}: 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<{
|
const [state, setState] = useState<{
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
integrations: string[];
|
integrations: string[];
|
||||||
@@ -37,44 +57,97 @@ export const WidgetPreviewPageContent = ({
|
|||||||
|
|
||||||
const Comp = loadWidgetDynamic(kind);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Comp
|
<Card
|
||||||
options={state.options as never}
|
withBorder
|
||||||
integrations={state.integrations.map(
|
w={dimensions.width}
|
||||||
(id) => integrationData.find((x) => x.id === id)!,
|
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}>
|
<Affix bottom={12} right={72}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={48}
|
size={48}
|
||||||
variant="default"
|
variant="default"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={() => {
|
onClick={openWitgetEditModal}
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconPencil size={24} />
|
<IconPencil size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Affix>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// Ignored because of gridstack attributes
|
// Ignored because of gridstack attributes
|
||||||
|
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import cx from "clsx";
|
import cx from "clsx";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ interface Props {
|
|||||||
|
|
||||||
export const SectionContent = ({ items, refs }: Props) => {
|
export const SectionContent = ({ items, refs }: Props) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,6 +59,7 @@ export const SectionContent = ({ items, refs }: Props) => {
|
|||||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
|
ref={ref as RefObject<HTMLDivElement>}
|
||||||
className={cx(classes.itemCard, "grid-stack-item-content")}
|
className={cx(classes.itemCard, "grid-stack-item-content")}
|
||||||
withBorder
|
withBorder
|
||||||
styles={{
|
styles={{
|
||||||
@@ -63,8 +67,9 @@ export const SectionContent = ({ items, refs }: Props) => {
|
|||||||
"--opacity": board.opacity / 100,
|
"--opacity": board.opacity / 100,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
p={width >= 96 ? undefined : "xs"}
|
||||||
>
|
>
|
||||||
<BoardItem item={item} />
|
<BoardItem item={item} width={width + 32} height={height + 32} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -75,9 +80,12 @@ export const SectionContent = ({ items, refs }: Props) => {
|
|||||||
|
|
||||||
interface ItemProps {
|
interface ItemProps {
|
||||||
item: Item;
|
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 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);
|
||||||
@@ -92,6 +100,8 @@ const BoardItem = ({ item }: ItemProps) => {
|
|||||||
options={options as never}
|
options={options as never}
|
||||||
integrations={item.integrations}
|
integrations={item.integrations}
|
||||||
serverData={serverData?.data as never}
|
serverData={serverData?.data as never}
|
||||||
|
isEditMode={editMode}
|
||||||
|
{...dimensions}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const useGridstack = ({
|
|||||||
// reference of the gridstack object for modifications after initialization
|
// reference of the gridstack object for modifications after initialization
|
||||||
const gridRef = useRef<GridStack>();
|
const gridRef = useRef<GridStack>();
|
||||||
|
|
||||||
useCssVariableConfiguration({ section, mainRef, gridRef });
|
useCssVariableConfiguration({ mainRef, gridRef });
|
||||||
|
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
@@ -146,7 +146,6 @@ export const useGridstack = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface UseCssVariableConfiguration {
|
interface UseCssVariableConfiguration {
|
||||||
section: Section;
|
|
||||||
mainRef?: RefObject<HTMLDivElement>;
|
mainRef?: RefObject<HTMLDivElement>;
|
||||||
gridRef: UseGridstackRefs["gridstack"];
|
gridRef: UseGridstackRefs["gridstack"];
|
||||||
}
|
}
|
||||||
@@ -155,12 +154,10 @@ interface UseCssVariableConfiguration {
|
|||||||
* This hook is used to configure the css variables for the gridstack
|
* 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
|
* Those css variables are used to define the size of the gridstack items
|
||||||
* @see gridstack.scss
|
* @see gridstack.scss
|
||||||
* @param section section of the board
|
|
||||||
* @param mainRef reference to the main div wrapping all sections
|
* @param mainRef reference to the main div wrapping all sections
|
||||||
* @param gridRef reference to the gridstack object
|
* @param gridRef reference to the gridstack object
|
||||||
*/
|
*/
|
||||||
const useCssVariableConfiguration = ({
|
const useCssVariableConfiguration = ({
|
||||||
section,
|
|
||||||
mainRef,
|
mainRef,
|
||||||
gridRef,
|
gridRef,
|
||||||
}: UseCssVariableConfiguration) => {
|
}: UseCssVariableConfiguration) => {
|
||||||
@@ -175,14 +172,25 @@ const useCssVariableConfiguration = ({
|
|||||||
|
|
||||||
// Define widget-width by calculating the width of one column with mainRef width and column count
|
// Define widget-width by calculating the width of one column with mainRef width and column count
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mainRef?.current) return;
|
if (typeof document === "undefined") return;
|
||||||
const widgetWidth = mainRef.current.clientWidth / board.columnCount;
|
const onResize = () => {
|
||||||
// widget width is used to define sizes of gridstack items within global.scss
|
if (!mainRef?.current) return;
|
||||||
root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
|
const widgetWidth = mainRef.current.clientWidth / board.columnCount;
|
||||||
gridRef.current?.cellHeight(widgetWidth);
|
// widget width is used to define sizes of gridstack items within global.scss
|
||||||
// gridRef.current is required otherwise the cellheight is run on production as undefined
|
root?.style.setProperty(
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
"--gridstack-widget-width",
|
||||||
}, [board.columnCount, root, section.kind, mainRef, gridRef.current]);
|
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
|
// Define column count by using the sectionColumnCount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./client": "./src/client.ts"
|
"./client": "./src/client.ts",
|
||||||
|
"./server": "./src/server.ts"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ export const appRouter = createTRPCRouter({
|
|||||||
orderBy: asc(apps.name),
|
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
|
byId: publicProcedure
|
||||||
.input(validation.app.byId)
|
.input(validation.app.byId)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from "@homarr/validation";
|
} from "@homarr/validation";
|
||||||
|
|
||||||
import { zodUnionFromArray } from "../../../validation/src/enums";
|
import { zodUnionFromArray } from "../../../validation/src/enums";
|
||||||
import type { WidgetComponentProps } from "../../../widgets/src/definition";
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
const filterAddedItems = <TInput extends { id: string }>(
|
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) =>
|
const forKind = <T extends WidgetKind>(kind: T) =>
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal(kind),
|
kind: z.literal(kind),
|
||||||
options: z.custom<Partial<WidgetComponentProps<T>["options"]>>(),
|
options: z.record(z.unknown()),
|
||||||
}) 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;
|
|
||||||
|
|
||||||
const outputItemSchema = zodUnionFromArray(
|
const outputItemSchema = zodUnionFromArray(
|
||||||
widgetKinds.map((kind) => forKind(kind)),
|
widgetKinds.map((kind) => forKind(kind)),
|
||||||
|
|||||||
@@ -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];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -288,6 +288,16 @@ export default {
|
|||||||
title: "Choose item to add",
|
title: "Choose item to add",
|
||||||
addToBoard: "Add to board",
|
addToBoard: "Add to board",
|
||||||
},
|
},
|
||||||
|
move: {
|
||||||
|
field: {
|
||||||
|
width: {
|
||||||
|
label: "Width",
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
label: "Height",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
edit: {
|
edit: {
|
||||||
title: "Edit item",
|
title: "Edit item",
|
||||||
field: {
|
field: {
|
||||||
@@ -302,6 +312,27 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
widget: {
|
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: {
|
clock: {
|
||||||
name: "Date and time",
|
name: "Date and time",
|
||||||
description: "Displays the current 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: {
|
board: {
|
||||||
action: {
|
action: {
|
||||||
edit: {
|
edit: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { WidgetOptionType } from "../options";
|
import type { WidgetOptionType } from "../options";
|
||||||
|
import { WidgetAppInput } from "./widget-app-input";
|
||||||
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
|
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
|
||||||
import { WidgetNumberInput } from "./widget-number-input";
|
import { WidgetNumberInput } from "./widget-number-input";
|
||||||
import { WidgetSelectInput } from "./widget-select-input";
|
import { WidgetSelectInput } from "./widget-select-input";
|
||||||
@@ -15,6 +16,7 @@ const mapping = {
|
|||||||
select: WidgetSelectInput,
|
select: WidgetSelectInput,
|
||||||
slider: WidgetSliderInput,
|
slider: WidgetSliderInput,
|
||||||
switch: WidgetSwitchInput,
|
switch: WidgetSwitchInput,
|
||||||
|
app: WidgetAppInput,
|
||||||
} satisfies Record<WidgetOptionType, unknown>;
|
} satisfies Record<WidgetOptionType, unknown>;
|
||||||
|
|
||||||
export const getInputForType = <TType extends WidgetOptionType>(
|
export const getInputForType = <TType extends WidgetOptionType>(
|
||||||
|
|||||||
97
packages/widgets/src/_inputs/widget-app-input.tsx
Normal file
97
packages/widgets/src/_inputs/widget-app-input.tsx
Normal 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);
|
||||||
13
packages/widgets/src/app/app.module.css
Normal file
13
packages/widgets/src/app/app.module.css
Normal 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;
|
||||||
|
}
|
||||||
135
packages/widgets/src/app/component.tsx
Normal file
135
packages/widgets/src/app/component.tsx
Normal 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
|
||||||
|
);
|
||||||
16
packages/widgets/src/app/index.ts
Normal file
16
packages/widgets/src/app/index.ts
Normal 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"));
|
||||||
10
packages/widgets/src/app/serverData.ts
Normal file
10
packages/widgets/src/app/serverData.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -104,6 +104,10 @@ type inferServerDataForKind<TKind extends WidgetKind> =
|
|||||||
export type WidgetComponentProps<TKind extends WidgetKind> =
|
export type WidgetComponentProps<TKind extends WidgetKind> =
|
||||||
WidgetProps<TKind> & {
|
WidgetProps<TKind> & {
|
||||||
serverData?: inferServerDataForKind<TKind>;
|
serverData?: inferServerDataForKind<TKind>;
|
||||||
|
} & {
|
||||||
|
isEditMode: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> =
|
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> =
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Loader } from "next/dynamic";
|
|||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { Loader as UiLoader } from "@homarr/ui";
|
import { Loader as UiLoader } from "@homarr/ui";
|
||||||
|
|
||||||
|
import * as app from "./app";
|
||||||
import * as clock from "./clock";
|
import * as clock from "./clock";
|
||||||
import type { WidgetComponentProps } from "./definition";
|
import type { WidgetComponentProps } from "./definition";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
@@ -19,6 +20,7 @@ export { useServerDataFor } from "./server/provider";
|
|||||||
export const widgetImports = {
|
export const widgetImports = {
|
||||||
clock,
|
clock,
|
||||||
weather,
|
weather,
|
||||||
|
app,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ const optionsFactory = {
|
|||||||
defaultValue: input?.defaultValue ?? [],
|
defaultValue: input?.defaultValue ?? [],
|
||||||
withDescription: input?.withDescription ?? false,
|
withDescription: input?.withDescription ?? false,
|
||||||
}),
|
}),
|
||||||
|
app: (input?: Omit<CommonInput<string>, "defaultValue">) => ({
|
||||||
|
type: "app" as const,
|
||||||
|
defaultValue: "",
|
||||||
|
withDescription: input?.withDescription ?? false,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
type WidgetOptionFactory = typeof optionsFactory;
|
type WidgetOptionFactory = typeof optionsFactory;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
|
||||||
import { widgetImports } from "..";
|
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "..";
|
||||||
import { ClientServerDataInitalizer } from "./client";
|
import { ClientServerDataInitalizer } from "./client";
|
||||||
import { GlobalItemServerDataProvider } from "./provider";
|
import { GlobalItemServerDataProvider } from "./provider";
|
||||||
|
|
||||||
@@ -32,13 +32,19 @@ interface ItemDataLoaderProps {
|
|||||||
item: Board["sections"][number]["items"][number];
|
item: Board["sections"][number]["items"][number];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ItemDataLoader = /*async*/ ({ item }: ItemDataLoaderProps) => {
|
const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
|
||||||
const widgetImport = widgetImports[item.kind];
|
const widgetImport = widgetImports[item.kind];
|
||||||
if (!("serverDataLoader" in widgetImport)) {
|
if (!("serverDataLoader" in widgetImport)) {
|
||||||
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
|
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
|
||||||
}
|
}
|
||||||
//const loader = await widgetImport.serverDataLoader();
|
const loader = await widgetImport.serverDataLoader();
|
||||||
//const data = await loader.default(item as never);
|
const optionsWithDefault = reduceWidgetOptionsWithDefaultValues(
|
||||||
//return <ClientServerDataInitalizer id={item.id} serverData={data} />;
|
item.kind,
|
||||||
return null;
|
item.options,
|
||||||
|
);
|
||||||
|
const data = await loader.default({
|
||||||
|
...item,
|
||||||
|
options: optionsWithDefault as never,
|
||||||
|
});
|
||||||
|
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user