DocDoeAI / src /components /student-os /StudentOsShell.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
f3056f5
Raw History Blame Contribute Delete
40.3 kB
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
Bell,
Bookmark,
BookOpen,
Check,
ChevronLeft,
ChevronRight,
Command,
LogOut,
Menu,
RefreshCw,
Search,
Settings,
StickyNote,
UserRound,
WifiOff,
X,
} from "lucide-react";
import { Suspense, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useWorkspaceOverlay } from "./useWorkspaceOverlay";
import { useTheme } from "next-themes";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
} from "framer-motion";
import { hasLiveSession } from "@/lib/api/mode";
import { isAuthenticationRequired } from "@/lib/auth/session-requirement";
import { useAuth } from "@/lib/auth/useAuth";
import { cn } from "@/lib/utils";
import { getBillingMe } from "@/lib/api/users";
import { isActivePaidPlanKey } from "@/lib/billing/activePlans";
import type { BillingMe } from "@/lib/api/types";
import { StudentOsAssistant } from "./StudentOsAssistant";
import { StudentOsPageSkeleton } from "./StudentOsStates";
import {
StudentWorkspaceProvider,
useStudentWorkspace,
} from "./StudentWorkspaceProvider";
import {
studentLibraryDestination,
studentLibraryNavigation,
studentPrimaryNavigation,
studentSearchNavigation,
} from "./studentNavigation";
import { popoverVariants, routeVariants } from "./motion";
const springSoft = { type: "spring" as const, stiffness: 420, damping: 36, mass: 0.85 };
const springSnappy = { type: "spring" as const, stiffness: 520, damping: 38, mass: 0.7 };
const easeOutExpo = [0.16, 1, 0.3, 1] as const;
const FOCUSABLE_SELECTOR =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
function useOverlayFocus(
containerRef: React.RefObject<HTMLElement | null>,
open: boolean,
onClose: () => void,
) {
const close = useEffectEvent(onClose);
useEffect(() => {
if (!open) return;
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const container = containerRef.current;
const focusables = () =>
Array.from(container?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? []).filter(
(element) => element.offsetParent !== null && element.tabIndex >= 0,
);
const first = focusables()[0];
(first ?? container)?.focus({ preventScroll: true });
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
const items = focusables();
if (!items.length) return;
const current = document.activeElement;
const index = items.indexOf(current as HTMLElement);
const next = event.shiftKey
? items[index <= 0 ? items.length - 1 : index - 1]
: items[index === items.length - 1 ? 0 : index + 1];
event.preventDefault();
next.focus();
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previous?.focus({ preventScroll: true });
};
}, [containerRef, open]);
}
// Bottom-right DocDoe guide (OURDOCDOE). Toggle only if product wants it off.
// Present on every student route; each student can still disable it in Settings.
const ASSISTANT_WIDGET_ENABLED = true;
function isActive(pathname: string, href: string) {
return pathname === href || pathname.startsWith(`${href}/`);
}
function StudentOsLogo({ compact = false }: { compact?: boolean }) {
return (
<Link
href="/home"
className="flex items-center gap-3 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-dd-ink/40"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-dd-ink">
<Image
src="/docdoe-logo-mark.png"
alt={compact ? "DocDoe" : ""}
width={23}
height={23}
priority
/>
</span>
{compact ? null : (
<span>
<span className="block text-[15px] font-semibold tracking-[-0.02em] text-dd-ink">
DocDoe
</span>
<span className="block text-[11px] text-dd-muted">
Your study workspace
</span>
</span>
)}
</Link>
);
}
function NavigationList({
pathname,
onNavigate,
libraryOnly = false,
compact = false,
}: {
pathname: string;
onNavigate?: () => void;
libraryOnly?: boolean;
compact?: boolean;
}) {
function renderLink(item: (typeof studentSearchNavigation)[number]) {
const active = isActive(pathname, item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
title={compact ? item.label : undefined}
aria-current={active ? "page" : undefined}
className={cn(
"group flex min-h-11 items-center gap-3 rounded-[9px] px-3 py-2 text-[13px] font-medium transition-[background-color,color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-dd-ink/40",
compact && "justify-center px-0",
active
? "bg-dd-ink text-white"
: "text-dd-muted hover:bg-dd-sunk hover:text-dd-ink",
)}
>
<Icon
className={cn(
"size-[17px] shrink-0",
active ? "text-white" : "text-dd-muted group-hover:text-dd-ink",
)}
strokeWidth={active ? 2.1 : 1.8}
/>
{compact ? <span className="sr-only">{item.label}</span> : <span className="truncate">{item.label}</span>}
</Link>
);
}
return (
<nav className="space-y-5" aria-label={libraryOnly ? "Library" : "Student workspace"}>
{!libraryOnly ? (
<div className="space-y-0.5">
{studentPrimaryNavigation.map((item) => renderLink(item))}
</div>
) : null}
<div className={cn(!libraryOnly && "pt-3")}>
{compact ? null : (
<p className="mb-2 px-3 text-[11px] font-medium text-dd-muted">
{libraryOnly ? "Library" : "My space"}
</p>
)}
<div id="student-library-navigation" className="space-y-0.5">
{studentLibraryNavigation.map((item) => renderLink(item))}
</div>
</div>
</nav>
);
}
function Sidebar({
pathname,
collapsed,
onToggleCollapsed,
}: {
pathname: string;
collapsed: boolean;
onToggleCollapsed: () => void;
}) {
const { user } = useAuth();
const { learningState } = useStudentWorkspace();
const [billing, setBilling] = useState<BillingMe | null>(null);
const name = user?.name?.trim() || "Student";
const classLine =
[learningState?.profile?.class_level, learningState?.profile?.board]
.filter(Boolean)
.join(" · ") || "Complete your study setup";
const isPaid = billing ? isActivePaidPlanKey(billing.selected_plan) : false;
useEffect(() => {
let cancelled = false;
void getBillingMe()
.then((value) => {
if (!cancelled) setBilling(value);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
return (
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 hidden flex-col border-r border-dd-line bg-dd-canvas px-3.5 py-5 transition-[width] duration-200 lg:flex",
collapsed ? "w-[76px]" : "w-[248px]",
)}
>
<div className="flex items-center justify-between gap-2 px-1">
<StudentOsLogo compact={collapsed} />
</div>
<div className="mt-7 min-h-0 flex-1 overflow-y-auto pb-3">
<NavigationList pathname={pathname} compact={collapsed} />
</div>
{collapsed || isPaid ? null : (
<Link
href="/pricing"
className="mb-2 flex min-h-11 items-center gap-3 rounded-[9px] px-3 text-[13px] font-medium text-dd-muted transition-colors hover:bg-dd-sunk hover:text-dd-ink"
>
<BookOpen className="size-[17px]" aria-hidden="true" />
<span className="flex-1">View plans</span>
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
)}
{collapsed && !isPaid ? (
<Link
href="/pricing"
title="View plans"
className="mb-2 flex size-11 items-center justify-center self-center rounded-[9px] text-dd-muted hover:bg-dd-sunk hover:text-dd-ink"
>
<BookOpen className="size-4" aria-hidden="true" />
<span className="sr-only">View plans</span>
</Link>
) : null}
<Link
href="/settings"
className={cn(
"flex items-center gap-3 rounded-[9px] p-2.5 text-dd-ink transition-colors hover:bg-dd-sunk",
collapsed && "justify-center px-0",
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-dd-sunk text-dd-muted">
<UserRound className="size-4" aria-hidden="true" />
</span>
{collapsed ? (
<span className="sr-only">Profile and settings for {name}</span>
) : (
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-semibold">{name}</span>
<span className="block truncate text-[11px] text-dd-muted">
{classLine}
</span>
</span>
)}
</Link>
<button
type="button"
onClick={onToggleCollapsed}
className="mt-2 inline-flex min-h-10 items-center justify-center gap-2 rounded-[9px] text-[11px] font-medium text-dd-muted transition hover:bg-dd-sunk hover:text-dd-ink"
aria-label={collapsed ? "Expand navigation" : "Collapse navigation"}
>
<ChevronLeft className={cn("size-4 transition-transform", collapsed && "rotate-180")} />
{collapsed ? null : "Collapse"}
</button>
</aside>
);
}
function SearchPalette({ onClose }: { onClose: () => void }) {
const router = useRouter();
const { data } = useStudentWorkspace();
const [query, setQuery] = useState("");
const results = useMemo(() => {
const pages = studentSearchNavigation.map((item) => ({
label: item.label,
detail: "Page",
href: item.href,
icon: item.icon,
}));
const classes = data.classes.map((item) => ({
label: item.name,
detail: item.subject,
href: item.href || "/classes",
icon: BookOpen,
}));
const notes = data.notes.map((item) => ({
label: item.title,
detail: item.subject || "Note",
href: `/notes?note=${encodeURIComponent(item.id)}`,
icon: StickyNote,
}));
const bookmarks = data.bookmarks.map((item) => ({
label: item.title,
detail: "Bookmark",
href: item.href,
icon: Bookmark,
}));
const all = [...pages, ...classes, ...notes, ...bookmarks];
const normalized = query.trim().toLowerCase();
return normalized
? all
.filter((item) =>
`${item.label} ${item.detail}`.toLowerCase().includes(normalized),
)
.slice(0, 12)
: all.slice(0, 8);
}, [data.bookmarks, data.classes, data.notes, query]);
const reduceMotion = useReducedMotion();
const dialogRef = useRef<HTMLDivElement>(null);
useOverlayFocus(dialogRef, true, onClose);
return (
<div
ref={dialogRef}
tabIndex={-1}
className="student-os-sheet fixed inset-0 z-[80] flex items-end justify-center px-0 sm:items-start sm:px-4 sm:pt-[10vh]"
role="dialog"
aria-modal="true"
aria-label="Search DocDoe"
>
{/* Scrim fades via opacity on a static colour. Animating
`background-color` forced a full-viewport paint every frame. */}
<motion.button
type="button"
className="absolute inset-0 bg-black/35 backdrop-blur-[2px]"
onClick={onClose}
aria-label="Close search"
tabIndex={-1}
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: reduceMotion ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.18 }}
/>
<motion.div
className="relative flex max-h-[min(88dvh,720px)] w-full max-w-2xl flex-col overflow-hidden rounded-t-2xl border border-black/10 bg-white shadow-[0_30px_100px_rgba(0,0,0,0.28)] sm:rounded-2xl"
initial={
reduceMotion
? false
: { opacity: 0, y: 28, scale: 0.98 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={
reduceMotion
? { opacity: 1 }
: { opacity: 0, y: 18, scale: 0.98 }
}
transition={reduceMotion ? { duration: 0 } : springSoft}
>
<div className="flex items-center gap-3 border-b border-dd-line px-3 sm:px-4">
<Search className="size-5 shrink-0 text-dd-ink-2" />
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search pages, classes, notes, and bookmarks"
placeholder="Search pages, classes, notes…"
className="h-14 min-w-0 flex-1 bg-transparent text-[16px] text-dd-ink outline-none placeholder:text-dd-muted sm:text-sm"
/>
<button
type="button"
onClick={onClose}
className="inline-flex size-10 shrink-0 items-center justify-center rounded-lg border border-dd-line text-dd-ink-2 transition hover:bg-dd-canvas sm:size-auto sm:px-2 sm:py-1 sm:text-[10px] sm:font-bold"
aria-label="Close search"
>
<X className="size-4 sm:hidden" />
<span className="hidden sm:inline">ESC</span>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
{results.length > 0 ? (
results.map((item, index) => {
const Icon = item.icon;
return (
<motion.button
key={`${item.href}-${item.label}`}
type="button"
onClick={() => {
router.push(item.href);
onClose();
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left hover:bg-dd-canvas"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index, 8) * 0.03, duration: 0.22, ease: easeOutExpo }}
>
<span className="flex size-9 items-center justify-center rounded-xl border border-dd-line bg-white text-dd-ink">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-dd-ink">
{item.label}
</span>
<span className="block text-[11px] text-dd-muted">
{item.detail}
</span>
</span>
<ChevronRight className="size-4 text-dd-muted" />
</motion.button>
);
})
) : (
<div className="px-5 py-12 text-center">
<p className="text-sm font-semibold text-dd-ink">
No matching study item
</p>
<p className="mt-1 text-xs text-dd-muted">
Try a subject, note title, or page name.
</p>
</div>
)}
</div>
<div className="flex items-center gap-2 border-t border-dd-line bg-dd-paper px-4 py-2.5 text-[10px] font-medium text-dd-muted">
<Command className="size-3" /> Press Ctrl K anywhere to search
</div>
</motion.div>
</div>
);
}
function Header({
pathname,
onOpenMenu,
onOpenSearch,
}: {
pathname: string;
onOpenMenu: () => void;
onOpenSearch: () => void;
}) {
const router = useRouter();
const { user, logout } = useAuth();
const { data, status, updateWorkspace, refresh } = useStudentWorkspace();
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const reduceMotion = useReducedMotion();
const notificationsRef = useWorkspaceOverlay(notificationsOpen, () => setNotificationsOpen(false));
const profileRef = useWorkspaceOverlay(profileOpen, () => setProfileOpen(false));
const unread = data.notifications.filter((item) => !item.read_at).length;
const page = studentSearchNavigation.find((item) =>
isActive(pathname, item.href),
);
const name = user?.name?.trim() || "Student";
function markAllRead() {
const now = new Date().toISOString();
void updateWorkspace((current) => ({
...current,
notifications: current.notifications.map((item) => ({
...item,
read_at: item.read_at ?? now,
})),
})).catch(() => undefined);
}
return (
<header className="student-os-header sticky top-0 z-40 flex items-center gap-2 border-b border-dd-line bg-dd-paper px-3 sm:gap-3 sm:px-6 lg:ml-[var(--student-sidebar-w)] lg:px-10">
<button
type="button"
onClick={onOpenMenu}
className="student-press flex size-11 shrink-0 items-center justify-center rounded-xl border border-dd-line bg-white text-dd-ink transition hover:bg-dd-canvas active:scale-[0.96] lg:hidden"
aria-label="Open navigation"
aria-haspopup="dialog"
>
<Menu className="size-[18px]" />
</button>
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-dd-ink">
{page?.label ?? "DocDoe"}
</span>
</div>
<button
type="button"
onClick={onOpenSearch}
className="ml-auto hidden h-10 min-w-0 w-[min(320px,38vw)] items-center gap-2 rounded-[9px] bg-dd-canvas px-3 text-left text-[13px] text-dd-muted transition hover:bg-dd-sunk focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-dd-ink/40 lg:flex"
aria-label="Search your workspace"
title="Search your workspace (Ctrl K)"
>
<Search className="size-4 shrink-0 text-dd-ink-2" />
<span className="truncate">Search your workspace</span>
<kbd className="ml-auto hidden text-[10px] text-dd-muted xl:block">Ctrl K</kbd>
</button>
<button
type="button"
onClick={onOpenSearch}
className="ml-auto flex size-11 shrink-0 items-center justify-center rounded-[10px] border border-dd-line bg-white text-dd-ink-2 transition-colors duration-150 hover:bg-dd-canvas hover:text-dd-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-dd-ink/35 lg:hidden"
aria-label="Search your workspace"
title="Search your workspace (Ctrl K)"
>
<Search className="size-4 shrink-0" />
</button>
{status === "offline" || status === "conflict" ? (
<button
type="button"
onClick={() => void refresh()}
className="student-press flex size-10 items-center justify-center gap-1.5 rounded-[10px] border border-[#ead9bc] bg-dd-canvas text-[#805411] transition-colors hover:bg-[#fff3dc] sm:h-9 sm:w-auto sm:px-2.5"
title={status === "conflict" ? "Review the latest saved version" : "Retry workspace sync"}
aria-label={status === "conflict" ? "Workspace updated elsewhere. Reload latest version" : "Workspace offline. Retry sync"}
>
{status === "conflict" ? <RefreshCw className="size-3.5" /> : <WifiOff className="size-3.5" />}
<span className="hidden text-[11px] font-semibold sm:inline">
{status === "conflict" ? "Updated elsewhere" : "Offline"}
</span>
<RefreshCw className="size-3" />
</button>
) : null}
{status === "saving" ? (
<span className="flex size-10 items-center justify-center text-dd-ink-2 sm:h-auto sm:w-auto sm:gap-1.5" role="status" aria-label="Saving your work">
<RefreshCw className="size-3.5 animate-spin" aria-hidden="true" />
<span className="hidden text-[11px] font-semibold sm:inline">Saving…</span>
</span>
) : null}
<div className="relative">
<button
type="button"
onClick={() => {
setNotificationsOpen((value) => !value);
setProfileOpen(false);
}}
className="relative flex size-11 items-center justify-center rounded-full border border-dd-line text-dd-ink hover:bg-dd-canvas"
aria-label={`${unread} unread notifications`}
aria-haspopup="dialog"
aria-expanded={notificationsOpen}
data-overlay-trigger
>
<Bell className="size-[17px]" />
{unread > 0 ? (
<span className="absolute -right-0.5 -top-0.5 inline-flex min-w-4 items-center justify-center rounded-full bg-dd-ink px-1 text-[9px] font-bold leading-4 text-white">
{unread > 9 ? "9+" : unread}
</span>
) : null}
</button>
<AnimatePresence>
{notificationsOpen ? (
<motion.div
role="dialog"
aria-label="Notifications"
ref={notificationsRef}
className="absolute right-0 top-12 z-50 w-[min(360px,calc(100vw-1.25rem))] max-w-[calc(100vw-1.25rem)] origin-top-right overflow-hidden rounded-2xl border border-dd-line bg-white shadow-[0_20px_70px_rgba(0,0,0,0.18)]"
variants={popoverVariants}
initial="hidden"
animate="show"
exit="exit"
transition={reduceMotion ? { duration: 0 } : springSnappy}
>
<div className="flex items-center justify-between border-b border-dd-line px-4 py-3">
<div>
<p className="text-sm font-bold text-dd-ink">
Notifications
</p>
<p className="text-[10px] text-dd-muted">
Only real workspace updates appear here
</p>
</div>
{unread > 0 ? (
<button
type="button"
onClick={markAllRead}
className="text-[10px] font-bold text-dd-ink"
>
Mark all read
</button>
) : null}
</div>
<div className="max-h-80 overflow-y-auto p-2">
{data.notifications.length ? (
data.notifications.slice(0, 12).map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
if (!item.read_at) {
void updateWorkspace((current) => ({
...current,
notifications: current.notifications.map(
(notification) =>
notification.id === item.id
? {
...notification,
read_at: new Date().toISOString(),
}
: notification,
),
})).catch(() => undefined);
}
if (item.href) router.push(item.href);
setNotificationsOpen(false);
}}
className="flex w-full gap-3 rounded-xl p-3 text-left hover:bg-dd-canvas"
>
<span
className={cn(
"mt-1 size-2 shrink-0 rounded-full",
item.read_at ? "bg-dd-line-strong" : "bg-dd-ink",
)}
/>
<span>
<span className="block text-xs font-semibold text-dd-ink">
{item.title}
</span>
<span className="mt-1 block text-[11px] leading-5 text-dd-muted">
{item.body}
</span>
</span>
</button>
))
) : (
<div className="px-6 py-10 text-center">
<Check className="mx-auto size-6 text-dd-muted" />
<p className="mt-3 text-xs font-semibold text-dd-ink">
You&apos;re all caught up
</p>
<p className="mt-1 text-[11px] text-dd-muted">
Job completions and study reminders will appear here.
</p>
</div>
)}
</div>
<Link
href="/notifications"
onClick={() => setNotificationsOpen(false)}
className="flex h-10 items-center justify-center border-t border-dd-line text-[10px] font-bold text-dd-ink"
>
View all notifications
</Link>
</motion.div>
) : null}
</AnimatePresence>
</div>
<div className="relative">
<button
type="button"
onClick={() => {
setProfileOpen((value) => !value);
setNotificationsOpen(false);
}}
className="student-press flex h-11 min-w-11 items-center justify-center gap-2 rounded-[9px] px-2.5 text-[12px] font-medium text-dd-ink transition hover:bg-dd-canvas active:scale-[0.98]"
aria-label="Open profile menu"
aria-haspopup="menu"
aria-expanded={profileOpen}
data-overlay-trigger
>
<UserRound className="size-[17px]" aria-hidden="true" />
<span className="hidden sm:inline">Profile</span>
<ChevronRight className="hidden size-3 rotate-90 sm:block" aria-hidden="true" />
</button>
<AnimatePresence>
{profileOpen ? (
<motion.div
role="menu"
aria-label="Profile menu"
ref={profileRef}
className="absolute right-0 top-12 z-50 w-[min(14rem,calc(100vw-1.25rem))] origin-top-right rounded-2xl border border-dd-line bg-white p-2 shadow-[0_20px_70px_rgba(0,0,0,0.18)]"
variants={popoverVariants}
initial="hidden"
animate="show"
exit="exit"
transition={reduceMotion ? { duration: 0 } : springSnappy}
>
<div className="border-b border-dd-line px-3 py-2.5">
<p className="truncate text-xs font-bold text-dd-ink">
{name}
</p>
<p className="mt-0.5 truncate text-[10px] text-dd-muted">
{user?.email ?? "Student account"}
</p>
</div>
<Link
href="/settings"
onClick={() => setProfileOpen(false)}
role="menuitem"
className="mt-1 flex min-h-11 items-center gap-2 rounded-xl px-3 py-2.5 text-xs font-semibold text-dd-ink hover:bg-dd-canvas"
>
<Settings className="size-4" /> Profile & settings
</Link>
<button
type="button"
onClick={() => {
logout();
router.push("/login");
}}
role="menuitem"
className="flex min-h-11 w-full items-center gap-2 rounded-xl px-3 py-2.5 text-xs font-semibold text-[#a13f49] hover:bg-dd-canvas"
>
<LogOut className="size-4" /> Sign out
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
</header>
);
}
function MobileDrawer({
open,
pathname,
onClose,
libraryOnly = false,
}: {
open: boolean;
pathname: string;
onClose: () => void;
libraryOnly?: boolean;
}) {
const reduceMotion = useReducedMotion();
const drawerRef = useRef<HTMLElement>(null);
useOverlayFocus(drawerRef, open, onClose);
return (
<AnimatePresence>
{open ? (
<motion.div
className="fixed inset-0 z-[70] lg:hidden"
role="dialog"
aria-modal="true"
aria-label={libraryOnly ? "Library navigation" : "Student workspace navigation"}
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: reduceMotion ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.2 }}
>
<motion.button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-[3px]"
onClick={onClose}
aria-label="Close navigation"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: reduceMotion ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.2 }}
/>
<motion.aside
ref={drawerRef}
tabIndex={-1}
className="relative flex h-full w-[min(320px,88vw)] max-w-full flex-col overscroll-contain bg-dd-canvas px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-[max(1rem,env(safe-area-inset-top))] shadow-[20px_0_60px_rgba(0,0,0,0.15)]"
initial={reduceMotion ? false : { x: "-104%" }}
animate={reduceMotion ? { opacity: 1 } : { x: 0 }}
exit={reduceMotion ? { opacity: 1 } : { x: "-104%" }}
transition={reduceMotion ? { duration: 0 } : springSoft}
>
<div className="flex items-center justify-between gap-2 px-1">
{libraryOnly ? (
<div>
<p className="text-[11px] font-medium text-dd-muted">
Your study space
</p>
<p className="mt-1 text-[20px] font-semibold tracking-[-0.03em] text-dd-ink">
Library
</p>
</div>
) : (
<StudentOsLogo />
)}
<button
type="button"
onClick={onClose}
className="student-press flex size-10 shrink-0 items-center justify-center rounded-[9px] text-dd-muted transition hover:bg-dd-sunk active:scale-[0.96]"
aria-label="Close navigation menu"
>
<X className="size-4" />
</button>
</div>
<div className="mt-6 min-h-0 flex-1 overflow-y-auto overscroll-contain">
<NavigationList
pathname={pathname}
onNavigate={onClose}
libraryOnly={libraryOnly}
/>
</div>
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.18, ...springSoft }}
>
<Link
href="/settings"
onClick={onClose}
className="mt-3 flex min-h-11 items-center gap-3 rounded-[9px] px-3 py-3 text-xs font-medium text-dd-muted transition hover:bg-dd-sunk hover:text-dd-ink"
>
<Settings className="size-4" /> Profile & settings
</Link>
</motion.div>
</motion.aside>
</motion.div>
) : null}
</AnimatePresence>
);
}
function MobileBottomBar({
pathname,
onLibrary,
}: {
pathname: string;
onLibrary: () => void;
}) {
const reduceMotion = useReducedMotion();
const LibraryIcon = studentLibraryDestination.icon;
return (
<nav
className="student-os-bottom-nav fixed inset-x-0 bottom-0 z-50 grid grid-cols-5 border-t border-dd-line bg-dd-paper/96 px-0.5 backdrop-blur-xl lg:hidden"
aria-label="Mobile navigation"
>
<LayoutGroup id="student-mobile-nav">
{studentPrimaryNavigation.map((item) => {
const active = isActive(pathname, item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"relative flex min-h-[64px] flex-col items-center justify-center gap-0.5 px-0.5 text-[10px] font-semibold leading-none transition-colors",
active ? "text-dd-ink" : "text-dd-muted",
)}
>
{active && !reduceMotion ? (
<motion.span
layoutId="mobile-nav-pill"
className="absolute inset-x-3 top-0 h-0.5 rounded-full bg-dd-ink"
transition={springSnappy}
/>
) : active ? (
<span className="absolute inset-x-3 top-0 h-0.5 rounded-full bg-dd-ink" />
) : null}
<span className="relative z-[1] flex flex-col items-center gap-0.5">
<Icon className="size-5" strokeWidth={active ? 2.2 : 1.8} />
<span className="max-w-full truncate">
{item.label === "Study Chat"
? "Chat"
: item.label === "Learn Anything"
? "Learn"
: item.label}
</span>
</span>
</Link>
);
})}
</LayoutGroup>
<button
type="button"
onClick={onLibrary}
className={cn(
"relative flex min-h-[64px] flex-col items-center justify-center gap-0.5 px-0.5 text-[10px] font-semibold leading-none transition active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-dd-ink",
studentLibraryNavigation.some((item) => isActive(pathname, item.href))
? "text-dd-ink"
: "text-dd-muted",
)}
aria-label="Open Library"
>
<LibraryIcon className="size-5" />
<span>Library</span>
</button>
</nav>
);
}
function StudentOsFrame({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { setTheme } = useTheme();
const pathname = usePathname() ?? "/home";
const searchParams = useSearchParams();
const page = studentSearchNavigation.find((item) =>
isActive(pathname, item.href),
);
const { isLoading: isAuthLoading } = useAuth();
const reduceMotion = useReducedMotion();
const [menuPathname, setMenuPathname] = useState<string | null>(null);
const [libraryPathname, setLibraryPathname] = useState<string | null>(null);
const [searchPathname, setSearchPathname] = useState<string | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const menuOpen = menuPathname === pathname;
const libraryOpen = libraryPathname === pathname;
const searchOpen = searchPathname === pathname;
const mainRef = useRef<HTMLElement>(null);
const previousPathname = useRef(pathname);
const unauthenticated = isAuthenticationRequired() || !hasLiveSession();
const focusedTuition =
pathname === "/tuition" && searchParams.get("start") === "1";
useEffect(() => {
setTheme("light");
}, [setTheme]);
useEffect(() => {
if (!menuOpen && !libraryOpen && !searchOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [libraryOpen, menuOpen, searchOpen]);
useEffect(() => {
if (!isAuthLoading && unauthenticated)
router.replace(`/login?next=${encodeURIComponent(pathname)}`);
}, [isAuthLoading, pathname, router, unauthenticated]);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const target = event.target instanceof HTMLElement ? event.target : null;
const isEditing = Boolean(
target?.isContentEditable || target?.matches("input, textarea, select"),
);
if (
!event.isComposing &&
!isEditing &&
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === "k"
) {
event.preventDefault();
setSearchPathname(pathname);
}
if (event.key === "Escape") {
setSearchPathname(null);
setMenuPathname(null);
setLibraryPathname(null);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [pathname]);
useEffect(() => {
if (previousPathname.current === pathname) return;
previousPathname.current = pathname;
const frame = window.requestAnimationFrame(() => {
mainRef.current?.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frame);
}, [pathname]);
const blockChildren = isAuthLoading || unauthenticated;
return (
<div
className="docdoe-student-os min-h-dvh bg-dd-paper text-dd-ink selection:bg-dd-canvas selection:text-dd-ink"
data-student-route={pathname}
style={{
["--student-sidebar-w" as string]: focusedTuition
? "0px"
: sidebarCollapsed
? "76px"
: "248px",
}}
>
{!focusedTuition ? (
<Sidebar
pathname={pathname}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((value) => !value)}
/>
) : null}
{!focusedTuition ? (
<Header
pathname={pathname}
onOpenMenu={() => setMenuPathname(pathname)}
onOpenSearch={() => setSearchPathname(pathname)}
/>
) : null}
<MobileDrawer
open={menuOpen}
pathname={pathname}
onClose={() => setMenuPathname(null)}
/>
<MobileDrawer
open={libraryOpen}
pathname={pathname}
onClose={() => setLibraryPathname(null)}
libraryOnly
/>
<AnimatePresence mode="wait">
{searchOpen ? (
<SearchPalette
key="search-palette"
onClose={() => setSearchPathname(null)}
/>
) : null}
</AnimatePresence>
<main
ref={mainRef}
tabIndex={-1}
aria-label={page?.label ?? "Student workspace"}
className={cn("student-os-main", !focusedTuition && "lg:ml-[var(--student-sidebar-w)]")}
style={focusedTuition ? { paddingBottom: 0 } : undefined}
>
<Suspense fallback={<StudentOsPageSkeleton />}>
{blockChildren ? (
<StudentOsPageSkeleton />
) : (
/* `mode="wait"` gives the outgoing route a real exit instead of an
abrupt swap, which is what makes navigation feel connected. One
motion node per route, so it stays inside the budget. */
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
className="student-route-surface"
variants={reduceMotion ? {
enter: { opacity: 0 },
center: { opacity: 1 },
exit: { opacity: 0 },
} : routeVariants}
initial="enter"
animate="center"
exit="exit"
transition={reduceMotion ? { duration: 0 } : undefined}
>
{children}
</motion.div>
</AnimatePresence>
)}
</Suspense>
</main>
{!focusedTuition ? (
<MobileBottomBar
pathname={pathname}
onLibrary={() => setLibraryPathname(pathname)}
/>
) : null}
{ASSISTANT_WIDGET_ENABLED && !focusedTuition ? (
<StudentOsAssistant />
) : null}
</div>
);
}
export function StudentOsShell({ children }: { children: React.ReactNode }) {
return (
<StudentWorkspaceProvider>
<Suspense fallback={<StudentOsPageSkeleton />}>
<StudentOsFrame>{children}</StudentOsFrame>
</Suspense>
</StudentWorkspaceProvider>
);
}