extract auth to lib

This commit is contained in:
2026-02-22 20:37:30 +01:00
parent db6813cab1
commit 9f86fe80d7
24 changed files with 2442 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode
} from 'react';
import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine';
const SIDEBAR_WIDTH_KEY = 'authSidebarWidth';
const SIDEBAR_COLLAPSED_KEY = 'authSidebarCollapsed';
const SIDEBAR_DEFAULT_WIDTH = 280;
const SIDEBAR_MIN_WIDTH = 220;
const SIDEBAR_MAX_WIDTH = 420;
const SIDEBAR_COLLAPSED_WIDTH = 56;
export type LeftMenuRenderState = {
collapsed: boolean;
mobileOpen: boolean;
isDesktop: boolean;
closeMenu: () => void;
};
export type LeftMenuContent = {
ariaLabel?: string;
render: (state: LeftMenuRenderState) => ReactNode;
};
export type LeftMenuStyle = CSSProperties & {
'--auth-sidebar-width': string;
};
type LeftMenuContextValue = {
collapsed: boolean;
mobileOpen: boolean;
content: LeftMenuContent;
desktopMenuStyle: LeftMenuStyle;
openMenu: (content?: LeftMenuContent) => void;
closeMenu: () => void;
toggleMenu: (content?: LeftMenuContent) => void;
expandMenu: () => void;
collapseMenu: () => void;
toggleCollapsed: () => void;
setMenuContent: (content: LeftMenuContent | null) => void;
startResize: ReturnType<typeof useSidePanelMachine>['startResize'];
};
type LeftMenuProviderProps = {
children: ReactNode;
defaultContent: LeftMenuContent;
closeOnPathname?: string;
};
const LeftMenuContext = createContext<LeftMenuContextValue | undefined>(undefined);
function readStoredCollapsed(): boolean {
if (!globalThis.window) {
return false;
}
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
}
export function LeftMenuProvider({
children,
defaultContent,
closeOnPathname
}: Readonly<LeftMenuProviderProps>) {
const [collapsed, setCollapsed] = useState<boolean>(() => readStoredCollapsed());
const [mobileOpen, setMobileOpen] = useState(false);
const [content, setContent] = useState<LeftMenuContent>(defaultContent);
const defaultContentRef = useRef(defaultContent);
useEffect(() => {
const previousDefaultContent = defaultContentRef.current;
defaultContentRef.current = defaultContent;
setContent((currentContent) => {
if (currentContent === previousDefaultContent) {
return defaultContent;
}
return currentContent;
});
}, [defaultContent]);
useEffect(() => {
if (!globalThis.window) {
return;
}
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? '1' : '0');
}, [collapsed]);
const expandMenu = useCallback(() => {
setCollapsed(false);
}, []);
const collapseMenu = useCallback(() => {
setCollapsed(true);
}, []);
const toggleCollapsed = useCallback(() => {
setCollapsed((previous) => !previous);
}, []);
const closeMobile = useCallback(() => {
setMobileOpen(false);
}, []);
const setMenuContent = useCallback((nextContent: LeftMenuContent | null) => {
setContent(nextContent ?? defaultContentRef.current);
}, []);
const closeMenu = useCallback(() => {
if (isDesktopViewport()) {
collapseMenu();
return;
}
closeMobile();
}, [collapseMenu, closeMobile]);
const openMenu = useCallback((nextContent?: LeftMenuContent) => {
if (nextContent) {
setContent(nextContent);
}
if (isDesktopViewport()) {
expandMenu();
return;
}
setMobileOpen(true);
}, [expandMenu]);
const toggleMenu = useCallback((nextContent?: LeftMenuContent) => {
if (nextContent) {
setContent(nextContent);
}
if (isDesktopViewport()) {
toggleCollapsed();
return;
}
setMobileOpen((previous) => !previous);
}, [toggleCollapsed]);
const handleCloseOnPathname = useCallback(() => {
setMobileOpen(false);
setContent(defaultContentRef.current);
}, []);
const { width, startResize } = useSidePanelMachine({
storageKey: SIDEBAR_WIDTH_KEY,
defaultWidth: SIDEBAR_DEFAULT_WIDTH,
minWidth: SIDEBAR_MIN_WIDTH,
maxWidth: SIDEBAR_MAX_WIDTH,
resizeAxis: 'from-left',
resizingBodyClass: 'auth-sidebar-resizing',
isOpen: mobileOpen,
canResize: !collapsed,
shouldPersistWidth: !collapsed,
closeOnPathname,
onCloseOnPathname: handleCloseOnPathname,
onEscape: closeMobile
});
const desktopMenuStyle = useMemo<LeftMenuStyle>(() => ({
'--auth-sidebar-width': `${collapsed ? SIDEBAR_COLLAPSED_WIDTH : width}px`
}), [collapsed, width]);
const value = useMemo<LeftMenuContextValue>(() => ({
collapsed,
mobileOpen,
content,
desktopMenuStyle,
openMenu,
closeMenu,
toggleMenu,
expandMenu,
collapseMenu,
toggleCollapsed,
setMenuContent,
startResize
}), [
collapsed,
mobileOpen,
content,
desktopMenuStyle,
openMenu,
closeMenu,
toggleMenu,
expandMenu,
collapseMenu,
toggleCollapsed,
setMenuContent,
startResize
]);
return (
<LeftMenuContext.Provider value={value}>
{children}
</LeftMenuContext.Provider>
);
}
export function useLeftMenu() {
const ctx = useContext(LeftMenuContext);
if (!ctx) {
throw new Error('useLeftMenu must be used within LeftMenuProvider');
}
return ctx;
}