diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx index 1077de04a..e8466f116 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx @@ -25,10 +25,10 @@ export const PreviewDimensionsModal = createModal(({ actions, innerP return (
- + - + diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 90a811fd5..c02f915cd 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -9,7 +9,9 @@ import { WidgetEditModal, widgetImports } from "@homarr/widgets"; import type { Item } from "~/app/[locale]/boards/_types"; import { useEditMode } from "~/app/[locale]/boards/(content)/_context"; +import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; +import { ItemMoveModal } from "./item-move-modal"; export const BoardItemMenu = ({ offset, @@ -24,12 +26,14 @@ export const BoardItemMenu = ({ const tItem = useScopedI18n("item"); const t = useI18n(); const { openModal } = useModalAction(WidgetEditModal); + const { openModal: openMoveModal } = useModalAction(ItemMoveModal); const { openConfirmModal } = useConfirmModal(); const [isEditMode] = useEditMode(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); + const { gridstack } = useSectionContext().refs; // Reset error boundary on next render if item has been edited useEffect(() => { @@ -95,7 +99,15 @@ export const BoardItemMenu = ({ } onClick={openEditModal}> {tItem("action.edit")} - }>{tItem("action.move")} + } + onClick={() => { + if (!gridstack.current) return; + openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); + }} + > + {tItem("action.moveResize")} + {" "} } onClick={() => duplicateItem({ itemId: item.id })}> {tItem("action.duplicate")} diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx new file mode 100644 index 000000000..64df4424b --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -0,0 +1,112 @@ +import { useCallback, useRef } from "react"; +import { Button, Grid, Group, NumberInput, Stack } from "@mantine/core"; + +import { useZodForm } from "@homarr/form"; +import type { GridStack } from "@homarr/gridstack"; +import { createModal } from "@homarr/modals"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { z } from "@homarr/validation"; + +import type { Item } from "~/app/[locale]/boards/_types"; +import { useItemActions } from "./item-actions"; + +interface InnerProps { + gridStack: GridStack; + item: Pick; + columnCount: number; +} + +export const ItemMoveModal = createModal(({ actions, innerProps }) => { + const tCommon = useScopedI18n("common"); + const t = useI18n(); + // Keep track of the maximum width based on the x offset + const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); + const { moveAndResizeItem } = useItemActions(); + const form = useZodForm( + z.object({ + xOffset: z + .number() + .min(0) + .max(innerProps.columnCount - 1), + yOffset: z.number().min(0), + width: z.number().min(1).max(maxWidthRef.current), + height: z.number().min(1), + }), + { + initialValues: { + xOffset: innerProps.item.xOffset, + yOffset: innerProps.item.yOffset, + width: innerProps.item.width, + height: innerProps.item.height, + }, + onValuesChange(values, previous) { + // Update the maximum width when the x offset changes + if (values.xOffset !== previous.xOffset) { + maxWidthRef.current = innerProps.columnCount - values.xOffset; + } + }, + }, + ); + + const handleSubmit = useCallback( + (values: Omit) => { + const gridItem = innerProps.gridStack + .getGridItems() + .find((item) => item.getAttribute("data-id") === innerProps.item.id); + if (!gridItem) return; + innerProps.gridStack.update(gridItem, { + h: values.height, + w: values.width, + x: values.xOffset, + y: values.yOffset, + }); + actions.closeModal(); + }, + [moveAndResizeItem], + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}).withOptions({ + defaultTitle(t) { + return t("item.moveResize.title"); + }, + size: "lg", +}); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 77b75f197..98a5d1662 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -689,7 +689,7 @@ export default { create: "New item", import: "Import item", edit: "Edit item", - move: "Move item", + moveResize: "Move / resize item", duplicate: "Duplicate item", remove: "Remove item", }, @@ -702,7 +702,8 @@ export default { title: "Choose item to add", addToBoard: "Add to board", }, - move: { + moveResize: { + title: "Move / resize item", field: { width: { label: "Width", @@ -710,6 +711,12 @@ export default { height: { label: "Height", }, + xOffset: { + label: "X offset", + }, + yOffset: { + label: "Y offset", + }, }, }, edit: {