Files
homarr/apps/nextjs/src/components/board/sections/section-collapse.tsx
T

160 lines
5.6 KiB
TypeScript

"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { readSectionCollapsedFromStorage, writeSectionCollapsedToStorage } from "./section-collapse-storage";
interface SectionCollapseContextValue {
collapsedSectionIds: ReadonlySet<string>;
visuallyCollapsedSectionIds: ReadonlySet<string>;
setCollapsed: (sectionId: string, collapsed: boolean) => void;
expandForEditing: (sectionIds: ReadonlySet<string>) => void;
}
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
Object.fromEntries(
board.sections.filter((section) => section.kind !== "empty").map((section) => [section.id, section.collapsed]),
),
);
const collapsedByIdRef = useRef(collapsedById);
const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
collapsedByIdRef.current = collapsedById;
useEffect(() => {
if (!isEditMode) setTemporarilyExpandedIds((current) => (current.size === 0 ? current : new Set()));
}, [isEditMode]);
useEffect(() => {
setCollapsedById((previous) => {
const next = { ...previous };
for (const section of board.sections) {
if (section.kind === "empty" || section.id in next) continue;
next[section.id] = section.collapsed;
}
return next;
});
}, [board.sections]);
useEffect(() => {
if (status !== "unauthenticated") return;
setCollapsedById((previous) => {
const next = { ...previous };
for (const sectionId of Object.keys(next)) {
next[sectionId] = readSectionCollapsedFromStorage(window.localStorage, sectionId, next[sectionId] ?? false);
}
return next;
});
}, [status]);
const setCollapsed = useCallback(
(sectionId: string, collapsed: boolean) => {
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed }));
setTemporarilyExpandedIds((current) => {
if (!current.has(sectionId)) return current;
const next = new Set(current);
next.delete(sectionId);
return next;
});
if (status === "authenticated") {
mutate(
{ sectionId, collapsed },
{
onError: () => {
if (collapsedByIdRef.current[sectionId] !== collapsed) return;
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: previousCollapsed };
setCollapsedById((previous) => ({ ...previous, [sectionId]: previousCollapsed }));
},
},
);
} else if (status === "unauthenticated") {
writeSectionCollapsedToStorage(window.localStorage, sectionId, collapsed);
}
},
[mutate, status],
);
const expandForEditing = useCallback(
(sectionIds: ReadonlySet<string>) => {
if (!isEditMode || sectionIds.size === 0) return;
setTemporarilyExpandedIds((current) => {
const next = new Set(current);
for (const sectionId of sectionIds) next.add(sectionId);
return next.size === current.size ? current : next;
});
},
[isEditMode],
);
const value = useMemo<SectionCollapseContextValue>(() => {
const collapsedSectionIds = new Set(
Object.entries(collapsedById)
.filter(([, collapsed]) => collapsed)
.map(([sectionId]) => sectionId),
);
return {
collapsedSectionIds,
visuallyCollapsedSectionIds: new Set(
[...collapsedSectionIds].filter((sectionId) => !isEditMode || !temporarilyExpandedIds.has(sectionId)),
),
setCollapsed,
expandForEditing,
};
}, [collapsedById, expandForEditing, isEditMode, setCollapsed, temporarilyExpandedIds]);
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
};
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
const context = useContext(SectionCollapseContext);
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
const isVisuallyCollapsed = collapsible && context.visuallyCollapsedSectionIds.has(sectionId);
return {
isCollapsed,
isVisuallyCollapsed,
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
toggle: () => context.setCollapsed(sectionId, !isVisuallyCollapsed),
};
};
export const useCollapsedSectionIds = () => {
const context = useContext(SectionCollapseContext);
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
return context.visuallyCollapsedSectionIds;
};
export const useExpandSectionsForEditing = () => {
const context = useContext(SectionCollapseContext);
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
return context.expandForEditing;
};