diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/reader/_components/reader-layout-shell.tsx | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps/web/app/reader/_components/reader-layout-shell.tsx')
| -rw-r--r-- | apps/web/app/reader/_components/reader-layout-shell.tsx | 204 |
1 files changed, 204 insertions, 0 deletions
diff --git a/apps/web/app/reader/_components/reader-layout-shell.tsx b/apps/web/app/reader/_components/reader-layout-shell.tsx new file mode 100644 index 0000000..7e0e80b --- /dev/null +++ b/apps/web/app/reader/_components/reader-layout-shell.tsx @@ -0,0 +1,204 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { classNames } from "@/lib/utilities" +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 { MfaChallenge } from "./mfa-challenge" +import { useKeyboardNavigation } from "@/lib/hooks/use-keyboard-navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +const DENSITY_FONT_SIZE_MAP: Record<string, string> = { + 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 setFocusedPanel = useUserInterfaceStore((state) => state.setFocusedPanel) + const focusFollowsInteraction = useUserInterfaceStore( + (state) => state.focusFollowsInteraction + ) + + 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 ( + <div className="flex h-screen items-center justify-center bg-background-primary"> + <span className="text-text-dim">loading ...</span> + </div> + ) + } + + if (requiresMfaVerification) { + return <MfaChallenge onVerified={() => setRequiresMfaVerification(false)} /> + } + + return ( + <div className="flex h-screen"> + <div + className={classNames( + "fixed inset-0 z-30 bg-black/50 transition-opacity md:hidden", + !isSidebarCollapsed + ? "pointer-events-auto opacity-100" + : "pointer-events-none opacity-0" + )} + onClick={toggleSidebar} + /> + + <aside + data-panel-zone="sidebar" + className={classNames( + "fixed z-40 flex h-full shrink-0 flex-col border-r border-border bg-background-secondary transition-transform duration-200 md:relative md:z-10 md:transition-[width]", + "w-64", + isSidebarCollapsed + ? "-translate-x-full md:w-0 md:translate-x-0 md:overflow-hidden" + : "translate-x-0", + focusedPanel === "sidebar" && !isSidebarCollapsed + ? "border-r-text-dim" + : "" + )} + > + <div className="flex items-center justify-between p-4"> + <h2 className="text-text-primary">asa.news</h2> + <button + type="button" + onClick={toggleSidebar} + className="px-1 py-0.5 text-lg leading-none text-text-dim transition-colors hover:text-text-secondary" + > + × + </button> + </div> + <ErrorBoundary> + <Suspense> + <SidebarContent /> + </Suspense> + </ErrorBoundary> + {sidebarFooter} + </aside> + + <main className="flex-1 overflow-hidden"> + <div className="flex h-full flex-col"> + {isSidebarCollapsed && ( + <div className="flex items-center border-b border-border px-2 py-1"> + <button + type="button" + onClick={toggleSidebar} + className="px-2 py-1 text-lg leading-none text-text-secondary transition-colors hover:text-text-primary" + > + ☰ + </button> + </div> + )} + <div className="flex-1 overflow-hidden">{children}</div> + </div> + </main> + <CommandPalette /> + <AddFeedDialog /> + {isSearchOpen && ( + <SearchOverlay onClose={() => setSearchOpen(false)} /> + )} + </div> + ) +} |