feat: rebuild board grid experience

This commit is contained in:
ajnart committed 2026-08-13 19:52:09 +02:00
1 parent 5dc4dbeaef
commit 0110a2ef63
122 files changed
+9582 -4063

No files matched your search

+69 -23
View File
@@ -1,25 +1,66 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
import { updateBoardName } from "./updater";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getBoardByName"];
currentLayout: string;
initialViewportWidth: number;
} | null>(null);
const subscribeToViewport = (onStoreChange: () => void) => {
if (typeof window === "undefined") return () => undefined;
let animationFrame = 0;
const handleResize = () => {
if (animationFrame !== 0) return;
animationFrame = window.requestAnimationFrame(() => {
animationFrame = 0;
persistViewportWidth(getViewportWidth());
onStoreChange();
});
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
window.cancelAnimationFrame(animationFrame);
};
};
export const BoardProvider = ({
children,
initialBoard,
initialLayoutId,
initialViewportWidth,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
initialLayoutId: string;
initialViewportWidth: number;
}>) => {
const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard });
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
// The route already fetched this board, so keep it fresh long enough to
// avoid an immediate duplicate request while still surfacing remote edits
// when a user later returns to the board.
staleTime: 30_000,
},
);
const currentLayout = useSyncExternalStore(
subscribeToViewport,
() => getCurrentLayout(data),
() => initialLayoutId,
);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
@@ -35,10 +76,15 @@ export const BoardProvider = ({
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
persistViewportWidth(getViewportWidth());
}, []);
return (
<BoardContext.Provider
value={{
board: data,
currentLayout,
initialViewportWidth,
}}
>
{children}
@@ -63,34 +109,28 @@ export const useOptionalBoard = () => {
};
export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return sortedLayouts.find((layout) => layout.breakpoint <= window.innerWidth)?.id ?? sortedLayouts.at(0)!.id;
const viewportWidth = typeof window === "undefined" ? 0 : getViewportWidth();
return getLayoutIdForViewportWidth(board.layouts, viewportWidth);
};
export const useCurrentLayout = () => {
const board = useRequiredBoard();
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
const context = useContext(BoardContext);
const onResize = useCallback(() => {
setCurrentLayout(getCurrentLayout(board));
}, [board]);
if (!context) {
throw new Error("Board is required");
}
useEffect(() => {
if (typeof window === "undefined") return;
window.addEventListener("resize", onResize);
return context.currentLayout;
};
return () => {
window.removeEventListener("resize", onResize);
};
}, [onResize]);
export const useInitialViewportWidth = () => {
const context = useContext(BoardContext);
return currentLayout;
if (!context) {
throw new Error("Board is required");
}
return context.initialViewportWidth;
};
export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) =>
@@ -101,3 +141,9 @@ export const useLayouts = () => {
return getBoardLayouts(board);
};
const persistViewportWidth = (viewportWidth: number) => {
document.cookie = `${boardViewportWidthCookieName}=${Math.round(viewportWidth)}; Path=/; Max-Age=31536000; SameSite=Lax`;
};
const getViewportWidth = () => document.documentElement.clientWidth || window.innerWidth;
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { getLayoutIdForViewportWidth } from "./layout-selection";
const layouts = [
{ id: "mobile", breakpoint: 0 },
{ id: "tablet", breakpoint: 768 },
{ id: "desktop", breakpoint: 1280 },
];
describe("getLayoutIdForViewportWidth", () => {
test.each([
[390, "mobile"],
[768, "tablet"],
[1279, "tablet"],
[1920, "desktop"],
])("selects the responsive layout for %ipx", (width, expected) => {
expect(getLayoutIdForViewportWidth(layouts, width)).toBe(expected);
});
test("falls back to the smallest layout for invalid widths", () => {
expect(getLayoutIdForViewportWidth(layouts, Number.NaN)).toBe("mobile");
});
test("rejects boards without layouts", () => {
expect(() => getLayoutIdForViewportWidth([], 1280)).toThrow("Expected the board to contain a layout");
});
});
+24
View File
@@ -0,0 +1,24 @@
export const boardViewportWidthCookieName = "homarr-viewport-width";
interface ResponsiveLayout {
id: string;
breakpoint: number;
}
export const getLayoutIdForViewportWidth = (
layouts: readonly ResponsiveLayout[],
viewportWidth: number,
): string => {
if (layouts.length === 0) {
throw new Error("Expected the board to contain a layout");
}
const normalizedWidth = Number.isFinite(viewportWidth) ? Math.max(0, viewportWidth) : 0;
const sortedLayouts = layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
const selected = sortedLayouts.find((layout) => layout.breakpoint <= normalizedWidth) ?? sortedLayouts.at(-1);
if (!selected) {
throw new Error("Expected the board to contain a layout");
}
return selected.id;
};