diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/page.tsx index d41f17ac6..96ae57035 100644 --- a/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/page.tsx @@ -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 { diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/page.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/page.tsx index af682e07f..e07263db6 100644 --- a/apps/nextjs/src/app/[locale]/(main)/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/apps/page.tsx @@ -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() { diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/page.tsx index 3a6063fcc..61ec435cd 100644 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/page.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/page.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/page.tsx index 91a7454ec..04aabf1c0 100644 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/integrations/page.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts index af962bc05..1512c171a 100644 --- a/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts +++ b/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts @@ -1,4 +1,5 @@ -import { api } from "~/trpc/server"; +import { api } from "@homarr/api/server"; + import { createBoardPage } from "../_creator"; export default createBoardPage<{ locale: string }>({ diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx index 56cdebce8..db62646d0 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx @@ -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 }>({ diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index 749edfcab..0112343e1 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index 4d1c68402..dfebada4b 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx index c318a6ad1..301e9da1e 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/page.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/users/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/page.tsx index 6de37296c..6d5f4f3d5 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/page.tsx @@ -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() { diff --git a/apps/nextjs/src/app/[locale]/modals.tsx b/apps/nextjs/src/app/[locale]/modals.tsx index 8f64c3b41..e8b77d1f1 100644 --- a/apps/nextjs/src/app/[locale]/modals.tsx +++ b/apps/nextjs/src/app/[locale]/modals.tsx @@ -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, }); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index f15666bde..e91672e86 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -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({ + width: 128, + height: 128, + }); const [state, setState] = useState<{ options: Record; 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 ( <> - integrationData.find((x) => x.id === id)!, - )} - /> + = 96 ? undefined : 4} + > + integrationData.find((x) => x.id === id)!, + )} + width={dimensions.width} + height={dimensions.height} + isEditMode={editMode} + /> + { - 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} > + + + {editMode ? ( + + ) : ( + + )} + + + + + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx new file mode 100644 index 000000000..b5223e7b0 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx @@ -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 = ({ + actions, + innerProps, +}) => { + const t = useI18n(); + const form = useForm({ + initialValues: innerProps.dimensions, + }); + + const handleSubmit = (values: Dimensions) => { + innerProps.setDimensions(values); + actions.closeModal(); + }; + + return ( +
+ + + + + + + + + + + + +
+ ); +}; + +export interface Dimensions { + width: number; + height: number; +} diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index 1f1a5aa6d..293a73266 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -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(); return ( <> @@ -56,6 +59,7 @@ export const SectionContent = ({ items, refs }: Props) => { ref={refs.items.current[item.id] as RefObject} > } 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"} > - + ); @@ -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} /> ); diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index cd186851c..8949ff58d 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -46,7 +46,7 @@ export const useGridstack = ({ // reference of the gridstack object for modifications after initialization const gridRef = useRef(); - useCssVariableConfiguration({ section, mainRef, gridRef }); + useCssVariableConfiguration({ mainRef, gridRef }); const board = useRequiredBoard(); @@ -146,7 +146,6 @@ export const useGridstack = ({ }; interface UseCssVariableConfiguration { - section: Section; mainRef?: RefObject; 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(() => { diff --git a/packages/api/package.json b/packages/api/package.json index 32555da93..c14136508 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 9d631be1e..24198bf04 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -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 }) => { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index af5c32dce..cf30edb93 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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 = ( @@ -387,21 +386,8 @@ const getFullBoardWithWhere = async (db: Database, where: SQL) => { const forKind = (kind: T) => z.object({ kind: z.literal(kind), - options: z.custom["options"]>>(), - }) as UnionizeSpecificItemSchemaForWidgetKind; - -type SpecificItemSchemaForWidgetKind = z.ZodObject<{ - kind: z.ZodLiteral; - options: z.ZodType< - Partial["options"]>, - z.ZodTypeDef, - Partial["options"]> - >; -}>; - -type UnionizeSpecificItemSchemaForWidgetKind = T extends WidgetKind - ? SpecificItemSchemaForWidgetKind - : never; + options: z.record(z.unknown()), + }); const outputItemSchema = zodUnionFromArray( widgetKinds.map((kind) => forKind(kind)), diff --git a/apps/nextjs/src/trpc/server.ts b/packages/api/src/server.ts similarity index 100% rename from apps/nextjs/src/trpc/server.ts rename to packages/api/src/server.ts diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 59d842383..9a0ce8b0c 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -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]; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 697fc18bf..fd88ada24 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -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: { diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index ec9c86958..4da6a74bd 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -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; export const getInputForType = ( diff --git a/packages/widgets/src/_inputs/widget-app-input.tsx b/packages/widgets/src/_inputs/widget-app-input.tsx new file mode 100644 index 000000000..e26c2215d --- /dev/null +++ b/packages/widgets/src/_inputs/widget-app-input.tsx @@ -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 ( +