File size: 5,348 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
"use client";

import { Sidebar } from "./Sidebar";
import { Header } from "./Header";
import { useEffect, useState, Suspense } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth/useAuth";
import { hasLiveSession } from "@/lib/api/mode";
import { isDemoPreviewSession } from "@/lib/auth/auth-client";
import { getPublicDemoMode } from "@/lib/env";
import { cn } from "@/lib/utils";

const isLiveFrontendMode = getPublicDemoMode() === "live";

type AppLayoutProps = {
  children: React.ReactNode;
};

function LoadingFallback() {
  return (
    <div className="flex flex-1 items-center justify-center">
      <div className="flex items-center gap-3">
        <span className="size-5 animate-spin rounded-full border-2 border-white/15 border-t-white/72" />
        <span className="text-[14px] text-[var(--text-secondary)]">Preparing your study space...</span>
      </div>
    </div>
  );
}

export function AppLayout({ children }: AppLayoutProps) {
  const router = useRouter();
  const pathname = usePathname();
  const { isLoading: isAuthLoading } = useAuth();
  const [mobileOpen, setMobileOpen] = useState(false);
  const ownsWorkspaceChrome = pathname === "/home" || pathname === "/study-chat";
  // startsWith, not exact match β€” a real bug found live: /learn-anything/[id]
  // (the roadmap view) kept AppLayout's own header, stacking it on top of
  // that section's own SectionTabs nav.
  const hidesAppHeader =
    pathname === "/home" ||
    pathname === "/tuition" ||
    pathname === "/learn-anything" ||
    pathname?.startsWith("/learn-anything/") ||
    pathname === "/study-chat";
  const isLocalTuitionBetaRoute = pathname === "/tuition" || pathname?.startsWith("/tuition/");

  // Auth guard β€” redirect unauthenticated users to login in live mode.
  // Runs on every student route wrapped by AppLayout.
  const unauthenticated =
    !isLocalTuitionBetaRoute && isLiveFrontendMode && !hasLiveSession() && !isDemoPreviewSession();
  useEffect(() => {
    if (!isLocalTuitionBetaRoute && !isAuthLoading && unauthenticated) {
      const next = encodeURIComponent(pathname ?? "/study-chat");
      router.replace(`/login?next=${next}`);
    }
  }, [isAuthLoading, isLocalTuitionBetaRoute, unauthenticated, pathname, router]);

  // Do NOT mount the page (and its authed API calls) while auth is still loading
  // or while we are redirecting an unauthenticated visitor β€” otherwise the page
  // fires a burst of 401 requests before the redirect lands.
  const blockChildren = !isLocalTuitionBetaRoute && (isAuthLoading || unauthenticated);

  // Close mobile drawer before paint on route change (avoids overlay flash blocking taps).
  useEffect(() => {
    const timer = window.setTimeout(() => setMobileOpen(false), 0);
    return () => window.clearTimeout(timer);
  }, [pathname]);

  const closeMobileNav = () => setMobileOpen(false);

  useEffect(() => {
    if (!mobileOpen) return;
    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") setMobileOpen(false);
    }
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [mobileOpen]);

  return (
    <div className="docdoe-workspace relative flex h-screen max-h-screen min-h-screen overflow-hidden bg-[#030303]">
      <div className="pointer-events-none fixed inset-0 z-0 select-none overflow-hidden">
        <div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.035)_0%,rgba(255,255,255,0.008)_38%,rgba(0,0,0,0)_100%)]" />
        <div className="absolute inset-x-0 top-[-22rem] mx-auto h-[34rem] max-w-[70rem] rounded-full bg-white/[0.035] blur-3xl" />
        <div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.014)_1px,transparent_0)] bg-[size:30px_30px] opacity-40" />
      </div>

      {mobileOpen ? (
        <div className="fixed inset-0 z-modal-backdrop lg:hidden">
          <button
            type="button"
            aria-label="Close navigation"
            className="absolute inset-0 bg-black/70"
            onClick={closeMobileNav}
          />
          <div className="relative z-modal h-full w-[min(100%,280px)] max-w-[85vw] shadow-lg pointer-events-auto">
            <Sidebar onNavigate={closeMobileNav} />
          </div>
        </div>
      ) : null}

      <div className="relative z-10 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
        {hidesAppHeader ? null : <Header onMenuClick={() => setMobileOpen(true)} />}
        <main
          className={cn(
            "docdoe-workspace relative z-10 min-h-0 flex-1 overflow-x-hidden",
            ownsWorkspaceChrome ? "overflow-hidden pb-0" : "overflow-y-auto pb-10",
          )}
        >
          <Suspense fallback={<LoadingFallback />}>
            {blockChildren ? (
              <LoadingFallback />
            ) : ownsWorkspaceChrome ? (
              // These pages own their full-height scroll/layout β€” no transform wrapper.
              children
            ) : (
              // Every standard app screen breathes in on navigation (keyed per route).
              <div key={pathname} className="dd-route-enter">
                {children}
              </div>
            )}
          </Suspense>
        </main>
      </div>
    </div>
  );
}