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 { Container, Stack, Title } from "@homarr/ui";
|
||||
|
||||
import { api } from "~/trpc/server";
|
||||
import { AppEditForm } from "./_app-edit-form";
|
||||
|
||||
interface AppEditPageProps {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "~/trpc/server";
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardPage } from "../_creator";
|
||||
|
||||
export default createBoardPage<{ locale: string }>({
|
||||
|
||||
@@ -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 }>({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { createCaller, createTRPCContext } from "@homarr/api";
|
||||
import { auth } from "@homarr/auth";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a tRPC call from a React Server Component.
|
||||
*/
|
||||
const createContext = cache(async () => {
|
||||
const heads = new Headers(headers());
|
||||
heads.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
session: await auth(),
|
||||
headers: heads,
|
||||
});
|
||||
});
|
||||
|
||||
export const api = createCaller(createContext);
|
||||
Reference in New Issue
Block a user