summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/_components/reader-layout-shell.tsx
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/reader/_components/reader-layout-shell.tsx
downloadasa.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.tsx204
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"
+ >
+ &times;
+ </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"
+ >
+ &#9776;
+ </button>
+ </div>
+ )}
+ <div className="flex-1 overflow-hidden">{children}</div>
+ </div>
+ </main>
+ <CommandPalette />
+ <AddFeedDialog />
+ {isSearchOpen && (
+ <SearchOverlay onClose={() => setSearchOpen(false)} />
+ )}
+ </div>
+ )
+}