feat: rebuild board grid experience
This commit is contained in:
1 parent
5dc4dbeaef
commit
0110a2ef63
122 files changed
+9582
-4063
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user