"use client" import { Suspense, useEffect, useMemo, useRef, useState } from "react" import { Group, Panel, Separator, useDefaultLayout, useGroupRef } from "react-resizable-panels" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { classNames } from "@/lib/utilities" import { useIsMobile } from "@/lib/hooks/use-is-mobile" import { ErrorBoundary } from "./error-boundary" import { SidebarContent } from "./sidebar-content" import { CommandPalette } from "./command-palette" import { AddFeedDialog } from "./add-feed-dialog" import { SearchOverlay } from "./search-overlay" import { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog" import { MfaChallenge } from "./mfa-challenge" import { OfflineBanner } from "./offline-banner" import { useKeyboardNavigation } from "@/lib/hooks/use-keyboard-navigation" import { useOfflineAccessSync } from "@/lib/hooks/use-offline-access-sync" import { createSupabaseBrowserClient } from "@/lib/supabase/client" import { useSubscriptions } from "@/lib/queries/use-subscriptions" import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" import { useUserProfile } from "@/lib/queries/use-user-profile" const DENSITY_FONT_SIZE_MAP: Record = { compact: "0.875rem", default: "1rem", spacious: "1.125rem", } export function ReaderLayoutShell({ sidebarFooter, children, }: { sidebarFooter: React.ReactNode children: React.ReactNode }) { const [requiresMfaVerification, setRequiresMfaVerification] = useState(false) const [isMfaCheckComplete, setIsMfaCheckComplete] = useState(false) const isSidebarCollapsed = useUserInterfaceStore( (state) => state.isSidebarCollapsed ) const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) const setSidebarCollapsed = useUserInterfaceStore( (state) => state.setSidebarCollapsed ) const displayDensity = useUserInterfaceStore( (state) => state.displayDensity ) const isSearchOpen = useUserInterfaceStore((state) => state.isSearchOpen) const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) const focusFollowsInteraction = useUserInterfaceStore( (state) => state.focusFollowsInteraction ) const isMobile = useIsMobile() const showFeedFavicons = useUserInterfaceStore( (state) => state.showFeedFavicons ) const { data: subscriptionsData } = useSubscriptions() const { data: customFeedsData } = useCustomFeeds() const { data: userProfile } = useUserProfile() useOfflineAccessSync(userProfile?.tier as "free" | "pro" | "developer" | undefined) const sidebarMaxWidth = useMemo(() => { if (typeof window === "undefined") return "35%" if (!subscriptionsData || !customFeedsData) return "35%" const canvas = document.createElement("canvas") const maybeContext = canvas.getContext("2d") if (!maybeContext) return "35%" const canvasContext = maybeContext const computedStyle = window.getComputedStyle(document.body) const fontFamily = computedStyle.fontFamily const baseFontSizePixels = parseFloat(computedStyle.fontSize) const feedFontSizePixels = baseFontSizePixels * 0.85 function measureText(text: string, fontSize: number): number { canvasContext.font = `${fontSize}px ${fontFamily}` return canvasContext.measureText(text).width } let maximumItemWidth = 0 const faviconSpace = showFeedFavicons ? 24 : 0 const NAV_PADDING = 32 const FEED_PADDING = 40 const FOLDER_FEED_PADDING = 48 const FOLDER_PADDING = 46 const FOOTER_PADDING = 32 const BADGE_SPACE = 40 const NOTIFICATION_BADGE_SPACE = 28 const SCROLLBAR_WIDTH = 8 for (const label of ["all entries", "saved", "highlights", "shares"]) { maximumItemWidth = Math.max( maximumItemWidth, NAV_PADDING + measureText(label, baseFontSizePixels) + BADGE_SPACE ) } maximumItemWidth = Math.max( maximumItemWidth, NAV_PADDING + measureText("+ add feed", baseFontSizePixels) ) if (customFeedsData) { for (const customFeed of customFeedsData) { const iconSpace = customFeed.iconUrl ? 24 : 0 maximumItemWidth = Math.max( maximumItemWidth, FEED_PADDING + iconSpace + measureText(customFeed.name, feedFontSizePixels) ) } } const subscriptions = subscriptionsData?.subscriptions ?? [] const folders = subscriptionsData?.folders ?? [] for (const subscription of subscriptions) { const name = subscription.customTitle || subscription.feedTitle || "untitled" const indent = subscription.folderIdentifier ? FOLDER_FEED_PADDING : FEED_PADDING const extras = faviconSpace + (subscription.feedType === "podcast" ? 14 : 0) + (subscription.consecutiveFailures > 0 ? 24 : 0) + BADGE_SPACE maximumItemWidth = Math.max( maximumItemWidth, indent + extras + measureText(name, feedFontSizePixels) ) } for (const folder of folders) { const iconSpace = folder.iconUrl ? 24 : 0 maximumItemWidth = Math.max( maximumItemWidth, FOLDER_PADDING + iconSpace + measureText(folder.name, baseFontSizePixels) + BADGE_SPACE ) } const displayName = userProfile?.displayName ?? "account" for (const label of [displayName, "settings", "sign out"]) { maximumItemWidth = Math.max( maximumItemWidth, FOOTER_PADDING + measureText(label, baseFontSizePixels) ) } maximumItemWidth = Math.max( maximumItemWidth, FOOTER_PADDING + measureText("notifications", baseFontSizePixels) + NOTIFICATION_BADGE_SPACE ) maximumItemWidth += SCROLLBAR_WIDTH maximumItemWidth = Math.max(maximumItemWidth, 192) return `${Math.ceil(maximumItemWidth)}px` }, [subscriptionsData, customFeedsData, userProfile, showFeedFavicons]) const sidebarLayout = useDefaultLayout({ id: "asa-sidebar-layout", panelIds: ["sidebar", "main-content"], storage: typeof window !== "undefined" ? localStorage : { getItem: () => null, setItem: () => {} }, }) const sidebarGroupRef = useGroupRef() const sidebarMaxWidthRef = useRef(sidebarMaxWidth) const sidebarOnLayoutChangedRef = useRef(sidebarLayout.onLayoutChanged) useEffect(() => { sidebarMaxWidthRef.current = sidebarMaxWidth }, [sidebarMaxWidth]) useEffect(() => { sidebarOnLayoutChangedRef.current = sidebarLayout.onLayoutChanged }, [sidebarLayout.onLayoutChanged]) useEffect(() => { useUserInterfaceStore.getState().setResetSidebarLayout(() => { const group = sidebarGroupRef.current if (!group) return const groupElement = document.querySelector("[data-group]") as HTMLElement | null if (!groupElement) return const totalPixels = groupElement.offsetWidth if (totalPixels === 0) return const minPixels = 192 const maxWidthValue = sidebarMaxWidthRef.current let maxPixels: number if (maxWidthValue.endsWith("px")) { maxPixels = parseFloat(maxWidthValue) } else if (maxWidthValue.endsWith("%")) { maxPixels = totalPixels * parseFloat(maxWidthValue) / 100 } else { maxPixels = totalPixels * 0.35 } const midpointPixels = Math.round((minPixels + maxPixels) / 2) const midpointPercent = (midpointPixels / totalPixels) * 100 const appliedLayout = group.setLayout({ sidebar: midpointPercent, "main-content": 100 - midpointPercent, }) sidebarOnLayoutChangedRef.current?.(appliedLayout) }) return () => useUserInterfaceStore.getState().setResetSidebarLayout(null) }, [sidebarGroupRef]) useKeyboardNavigation() useEffect(() => { async function checkAssuranceLevel() { const supabaseClient = createSupabaseBrowserClient() const { data } = await supabaseClient.auth.mfa.getAuthenticatorAssuranceLevel() if ( data && data.currentLevel === "aal1" && data.nextLevel === "aal2" ) { setRequiresMfaVerification(true) } setIsMfaCheckComplete(true) } checkAssuranceLevel() }, []) useEffect(() => { if (window.innerWidth < 768) { setSidebarCollapsed(true) } }, [setSidebarCollapsed]) useEffect(() => { document.body.style.setProperty( "--base-font-size", DENSITY_FONT_SIZE_MAP[displayDensity] ?? "0.8125rem" ) }, [displayDensity]) useEffect(() => { if (!focusFollowsInteraction) return function handlePointerDown(event: PointerEvent) { const target = event.target as HTMLElement const zone = target.closest("[data-panel-zone]") if (!zone) return const panelZone = zone.getAttribute("data-panel-zone") if ( panelZone === "sidebar" || panelZone === "entryList" || panelZone === "detailPanel" ) { useUserInterfaceStore.getState().setFocusedPanel(panelZone) } } function handleScroll(event: Event) { const target = event.target as HTMLElement if (!target || !target.closest) return const zone = target.closest("[data-panel-zone]") if (!zone) return const panelZone = zone.getAttribute("data-panel-zone") if ( panelZone === "sidebar" || panelZone === "entryList" || panelZone === "detailPanel" ) { const currentPanel = useUserInterfaceStore.getState().focusedPanel if (currentPanel !== panelZone) { useUserInterfaceStore.getState().setFocusedPanel(panelZone) } } } document.addEventListener("pointerdown", handlePointerDown) document.addEventListener("scroll", handleScroll, true) return () => { document.removeEventListener("pointerdown", handlePointerDown) document.removeEventListener("scroll", handleScroll, true) } }, [focusFollowsInteraction]) if (!isMfaCheckComplete) { return (
loading ...
) } if (requiresMfaVerification) { return setRequiresMfaVerification(false)} /> } return (
{isMobile ? ( <>
{isSidebarCollapsed && (
)}
{children}
) : (
{!isSidebarCollapsed && ( <> )}
{isSidebarCollapsed && (
)}
{children}
)} {isSearchOpen && ( setSearchOpen(false)} /> )}
) }