From 7addc1a0a6103b79116af509dd9072f29d9c9ddb Mon Sep 17 00:00:00 2001 From: Yevhenii Zhaivoronok Date: Sun, 8 Feb 2026 21:50:55 +0100 Subject: [PATCH] feat: complete visual redesign with new design system Replace the dark/yellow theme with a deep navy + emerald/cyan design system. Redesign all pages (homepage, topic selector, content pages) with glass morphism cards, gradient accents, Framer Motion animations, and responsive layouts. Fix width mode selector hydration bug so it correctly shows the saved preference on page load. Redesign table of contents with active section tracking via IntersectionObserver, cleaner hierarchy, mobile FAB trigger, and pin/unpin UI. Co-Authored-By: Claude Opus 4.6 --- src/app/(main)/home.tsx | 201 ++++++++--- src/app/frontend/junior/frontend-junior.tsx | 198 ++++++++--- src/app/globals.css | 133 +++++-- .../ambient-background/ambient-background.tsx | 110 +++++- src/components/code-block/code-block.tsx | 25 +- src/components/layout/content-page.tsx | 2 +- src/components/layout/page-container.tsx | 30 +- src/components/notes-area/notes-area.tsx | 4 +- src/components/page-header/page-header.tsx | 30 +- src/components/section-card/section-card.tsx | 13 +- .../table-of-contents/table-of-contents.tsx | 329 ++++++++++-------- src/components/typography/callout.tsx | 2 +- src/components/typography/code-span.tsx | 2 +- src/components/typography/header.tsx | 2 +- src/components/typography/subheader.tsx | 2 +- src/components/typography/text.tsx | 9 +- .../width-switcher/width-switcher.tsx | 37 +- 17 files changed, 778 insertions(+), 351 deletions(-) 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 (