diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index e9551af52..77c086b58 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,76 +1,24 @@ "use client"; -import { useCallback, useRef } from "react"; -import { Box, LoadingOverlay, Stack } from "@mantine/core"; +import dynamic from "next/dynamic"; +import { Box } from "@mantine/core"; -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; -import { BoardCategorySection } from "~/components/board/sections/category-section"; -import { BoardEmptySection } from "~/components/board/sections/empty-section"; +import { StaticBoardGrid } from "~/components/board/sections/static-grid"; import { BoardBackgroundVideo } from "~/components/layout/background"; -import { fullHeightWithoutHeaderAndFooter } from "~/constants"; -import { useIsBoardReady } from "./_ready-context"; -let boardName: string | null = null; - -export const updateBoardName = (name: string | null) => { - boardName = name; -}; - -type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"]; - -export const useUpdateBoard = () => { - const utils = clientApi.useUtils(); - - const updateBoard = useCallback( - (updaterWithoutUndefined: UpdateCallback) => { - if (!boardName) { - throw new Error("Board name is not set"); - } - utils.board.getBoardByName.setData({ name: boardName }, (previous) => - previous ? updaterWithoutUndefined(previous) : previous, - ); - }, - [utils], - ); - - return { - updateBoard, - }; -}; +const EditModeBoard = dynamic(() => import("./_edit-board"), { ssr: false }); export const ClientBoard = () => { const board = useRequiredBoard(); - const currentLayoutId = useCurrentLayout(); - const isReady = useIsBoardReady(); - - const fullWidthSortedSections = board.sections - .filter((section) => section.kind === "empty" || section.kind === "category") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); - - const ref = useRef(null); + const [isEditMode] = useEditMode(); return ( - - - {fullWidthSortedSections.map((section) => - section.kind === "empty" ? ( - // Unique keys per layout to always reinitialize the gridstack - - ) : ( - - ), - )} - + {isEditMode ? : } ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx index 50b0aff66..6b961ff06 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx @@ -1,7 +1,3 @@ "use client"; -import dynamic from "next/dynamic"; - -export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), { - ssr: false, -}); +export { ClientBoard as DynamicClientBoard } from "./_client"; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-board.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-board.tsx new file mode 100644 index 000000000..e78952ff9 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-board.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Box, LoadingOverlay, Stack } from "@mantine/core"; + +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; + +import { BoardCategorySection } from "~/components/board/sections/category-section"; +import { BoardEmptySection } from "~/components/board/sections/empty-section"; +import { fullHeightWithoutHeaderAndFooter } from "~/constants"; +import { useIsBoardReady } from "./_ready-context"; + +const EditModeBoard = () => { + const board = useRequiredBoard(); + const currentLayoutId = useCurrentLayout(); + const isReady = useIsBoardReady(); + + const fullWidthSortedSections = board.sections + .filter((section) => section.kind === "empty" || section.kind === "category") + .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + + return ( + + + + {fullWidthSortedSections.map((section) => + section.kind === "empty" ? ( + + ) : ( + + ), + )} + + + ); +}; + +export default EditModeBoard; diff --git a/apps/nextjs/src/components/board/sections/static-grid.tsx b/apps/nextjs/src/components/board/sections/static-grid.tsx new file mode 100644 index 000000000..842a0be99 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/static-grid.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { Badge, Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; + +import type { + Board, + CategorySection, + DynamicSectionItem, + EmptySection, + SectionItem, +} from "~/app/[locale]/boards/_types"; +import { BoardItemContent } from "../items/item-content"; +import { CategoryMenu } from "./category/category-menu"; +import classes from "./item.module.css"; + +interface StaticBoardGridProps { + board: Board; +} + +export const StaticBoardGrid = ({ board }: StaticBoardGridProps) => { + const currentLayoutId = useCurrentLayout(); + const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId); + const columnCount = currentLayout?.columnCount ?? 12; + + const fullWidthSortedSections = board.sections + .filter( + (section): section is CategorySection | EmptySection => section.kind === "empty" || section.kind === "category", + ) + .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + + return ( + + {fullWidthSortedSections.map((section) => + section.kind === "empty" ? ( + + ) : ( + + ), + )} + + ); +}; + +interface StaticSectionGridProps { + sectionId: string; + board: Board; + layoutId: string; + columnCount: number; + isDynamic?: boolean; + fixedRowCount?: number; +} + +const StaticSectionGrid = ({ + sectionId, + board, + layoutId, + columnCount, + isDynamic, + fixedRowCount, +}: StaticSectionGridProps) => { + const items = getSectionItems(board, sectionId, layoutId); + const innerSections = getDynamicSections(board, sectionId, layoutId); + const allItems = [...items, ...innerSections].sort((itemA, itemB) => { + if (itemA.yOffset === itemB.yOffset) return itemA.xOffset - itemB.xOffset; + return itemA.yOffset - itemB.yOffset; + }); + + if (allItems.length === 0) return null; + + const rowCount = fixedRowCount ?? Math.max(...allItems.map((item) => item.yOffset + item.height)); + + return ( +
+
+ {allItems.map((item) => ( + + ))} +
+
+ ); +}; + +interface StaticGridItemProps { + item: SectionItem | DynamicSectionItem; + board: Board; + layoutId: string; +} + +const StaticGridItem = ({ item, board, layoutId }: StaticGridItemProps) => ( +
+
+ {item.type === "item" ? ( + + ) : ( + + )} +
+
+); + +interface StaticEmptySectionProps { + section: EmptySection; + board: Board; + layoutId: string; + columnCount: number; +} + +const StaticEmptySection = ({ section, board, layoutId, columnCount }: StaticEmptySectionProps) => { + const items = getSectionItems(board, section.id, layoutId); + const innerSections = getDynamicSections(board, section.id, layoutId); + + if (items.length === 0 && innerSections.length === 0) return null; + + return ; +}; + +interface StaticCategorySectionProps { + section: CategorySection; + board: Board; + layoutId: string; + columnCount: number; +} + +const StaticCategorySection = ({ section, board, layoutId, columnCount }: StaticCategorySectionProps) => { + const { mutate } = clientApi.section.changeCollapsed.useMutation(); + const boardData = useRequiredBoard(); + const [opened, { toggle }] = useDisclosure(section.collapsed); + + const handleToggle = () => { + toggle(); + mutate({ sectionId: section.id, collapsed: !opened }); + }; + + return ( + + + + + + {opened ? : } + {section.name} + + + + + + + + + + ); +}; + +interface StaticDynamicSectionProps { + section: DynamicSectionItem; + board: Board; + layoutId: string; +} + +const StaticDynamicSection = ({ section, board, layoutId }: StaticDynamicSectionProps) => { + const boardData = useRequiredBoard(); + const options = section.options; + + return ( + + {options.title && ( + + {options.title} + + )} + + + ); +}; + +function getSectionItems(board: Board, sectionId: string, layoutId: string): SectionItem[] { + return board.items + .map(({ layouts, ...item }) => { + const layout = layouts.find((candidate) => candidate.layoutId === layoutId); + if (!layout) return null; + return { ...layout, ...item, type: "item" as const }; + }) + .filter((item): item is SectionItem => item !== null && item.sectionId === sectionId); +} + +function getDynamicSections(board: Board, sectionId: string, layoutId: string): DynamicSectionItem[] { + return board.sections + .filter((section) => section.kind === "dynamic") + .map(({ layouts, ...section }) => { + const layout = layouts.find((candidate) => candidate.layoutId === layoutId); + if (!layout) return null; + return { ...layout, ...section, type: "section" as const }; + }) + .filter((entry): entry is DynamicSectionItem => entry !== null && entry.parentSectionId === sectionId); +} diff --git a/apps/nextjs/src/styles/gridstack.scss b/apps/nextjs/src/styles/gridstack.scss index 615278f80..35e8b2278 100644 --- a/apps/nextjs/src/styles/gridstack.scss +++ b/apps/nextjs/src/styles/gridstack.scss @@ -112,3 +112,17 @@ .grid-stack-empty-wrapper { display: none !important; } + +.static-grid-container { + container-type: inline-size; +} + +.static-grid-item-content { + position: absolute; + inset: 10px; + + > .grid-stack-item-content { + width: 100%; + height: 100%; + } +}