diff --git a/src/app/(main)/home.tsx b/src/app/(main)/home.tsx index 831aacb..70fc84e 100644 --- a/src/app/(main)/home.tsx +++ b/src/app/(main)/home.tsx @@ -1,4 +1,7 @@ 'use client'; + +import { motion } from 'framer-motion'; +import { ArrowRight, BookOpen, Code2, Layers, Sparkles } from 'lucide-react'; import { useRouter } from 'next/navigation'; import type { Section } from '@/types'; @@ -13,76 +16,186 @@ const sections: Section[] = [ }, ]; -export default function HomeComponent() { - const router = useRouter(); +const features = [ + { + icon: Code2, + title: 'Code Examples', + description: + 'Syntax-highlighted, real-world code snippets you can learn from', + }, + { + icon: BookOpen, + title: 'Interactive Notes', + description: 'Structured study materials with clear explanations', + }, + { + icon: Layers, + title: 'Topic Navigation', + description: 'Organized by skill level with table of contents', + }, +]; - const totalItems = sections.length; - const columns = 3; +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + delayChildren: 0.2, + }, + }, +}; - const colSpanClasses: { [key: number]: string } = { - 2: 'md:col-span-2', - 3: 'md:col-span-3', - 6: 'md:col-span-6', - }; +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] as const }, + }, +}; + +export default function HomeComponent() { + const router = useRouter(); return ( -
-
-

- Any interview prep -

-
-
- {sections.map((section, index) => { - const rowNumber = Math.floor(index / columns); - const isLastRow = - rowNumber === Math.floor((totalItems - 1) / columns); +
+ {/* Hero */} + + + + Interview Preparation Platform + - const itemsOnLastRow = totalItems % columns; - const itemsOnThisRow = - isLastRow && itemsOnLastRow > 0 ? itemsOnLastRow : columns; + + Ace your next interview + - const colSpan = Math.round(6 / itemsOnThisRow); + + Structured study materials, real code examples, and comprehensive + topic coverage to help you prepare with confidence. + - 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 ( - - ); - })} -
+ + ))} +
+ + + {/* Features Grid */} + +
+ {features.map((feature) => ( + + +

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
); } diff --git a/src/app/frontend/junior/frontend-junior.tsx b/src/app/frontend/junior/frontend-junior.tsx index 86969bd..b93c91e 100644 --- a/src/app/frontend/junior/frontend-junior.tsx +++ b/src/app/frontend/junior/frontend-junior.tsx @@ -1,7 +1,20 @@ 'use client'; + +import { motion } from 'framer-motion'; +import { + ArrowLeft, + ArrowRight, + Code2, + FileCode, + Globe, + Layers, + Wrench, +} from 'lucide-react'; import { useRouter } from 'next/navigation'; import type { Section } from '@/types'; +const topicIcons = [FileCode, Code2, Globe, Layers, Wrench]; + const sections: Section[] = [ { href: '/frontend/junior/html&css', @@ -36,69 +49,150 @@ const sections: Section[] = [ }, ]; -export default function FrontendJunior() { - const router = useRouter(); +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.08, delayChildren: 0.15 }, + }, +}; - const totalItems = sections.length; - const columns = 3; +const itemVariants = { + hidden: { opacity: 0, y: 16 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.45, ease: [0.25, 0.46, 0.45, 0.94] as const }, + }, +}; - const colSpanClasses: { [key: number]: string } = { - 2: 'md:col-span-2', - 3: 'md:col-span-3', - 6: 'md:col-span-6', - }; +export default function FrontendJunior() { + const router = useRouter(); return ( -
-
-

- 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 (