Download src/components/student-os/StudentOsShell.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 40.3 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/student-os/StudentOsShell.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/student-os/StudentOsShell.tsx
-
curl -L -o StudentOsShell.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/student-os/StudentOsShell.tsx
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'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> | |
| ); | |
| } | |