Files
homarr/apps/nextjs/src/components/board/sections/static-grid.tsx
T
Thomas Camlong 121cff20d7 feat(board): add SSR-compatible static grid for view mode
Split board rendering into two modes: a static CSS Grid for view mode
(SSR-friendly, no JS required for layout) and a lazy-loaded GridStack
for edit mode. This eliminates widget "pop-in" on page load by rendering
the full grid layout server-side.
2026-05-22 11:37:25 +02:00

265 lines
7.7 KiB
TypeScript

"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 (
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<StaticEmptySection
key={section.id}
section={section}
board={board}
layoutId={currentLayoutId}
columnCount={columnCount}
/>
) : (
<StaticCategorySection
key={section.id}
section={section}
board={board}
layoutId={currentLayoutId}
columnCount={columnCount}
/>
),
)}
</Stack>
);
};
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 (
<div className="static-grid-container" style={{ height: isDynamic ? "100%" : undefined }}>
<div
className="static-grid"
style={{
display: "grid",
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
gridTemplateRows: isDynamic
? `repeat(${rowCount}, 1fr)`
: `repeat(${rowCount}, calc(100cqi / ${columnCount}))`,
height: isDynamic ? "100%" : undefined,
}}
>
{allItems.map((item) => (
<StaticGridItem key={item.id} item={item} board={board} layoutId={layoutId} />
))}
</div>
</div>
);
};
interface StaticGridItemProps {
item: SectionItem | DynamicSectionItem;
board: Board;
layoutId: string;
}
const StaticGridItem = ({ item, board, layoutId }: StaticGridItemProps) => (
<div
style={{
gridColumn: `${item.xOffset + 1} / span ${item.width}`,
gridRow: `${item.yOffset + 1} / span ${item.height}`,
position: "relative",
}}
>
<div className="static-grid-item-content">
{item.type === "item" ? (
<BoardItemContent item={item} />
) : (
<StaticDynamicSection section={item} board={board} layoutId={layoutId} />
)}
</div>
</div>
);
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 <StaticSectionGrid sectionId={section.id} board={board} layoutId={layoutId} columnCount={columnCount} />;
};
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 (
<Card
style={{ "--opacity": boardData.opacity / 100 }}
radius={boardData.itemRadius}
withBorder
p={0}
className={classes.itemCard}
>
<Stack>
<Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={handleToggle}>
<Group wrap="nowrap">
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
<Title order={3}>{section.name}</Title>
</Group>
</UnstyledButton>
<CategoryMenu category={section} />
</Group>
<Collapse in={opened} p="sm" pt={0}>
<StaticSectionGrid sectionId={section.id} board={board} layoutId={layoutId} columnCount={columnCount} />
</Collapse>
</Stack>
</Card>
);
};
interface StaticDynamicSectionProps {
section: DynamicSectionItem;
board: Board;
layoutId: string;
}
const StaticDynamicSection = ({ section, board, layoutId }: StaticDynamicSectionProps) => {
const boardData = useRequiredBoard();
const options = section.options;
return (
<Card
className={classes.itemCard}
w="100%"
h="100%"
withBorder
styles={{
root: {
overflow: "visible",
"--opacity": boardData.opacity / 100,
"--border-color": options.borderColor || undefined,
},
}}
radius={boardData.itemRadius}
p={0}
>
{options.title && (
<Badge
pos="absolute"
top={-15}
left={10}
size="md"
radius={boardData.itemRadius}
color="var(--background-color)"
c="var(--mantine-color-text)"
bd="1px solid var(--border-color)"
>
{options.title}
</Badge>
)}
<StaticSectionGrid
sectionId={section.id}
board={board}
layoutId={layoutId}
columnCount={section.width}
isDynamic
fixedRowCount={section.height}
/>
</Card>
);
};
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);
}