-
-
- Junior Frontend Developer Preparation
-
-
- (in development, 'in progress' parts are not completed)
-
-
-
- {sections.map((section, index) => {
- const rowNumber = Math.floor(index / columns);
- const isLastRow =
- rowNumber === Math.floor((totalItems - 1) / columns);
+
+ {/* Back nav */}
+
+
+
- const itemsOnLastRow = totalItems % columns;
- const itemsOnThisRow =
- isLastRow && itemsOnLastRow > 0 ? itemsOnLastRow : columns;
+ {/* Header */}
+
+
+ Junior Level
+
+
+ Frontend Development
+
+
+ Master the fundamentals of modern frontend development. Topics marked
+ as in progress are coming soon.
+
+
- const colSpan = Math.round(6 / itemsOnThisRow);
+ {/* Topic Grid */}
+
+
+ {sections.map((section, index) => {
+ const Icon = topicIcons[index] || Code2;
+ const isDisabled = section.inProgress;
- const className = `rounded-lg border border-zinc-800 bg-zinc-900/90 p-6 shadow-sm transition-colors duration-200 hover:border-zinc-600 ${
- colSpanClasses[colSpan]
- }`;
+ return (
+
!isDisabled && router.push(section.href)}
+ type='button'
+ variants={itemVariants}
+ whileHover={isDisabled ? {} : { scale: 1.02, y: -2 }}
+ whileTap={isDisabled ? {} : { scale: 0.98 }}
+ >
+ {/* Top gradient bar */}
+ {!isDisabled && (
+
+ )}
- return (
-
- );
- })}
-
+
+
+ {isDisabled ? (
+ 'Coming soon'
+ ) : (
+ <>
+ Start learning
+
+ >
+ )}
+
+
+ );
+ })}
+
+
);
}
diff --git a/src/app/globals.css b/src/app/globals.css
index e1274dc..0bd2b4d 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,26 +1,32 @@
@import "tailwindcss";
:root {
- /* Default to dark theme */
- --background: #0a0a0a;
- --foreground: #ededed;
- --accent: #eab308; /* yellow-500 */
- --muted: #a1a1aa; /* zinc-400 */
- --muted-foreground: #d4d4d8; /* zinc-300 */
- --border: rgba(255, 255, 255, 0.08);
- --glass-bg: rgba(0, 0, 0, 0.1);
- --glass-strong-bg: rgba(0, 0, 0, 0.22);
+ /* ─── Design System: Deep Navy + Emerald/Cyan Accent ─── */
+ --background: #0b0f1a;
+ --background-secondary: #111827;
+ --foreground: #e8edf5;
+ --accent: #34d399; /* emerald-400 */
+ --accent-secondary: #06b6d4; /* cyan-500 */
+ --accent-gradient: linear-gradient(135deg, #06b6d4, #34d399);
+ --muted: #94a3b8; /* slate-400 */
+ --muted-foreground: #cbd5e1; /* slate-300 */
+ --border: rgba(148, 163, 184, 0.1);
+ --border-strong: rgba(148, 163, 184, 0.2);
+ --glass-bg: rgba(15, 23, 42, 0.6);
+ --glass-strong-bg: rgba(15, 23, 42, 0.85);
+ --card-bg: rgba(15, 23, 42, 0.5);
--ring: var(--accent);
+
/* Current page header height (controlled by PageHeader) */
--page-header-height: 128px;
- /* Offset built-in anchor navigation and scrollIntoView to account for sticky header */
+ /* Offset anchor navigation for sticky header */
scroll-padding-top: var(--page-header-height, 128px);
- /* Ambient background hues */
- --ambient-1: 255 90 31; /* warm orange */
- --ambient-2: 234 179 8; /* yellow-500 */
- --ambient-3: 168 85 247; /* purple */
- --ambient-4: 14 165 233; /* cyan */
+ /* Ambient background hues - deep blues and teals */
+ --ambient-1: 6 182 212; /* cyan-500 */
+ --ambient-2: 52 211 153; /* emerald-400 */
+ --ambient-3: 99 102 241; /* indigo-500 */
+ --ambient-4: 139 92 246; /* violet-500 */
}
/* Smooth scrolling for anchor navigation; respect reduced motion */
@@ -37,12 +43,17 @@ html {
h2[id],
h3[id],
h4[id] {
- scroll-margin-top: calc(var(--page-header-height, 128px) + 16px);
+ scroll-margin-top: calc(var(--page-header-height, 128px) + 24px);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
+ --color-accent: var(--accent);
+ --color-accent-secondary: var(--accent-secondary);
+ --color-muted: var(--muted);
+ --color-border: var(--border);
+ --color-card-bg: var(--card-bg);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@@ -50,13 +61,16 @@ h4[id] {
/* Respect system light preference while favoring dark by default */
@media (prefers-color-scheme: light) {
:root {
- --background: #ffffff;
- --foreground: #171717;
- --muted: #52525b; /* zinc-600 */
- --muted-foreground: #3f3f46; /* zinc-700 */
- --border: rgba(0, 0, 0, 0.08);
- --glass-bg: rgba(255, 255, 255, 0.5);
- --glass-strong-bg: rgba(255, 255, 255, 0.6);
+ --background: #f8fafc;
+ --background-secondary: #f1f5f9;
+ --foreground: #0f172a;
+ --muted: #64748b;
+ --muted-foreground: #475569;
+ --border: rgba(15, 23, 42, 0.08);
+ --border-strong: rgba(15, 23, 42, 0.15);
+ --glass-bg: rgba(248, 250, 252, 0.7);
+ --glass-strong-bg: rgba(248, 250, 252, 0.9);
+ --card-bg: rgba(241, 245, 249, 0.7);
}
}
@@ -76,9 +90,7 @@ body {
"Segoe UI Emoji";
}
-/* Image + gradient background is rendered via AmbientBackground component */
-
-/* subtle vignette to highlight glass layers */
+/* Subtle vignette overlay */
body::after {
content: "";
position: fixed;
@@ -86,11 +98,15 @@ body::after {
z-index: -1;
background:
radial-gradient(
- 60% 60% at 50% 0%,
- rgba(255, 255, 255, 0.03),
- transparent 70%
+ ellipse 80% 50% at 50% 0%,
+ rgba(6, 182, 212, 0.03),
+ transparent 60%
),
- radial-gradient(80% 50% at 50% 100%, rgba(0, 0, 0, 0.25), transparent 70%);
+ radial-gradient(
+ ellipse 60% 40% at 50% 100%,
+ rgba(0, 0, 0, 0.3),
+ transparent 70%
+ );
pointer-events: none;
transition: filter 0.28s ease-in-out;
}
@@ -107,13 +123,40 @@ a:focus-visible,
.glass {
background: var(--glass-bg);
border: 1px solid var(--border);
- backdrop-filter: blur(12px) saturate(140%);
+ backdrop-filter: blur(16px) saturate(150%);
+ -webkit-backdrop-filter: blur(16px) saturate(150%);
}
.glass-strong {
background: var(--glass-strong-bg);
border-bottom: 1px solid var(--border);
- backdrop-filter: blur(14px) saturate(160%);
+ backdrop-filter: blur(20px) saturate(170%);
+ -webkit-backdrop-filter: blur(20px) saturate(170%);
+}
+
+/* Card glass variant */
+.glass-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ backdrop-filter: blur(12px) saturate(130%);
+ -webkit-backdrop-filter: blur(12px) saturate(130%);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.glass-card:hover {
+ border-color: var(--border-strong);
+ background: rgba(15, 23, 42, 0.65);
+ box-shadow:
+ 0 8px 32px rgba(6, 182, 212, 0.08),
+ 0 0 0 1px var(--border-strong);
+}
+
+/* Accent gradient text utility */
+.text-gradient {
+ background: var(--accent-gradient);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
}
/* Mobile: allow full viewport width */
@@ -123,8 +166,7 @@ a:focus-visible,
}
}
-/* SSR-safe initial width via data attribute (desktop only to avoid descending specificity with mobile override) */
-/* Runtime inline style from PageContainer drives width; these are fallback for SSR/first paint */
+/* SSR-safe initial width via data attribute (desktop only) */
@media (min-width: 641px) {
html[data-content-width="narrow"] .content-container {
max-width: 50vw;
@@ -149,3 +191,26 @@ a:focus-visible,
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
+
+/* Thin styled scrollbar for TOC */
+.scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(148, 163, 184, 0.2) transparent;
+}
+
+.scrollbar-thin::-webkit-scrollbar {
+ width: 4px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background: rgba(148, 163, 184, 0.2);
+ border-radius: 2px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background: rgba(148, 163, 184, 0.4);
+}
diff --git a/src/components/ambient-background/ambient-background.tsx b/src/components/ambient-background/ambient-background.tsx
index bef1f56..f0d7157 100644
--- a/src/components/ambient-background/ambient-background.tsx
+++ b/src/components/ambient-background/ambient-background.tsx
@@ -1,29 +1,117 @@
'use client';
-import Image from 'next/image';
+import { motion } from 'framer-motion';
+import { useEffect, useState } from 'react';
export function AmbientBackground() {
+ const [prefersReduced, setPrefersReduced] = useState(false);
+
+ useEffect(() => {
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
+ setPrefersReduced(mq.matches);
+ const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
+ mq.addEventListener('change', handler);
+ return () => mq.removeEventListener('change', handler);
+ }, []);
+
return (
-
+
+ {/* Animated orbs */}
+
+
+
+
+ {/* Fade to background at bottom */}
+
+ {/* Grid overlay for subtle texture */}
+
diff --git a/src/components/code-block/code-block.tsx b/src/components/code-block/code-block.tsx
index c83e946..ca13fd8 100644
--- a/src/components/code-block/code-block.tsx
+++ b/src/components/code-block/code-block.tsx
@@ -3,7 +3,6 @@ import { coldarkDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { parseHighlightLines } from '@/helpers/parse-highlight-lines';
import type { CodeBlockProps } from '@/types';
-// Move regex patterns to top level for better performance
const COMMENT_REGEX = /^\/\*\s*\w+\s*\*\/\s*\n?/;
const LEADING_SPACES_REGEX = /^\s+/;
const TRAILING_SPACES_REGEX = /\s+$/;
@@ -45,7 +44,6 @@ export const CodeBlock = ({
const highlightedLines = parseHighlightLines(highlightLines);
const highlightedLinesEnd = parseHighlightLines(highlightLinesEnd);
- // Line props function for highlighting
const getLineProps = (lineNumber: number) => {
const isHighlighted = highlightedLines.includes(lineNumber);
const isHighlightedEnd = highlightedLinesEnd.includes(lineNumber);
@@ -54,12 +52,11 @@ export const CodeBlock = ({
let borderLeft = 'none';
if (isHighlightedEnd) {
- backgroundColor = 'rgba(34, 197, 94, 0.15)'; // Green for end lines
- borderLeft = '3px solid rgb(34, 197, 94)';
+ backgroundColor = 'rgba(52, 211, 153, 0.12)';
+ borderLeft = '3px solid rgb(52, 211, 153)';
} else if (isHighlighted) {
- // Yellow accent for highlighted lines
- backgroundColor = 'rgba(234, 179, 8, 0.18)';
- borderLeft = '3px solid rgb(234, 179, 8)';
+ backgroundColor = 'rgba(6, 182, 212, 0.12)';
+ borderLeft = '3px solid rgb(6, 182, 212)';
}
return {
@@ -76,9 +73,15 @@ export const CodeBlock = ({
};
return (
-
+
{comment && (
-
+
{`/* ${comment} */`}
)}
@@ -94,8 +97,8 @@ export const CodeBlock = ({
margin: 0,
padding: '1rem',
background: 'transparent',
- fontSize: '0.875rem',
- lineHeight: '1.5',
+ fontSize: '0.8125rem',
+ lineHeight: '1.6',
overflowX: 'auto',
}}
language={language}
diff --git a/src/components/layout/content-page.tsx b/src/components/layout/content-page.tsx
index 92cf0dd..53f1f93 100644
--- a/src/components/layout/content-page.tsx
+++ b/src/components/layout/content-page.tsx
@@ -14,7 +14,7 @@ export function ContentPage({
allowWidthToggle = true,
}: ContentPageProps) {
return (
-
+
= {
full: '100vw',
};
+const validPresets = new Set(Object.keys(presetToMaxWidth));
+
export function PageContainer({
children,
className = '',
@@ -18,20 +20,26 @@ export function PageContainer({
allowWidthToggle = true,
}: PageContainerProps) {
const storageKey = 'prep:content-width';
- const [width, setWidth] = useState(() => {
- if (typeof document !== 'undefined') {
- const attr = document.documentElement.dataset.contentWidth as
- | WidthPreset
- | undefined;
- if (attr && attr in presetToMaxWidth) {
- return attr;
- }
- }
- return initialWidth;
- });
+ const [width, setWidth] = useState(initialWidth);
const [headerHeight, setHeaderHeight] = useState(120);
+ // Sync width state from localStorage/data-attribute after hydration
+ useEffect(() => {
+ // First check localStorage (most reliable client-side)
+ const stored = localStorage.getItem(storageKey);
+ if (stored && validPresets.has(stored)) {
+ setWidth(stored as WidthPreset);
+ document.documentElement.dataset.contentWidth = stored;
+ return;
+ }
+ // Fallback to data attribute set by boot script or SSR cookie
+ const attr = document.documentElement.dataset.contentWidth;
+ if (attr && validPresets.has(attr)) {
+ setWidth(attr as WidthPreset);
+ }
+ }, []);
+
useEffect(() => {
if (typeof window === 'undefined') {
return;
diff --git a/src/components/notes-area/notes-area.tsx b/src/components/notes-area/notes-area.tsx
index 6644a05..3024c69 100644
--- a/src/components/notes-area/notes-area.tsx
+++ b/src/components/notes-area/notes-area.tsx
@@ -6,9 +6,9 @@ export const NotesArea = ({
}: NotesAreaProps) => {
return (
-
{placeholder}
+
{placeholder}
);
};
diff --git a/src/components/page-header/page-header.tsx b/src/components/page-header/page-header.tsx
index 219d2b2..c294e67 100644
--- a/src/components/page-header/page-header.tsx
+++ b/src/components/page-header/page-header.tsx
@@ -28,7 +28,6 @@ export const PageHeader = ({
const isHiddenOnMobileRef = useRef(false);
const lastCssHeaderHeight = useRef(null);
- // Threshold constants for mobile hysteresis
const JITTER_PX = 5;
const HIDE_THRESHOLD_PX = 24;
const SHOW_THRESHOLD_PX = 64;
@@ -111,7 +110,6 @@ export const PageHeader = ({
]
);
- // Track mobile breakpoint to enable full hide behavior on small screens
useEffect(() => {
if (typeof window === 'undefined') return;
const mq: MediaQueryList = window.matchMedia('(max-width: 639px)');
@@ -135,7 +133,6 @@ export const PageHeader = ({
};
}, []);
- // Scroll listener with rAF batching
useEffect(() => {
if (typeof window === 'undefined') return;
let rafId: number | null = null;
@@ -145,7 +142,6 @@ export const PageHeader = ({
rafId = null;
const currentScrollY = Math.max(0, window.scrollY);
const delta = currentScrollY - lastScrollY.current;
- // Avoid toggling collapse state on mobile to reduce flicker; rely on full-hide
if (!isMobile.current) {
setIsScrolled(currentScrollY > 20 && delta > 0);
}
@@ -163,7 +159,6 @@ export const PageHeader = ({
};
}, [handleMobileScroll]);
- // Expose current header height as a CSS variable for other components
useEffect(() => {
if (typeof document === 'undefined') {
return;
@@ -174,7 +169,6 @@ export const PageHeader = ({
if (isHiddenOnMobile) {
current = '0px';
} else if (isMobileScreen) {
- // On mobile, when visible, keep the header at full height for clear tap targets
current = expandedHeight;
} else if (isInitialLoad || !isScrolled) {
current = expandedHeight;
@@ -206,7 +200,6 @@ export const PageHeader = ({
style={{
willChange: 'height',
overflow: 'hidden',
- // Remove bottom border when fully hidden on mobile to avoid a 1px line
borderBottomWidth: isHiddenOnMobile ? 0 : 1,
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
@@ -217,11 +210,20 @@ export const PageHeader = ({
ease: 'easeOut',
}}
>
+ {/* Gradient accent line at bottom */}
+
+
@@ -230,7 +232,7 @@ export const PageHeader = ({
{(isInitialLoad || !isScrolled) && (
-
+
{topicHome && (
)}
diff --git a/src/components/section-card/section-card.tsx b/src/components/section-card/section-card.tsx
index 26e0617..06b5f99 100644
--- a/src/components/section-card/section-card.tsx
+++ b/src/components/section-card/section-card.tsx
@@ -5,16 +5,23 @@ export const SectionCard = ({ title, children }: SectionCardProps) => {
const id = slugify(title);
return (
+ {/* Top gradient accent */}
+
{title}
- {children}
+
+ {children}
+
);
};
diff --git a/src/components/table-of-contents/table-of-contents.tsx b/src/components/table-of-contents/table-of-contents.tsx
index 8bde15c..13f3233 100644
--- a/src/components/table-of-contents/table-of-contents.tsx
+++ b/src/components/table-of-contents/table-of-contents.tsx
@@ -1,8 +1,8 @@
'use client';
import { motion } from 'framer-motion';
-import { Pin, PinOff } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
+import { List, Pin, PinOff } from 'lucide-react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import type { TocHeading, TocItem } from '@/types';
export const TableOfContents = () => {
@@ -11,28 +11,24 @@ export const TableOfContents = () => {
const [pinned, setPinned] = useState(false);
const [headerHeight, setHeaderHeight] = useState(0);
const [isLoaded, setIsLoaded] = useState(false);
+ const [activeId, setActiveId] = useState
('');
const touchStartRef = useRef(null);
const openRef = useRef(open);
const pinnedRef = useRef(pinned);
+ // Build TOC from headings
useEffect(() => {
const getHeadingLevel = (tagName: string): number => {
- if (tagName === 'H2') {
- return 2;
- }
- if (tagName === 'H3') {
- return 3;
- }
+ if (tagName === 'H2') return 2;
+ if (tagName === 'H3') return 3;
return 4;
};
- const createTocItem = (el: HTMLElement): TocHeading => {
- return {
- id: el.id,
- text: el.textContent || '',
- level: getHeadingLevel(el.tagName),
- };
- };
+ const createTocItem = (el: HTMLElement): TocHeading => ({
+ id: el.id,
+ text: el.textContent || '',
+ level: getHeadingLevel(el.tagName),
+ });
const updateTOC = () => {
const headings = Array.from(
@@ -40,10 +36,8 @@ export const TableOfContents = () => {
);
const mapped: TocItem[] = [];
-
for (const el of headings) {
const item = createTocItem(el);
-
if (item.level === 2) {
mapped.push({ id: item.id, text: item.text, children: [] });
} else {
@@ -57,15 +51,13 @@ export const TableOfContents = () => {
setIsLoaded(true);
};
- // Initial update with delay to ensure content is rendered
const timeoutId = setTimeout(updateTOC, 0);
const header = document.getElementById('page-header');
let resizeObserver: ResizeObserver | null = null;
- let updateHeight: (() => void) | null = null;
if (header) {
- updateHeight = () =>
+ const updateHeight = () =>
setHeaderHeight(header.getBoundingClientRect().height);
updateHeight();
resizeObserver = new ResizeObserver(updateHeight);
@@ -74,12 +66,41 @@ export const TableOfContents = () => {
return () => {
clearTimeout(timeoutId);
- if (resizeObserver) {
- resizeObserver.disconnect();
- }
+ resizeObserver?.disconnect();
};
}, []);
+ // IntersectionObserver for active section tracking
+ useEffect(() => {
+ if (!isLoaded) return;
+
+ const headings = document.querySelectorAll(
+ 'h2[id], h3[id], h4[id]'
+ );
+ if (headings.length === 0) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ setActiveId(entry.target.id);
+ }
+ }
+ },
+ {
+ rootMargin: '-20% 0px -70% 0px',
+ threshold: 0,
+ }
+ );
+
+ for (const heading of headings) {
+ observer.observe(heading);
+ }
+
+ return () => observer.disconnect();
+ }, [isLoaded]);
+
+ // Touch gestures for mobile
useEffect(() => {
const onTouchStart = (e: TouchEvent) => {
if (window.innerWidth < 768) {
@@ -103,94 +124,83 @@ export const TableOfContents = () => {
};
}, []);
- // Also open on single tap anywhere near the left edge on mobile
useEffect(() => {
const onTouchTap = (e: TouchEvent) => {
if (window.innerWidth >= 768) return;
if (e.touches.length !== 1) return;
- const x = e.touches[0].clientX;
- if (x < 30) {
+ if (e.touches[0].clientX < 30) {
setOpen(true);
}
};
window.addEventListener('touchstart', onTouchTap, { passive: true });
- return () => {
- window.removeEventListener('touchstart', onTouchTap);
- };
+ return () => window.removeEventListener('touchstart', onTouchTap);
}, []);
- const handleMouseLeave = () => {
- if (!pinned) {
- setOpen(false);
- }
- };
+ const handleMouseLeave = useCallback(() => {
+ if (!pinned) setOpen(false);
+ }, [pinned]);
- const handleLinkClick = (e: React.MouseEvent) => {
- if (!pinned) {
- setOpen(false);
- }
+ const handleLinkClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (!pinned) setOpen(false);
- // Prevent default navigation and use CSS scroll-margin/scroll-padding
- e.preventDefault();
- const href = e.currentTarget.getAttribute('href');
- if (href?.startsWith('#')) {
- const targetId = href.substring(1);
- const targetElement = document.getElementById(targetId);
- if (targetElement) {
- targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
- // Update the address bar without triggering a jump
- if (
- typeof history !== 'undefined' &&
- typeof history.replaceState === 'function'
- ) {
- history.replaceState(null, '', href);
+ e.preventDefault();
+ const href = e.currentTarget.getAttribute('href');
+ if (href?.startsWith('#')) {
+ const targetId = href.substring(1);
+ const targetElement = document.getElementById(targetId);
+ if (targetElement) {
+ targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ if (typeof history !== 'undefined') {
+ history.replaceState(null, '', href);
+ }
+ setActiveId(targetId);
}
}
- }
- };
+ },
+ [pinned]
+ );
- const handleTriggerClick = () => {
- setOpen(!open);
- };
+ const handleTriggerClick = useCallback(() => {
+ setOpen((prev) => !prev);
+ }, []);
- // Handle click outside on mobile and always unpin when mobile
+ // Keep refs in sync
useEffect(() => {
openRef.current = open;
pinnedRef.current = pinned;
}, [open, pinned]);
+ // Close on outside click (mobile)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (window.innerWidth >= 768) return;
- // On mobile ensure menu is unpinned
- if (pinnedRef.current) {
- setPinned(false);
- }
- if (!openRef.current || pinnedRef.current) {
- return;
- }
+ if (pinnedRef.current) setPinned(false);
+ if (!openRef.current || pinnedRef.current) return;
+
const target = event.target as Element;
const tocNav = document.querySelector('nav[style*="top:"]');
- const tocContent = tocNav?.querySelector('.scrollbar-hide');
-
+ const tocContent = tocNav?.querySelector('.scrollbar-thin');
if (tocContent && !tocContent.contains(target)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
+ return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
- // Don't render until content is loaded to prevent flickering
- if (!isLoaded) {
- return null;
- }
+ // Determine if a section or its children are active
+ const isSectionActive = (section: TocItem) => {
+ if (activeId === section.id) return true;
+ return section.children.some((child) => child.id === activeId);
+ };
+
+ if (!isLoaded) return null;
return (