diff --git a/bun.lock b/bun.lock index fb95158..b8d055f 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "dedent": "^1.6.0", "framer-motion": "^12.23.0", "lucide-react": "^0.523.0", - "next": "16.0.1", + "next": "16.0.10", "react": "^19.0.0", "react-dom": "^19.0.0", "react-syntax-highlighter": "^15.6.1", @@ -283,21 +283,21 @@ "@next/env": ["@next/env@16.0.3", "", {}, "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.10", "", { "os": "win32", "cpu": "x64" }, "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], @@ -967,7 +967,7 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next": ["next@16.0.1", "", { "dependencies": { "@next/env": "16.0.1", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.1", "@next/swc-darwin-x64": "16.0.1", "@next/swc-linux-arm64-gnu": "16.0.1", "@next/swc-linux-arm64-musl": "16.0.1", "@next/swc-linux-x64-gnu": "16.0.1", "@next/swc-linux-x64-musl": "16.0.1", "@next/swc-win32-arm64-msvc": "16.0.1", "@next/swc-win32-x64-msvc": "16.0.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw=="], + "next": ["next@16.0.10", "", { "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-x64": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-musl": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA=="], "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], @@ -1291,7 +1291,7 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "next/@next/env": ["@next/env@16.0.1", "", {}, "sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw=="], + "next/@next/env": ["@next/env@16.0.10", "", {}, "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], diff --git a/src/app/(main)/home.tsx b/src/app/(main)/home.tsx index 831aacb..25f49f9 100644 --- a/src/app/(main)/home.tsx +++ b/src/app/(main)/home.tsx @@ -16,72 +16,55 @@ const sections: Section[] = [ export default function HomeComponent() { const router = useRouter(); - const totalItems = sections.length; - const columns = 3; - - const colSpanClasses: { [key: number]: string } = { - 2: 'md:col-span-2', - 3: 'md:col-span-3', - 6: 'md:col-span-6', - }; - return ( -
-
-

+
+
+

+ Interview preparation +

+

Any interview prep

+

+ Choose a topic to start preparing +

-
- {sections.map((section, index) => { - const rowNumber = Math.floor(index / columns); - const isLastRow = - rowNumber === Math.floor((totalItems - 1) / columns); - - const itemsOnLastRow = totalItems % columns; - const itemsOnThisRow = - isLastRow && itemsOnLastRow > 0 ? itemsOnLastRow : columns; - - const colSpan = Math.round(6 / itemsOnThisRow); - - 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 ( - - ); - })} +
+ {sections.map((section) => ( + + ))}
); diff --git a/src/app/frontend/junior/frontend-junior.tsx b/src/app/frontend/junior/frontend-junior.tsx index 86969bd..bce1b1b 100644 --- a/src/app/frontend/junior/frontend-junior.tsx +++ b/src/app/frontend/junior/frontend-junior.tsx @@ -39,65 +39,56 @@ const sections: Section[] = [ export default function FrontendJunior() { const router = useRouter(); - const totalItems = sections.length; - const columns = 3; - - const colSpanClasses: { [key: number]: string } = { - 2: 'md:col-span-2', - 3: 'md:col-span-3', - 6: 'md:col-span-6', - }; - return ( -
-
-

- Junior Frontend Developer Preparation +
+
+

+ Frontend +

+

+ Junior Developer Preparation

-

- (in development, 'in progress' parts are not completed) +

+ Topics marked as “in progress” are still being written

-
- {sections.map((section, index) => { - const rowNumber = Math.floor(index / columns); - const isLastRow = - rowNumber === Math.floor((totalItems - 1) / columns); - - const itemsOnLastRow = totalItems % columns; - const itemsOnThisRow = - isLastRow && itemsOnLastRow > 0 ? itemsOnLastRow : columns; - const colSpan = Math.round(6 / itemsOnThisRow); - - 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 ( - - ); - })} + )} + + ))}
); diff --git a/src/app/globals.css b/src/app/globals.css index e1274dc..447a29a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,19 +2,24 @@ :root { /* Default to dark theme */ - --background: #0a0a0a; - --foreground: #ededed; + --background: #09090b; + --foreground: #f4f4f5; --accent: #eab308; /* yellow-500 */ + --accent-dim: #a16207; /* yellow-700 */ --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); + --border: rgba(255, 255, 255, 0.07); + --border-strong: rgba(255, 255, 255, 0.12); + --glass-bg: rgba(0, 0, 0, 0.12); + --glass-strong-bg: rgba(0, 0, 0, 0.28); --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 */ - scroll-padding-top: var(--page-header-height, 128px); + + /* Layout */ + --page-header-height: 64px; + --toc-width: 260px; + + /* Offset built-in anchor navigation for sticky header */ + scroll-padding-top: calc(var(--page-header-height) + 24px); /* Ambient background hues */ --ambient-1: 255 90 31; /* warm orange */ @@ -37,7 +42,7 @@ 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) + 24px); } @theme inline { @@ -50,13 +55,14 @@ h4[id] { /* Respect system light preference while favoring dark by default */ @media (prefers-color-scheme: light) { :root { - --background: #ffffff; - --foreground: #171717; + --background: #fafafa; + --foreground: #18181b; --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); + --border: rgba(0, 0, 0, 0.06); + --border-strong: rgba(0, 0, 0, 0.12); + --glass-bg: rgba(255, 255, 255, 0.55); + --glass-strong-bg: rgba(255, 255, 255, 0.7); } } @@ -87,12 +93,10 @@ body::after { background: radial-gradient( 60% 60% at 50% 0%, - rgba(255, 255, 255, 0.03), + rgba(255, 255, 255, 0.02), transparent 70% - ), - radial-gradient(80% 50% at 50% 100%, rgba(0, 0, 0, 0.25), transparent 70%); + ), radial-gradient(80% 50% at 50% 100%, rgba(0, 0, 0, 0.3), transparent 70%); pointer-events: none; - transition: filter 0.28s ease-in-out; } /* Accessible focus styles */ @@ -113,7 +117,7 @@ a:focus-visible, .glass-strong { background: var(--glass-strong-bg); border-bottom: 1px solid var(--border); - backdrop-filter: blur(14px) saturate(160%); + backdrop-filter: blur(16px) saturate(160%); } /* Mobile: allow full viewport width */ @@ -123,8 +127,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 +152,26 @@ a:focus-visible, .scrollbar-hide::-webkit-scrollbar { display: none; } + +/* Thin scrollbar for TOC */ +.scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +} + +.scrollbar-thin::-webkit-scrollbar { + width: 4px; +} + +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; +} + +.scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} diff --git a/src/components/layout/content-page.tsx b/src/components/layout/content-page.tsx index 92cf0dd..c498379 100644 --- a/src/components/layout/content-page.tsx +++ b/src/components/layout/content-page.tsx @@ -20,13 +20,15 @@ export function ContentPage({ title={title} topicHome={topicHome} /> - - - {children} - +
+ + {children} + + +

); } diff --git a/src/components/layout/page-container.tsx b/src/components/layout/page-container.tsx index b7ca7df..7386c9d 100644 --- a/src/components/layout/page-container.tsx +++ b/src/components/layout/page-container.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { WidthSwitcher } from '@/components/width-switcher'; import type { PageContainerProps, WidthPreset } from '@/types'; @@ -30,26 +30,6 @@ export function PageContainer({ return initialWidth; }); - const [headerHeight, setHeaderHeight] = useState(120); - - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - const header = document.getElementById('page-header'); - if (!header) { - return; - } - - const update = () => setHeaderHeight(header.getBoundingClientRect().height); - update(); - const observer = new ResizeObserver(update); - observer.observe(header); - return () => { - observer.disconnect(); - }; - }, []); - const applyWidthPreference = (next: WidthPreset) => { setWidth(next); try { @@ -89,23 +69,23 @@ export function PageContainer({ const containerClasses = useMemo(() => { return [ - 'content-container mx-0 sm:mx-auto px-4 py-8', + 'content-container w-full px-4 sm:px-6 py-8', 'transition-[max-width] duration-200 ease-in-out', className, ].join(' '); }, [className]); return ( -
- {allowWidthToggle && ( - - )} - -
{children}
-
+
+
+ {allowWidthToggle && ( + + )} + {children} +
+
); } diff --git a/src/components/notes-area/notes-area.tsx b/src/components/notes-area/notes-area.tsx index 6644a05..aad8c34 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..ab4bd91 100644 --- a/src/components/page-header/page-header.tsx +++ b/src/components/page-header/page-header.tsx @@ -1,9 +1,7 @@ 'use client'; -import { AnimatePresence, motion } from 'framer-motion'; import { BookOpenCheck, House } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useRef, useState } from 'react'; import type { PageHeaderProps } from '@/types'; export const PageHeader = ({ @@ -11,258 +9,47 @@ export const PageHeader = ({ title, topicHome, }: PageHeaderProps) => { - type MediaQueryListWithLegacy = MediaQueryList & { - addListener: (listener: (e: MediaQueryListEvent) => void) => void; - removeListener: (listener: (e: MediaQueryListEvent) => void) => void; - }; - const router = useRouter(); - const [isScrolled, setIsScrolled] = useState(false); - const [isInitialLoad, setIsInitialLoad] = useState(true); - const lastScrollY = useRef(0); - const [isHiddenOnMobile, setIsHiddenOnMobile] = useState(false); - const [isMobileScreen, setIsMobileScreen] = useState(false); - const isMobile = useRef(false); - const downwardAccumPx = useRef(0); - const upwardAccumPx = useRef(0); - 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; - - useEffect(() => { - isHiddenOnMobileRef.current = isHiddenOnMobile; - }, [isHiddenOnMobile]); - - const resetNonMobileState = useCallback(() => { - downwardAccumPx.current = 0; - upwardAccumPx.current = 0; - if (isHiddenOnMobileRef.current) setIsHiddenOnMobile(false); - }, []); - - const accumulateDown = useCallback((delta: number) => { - downwardAccumPx.current += delta; - upwardAccumPx.current = 0; - }, []); - - const accumulateUp = useCallback((delta: number) => { - upwardAccumPx.current += -delta; - downwardAccumPx.current = 0; - }, []); - - const maybeHideOnMobile = useCallback((currentScrollY: number) => { - if ( - !isHiddenOnMobileRef.current && - currentScrollY > 16 && - downwardAccumPx.current > HIDE_THRESHOLD_PX - ) { - setIsHiddenOnMobile(true); - } - }, []); - - const maybeShowOnMobile = useCallback(() => { - if ( - isHiddenOnMobileRef.current && - upwardAccumPx.current > SHOW_THRESHOLD_PX - ) { - setIsHiddenOnMobile(false); - } - }, []); - - const maybeResetAtTop = useCallback((currentScrollY: number) => { - if (currentScrollY < 2 && isHiddenOnMobileRef.current) { - setIsHiddenOnMobile(false); - } - }, []); - - const handleMobileScroll = useCallback( - (currentScrollY: number, delta: number) => { - if (!isMobile.current) { - resetNonMobileState(); - return; - } - - const absDelta = Math.abs(delta); - if (absDelta <= JITTER_PX) { - maybeResetAtTop(currentScrollY); - return; - } - - if (delta > 0) { - accumulateDown(delta); - maybeHideOnMobile(currentScrollY); - } else if (delta < 0) { - accumulateUp(delta); - maybeShowOnMobile(); - } - - maybeResetAtTop(currentScrollY); - }, - [ - accumulateDown, - accumulateUp, - maybeHideOnMobile, - maybeResetAtTop, - maybeShowOnMobile, - resetNonMobileState, - ] - ); - - // 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)'); - const setMobileFlag = () => { - isMobile.current = mq.matches; - setIsMobileScreen(mq.matches); - }; - setMobileFlag(); - const hasModernListener = typeof mq.addEventListener === 'function'; - if (hasModernListener) { - mq.addEventListener('change', setMobileFlag); - } else { - (mq as MediaQueryListWithLegacy).addListener(setMobileFlag); - } - return () => { - if (hasModernListener) { - mq.removeEventListener('change', setMobileFlag); - } else { - (mq as MediaQueryListWithLegacy).removeListener(setMobileFlag); - } - }; - }, []); - - // Scroll listener with rAF batching - useEffect(() => { - if (typeof window === 'undefined') return; - let rafId: number | null = null; - const onScroll = () => { - if (rafId !== null) return; - rafId = window.requestAnimationFrame(() => { - 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); - } - handleMobileScroll(currentScrollY, delta); - lastScrollY.current = currentScrollY; - }); - }; - - setIsInitialLoad(false); - onScroll(); - window.addEventListener('scroll', onScroll, { passive: true }); - return () => { - window.removeEventListener('scroll', onScroll); - if (rafId !== null) cancelAnimationFrame(rafId); - }; - }, [handleMobileScroll]); - - // Expose current header height as a CSS variable for other components - useEffect(() => { - if (typeof document === 'undefined') { - return; - } - const expandedHeight = '128px'; - const collapsedHeight = '72px'; - let current: string; - 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; - } else { - current = collapsedHeight; - } - if (lastCssHeaderHeight.current !== current) { - document.documentElement.style.setProperty( - '--page-header-height', - current - ); - lastCssHeaderHeight.current = current; - } - }, [isInitialLoad, isScrolled, isHiddenOnMobile, isMobileScreen]); return ( - { - if (isHiddenOnMobile) return '0px'; - if (isMobileScreen) return '128px'; - if (isInitialLoad || !isScrolled) return '128px'; - return '72px'; - })(), - }} + ); }; diff --git a/src/components/section-card/section-card.tsx b/src/components/section-card/section-card.tsx index 26e0617..f360873 100644 --- a/src/components/section-card/section-card.tsx +++ b/src/components/section-card/section-card.tsx @@ -5,16 +5,19 @@ export const SectionCard = ({ title, children }: SectionCardProps) => { const id = slugify(title); return (

+ {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..cd63c02 100644 --- a/src/components/table-of-contents/table-of-contents.tsx +++ b/src/components/table-of-contents/table-of-contents.tsx @@ -1,48 +1,35 @@ 'use client'; -import { motion } from 'framer-motion'; -import { Pin, PinOff } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { List, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { TocHeading, TocItem } from '@/types'; export const TableOfContents = () => { const [items, setItems] = useState([]); - const [open, setOpen] = useState(false); - const [pinned, setPinned] = useState(false); - const [headerHeight, setHeaderHeight] = useState(0); - const [isLoaded, setIsLoaded] = useState(false); - const touchStartRef = useRef(null); - const openRef = useRef(open); - const pinnedRef = useRef(pinned); + const [activeId, setActiveId] = useState(''); + const [mobileOpen, setMobileOpen] = useState(false); + const observerRef = useRef(null); + // Build TOC from DOM 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 updateTOC = () => { const headings = Array.from( document.querySelectorAll('h2[id], h3[id], h4[id]') ); const mapped: TocItem[] = []; - for (const el of headings) { - const item = createTocItem(el); + const item: TocHeading = { + id: el.id, + text: el.textContent || '', + level: getHeadingLevel(el.tagName), + }; if (item.level === 2) { mapped.push({ id: item.id, text: item.text, children: [] }); @@ -54,276 +41,177 @@ export const TableOfContents = () => { } } setItems(mapped); - 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 = () => - setHeaderHeight(header.getBoundingClientRect().height); - updateHeight(); - resizeObserver = new ResizeObserver(updateHeight); - resizeObserver.observe(header); - } - - return () => { - clearTimeout(timeoutId); - if (resizeObserver) { - resizeObserver.disconnect(); - } - }; + return () => clearTimeout(timeoutId); }, []); + // Track active section via IntersectionObserver useEffect(() => { - const onTouchStart = (e: TouchEvent) => { - if (window.innerWidth < 768) { - touchStartRef.current = e.touches[0].clientX; - } - }; - const onTouchEnd = (e: TouchEvent) => { - if (window.innerWidth < 768 && touchStartRef.current !== null) { - const diff = e.changedTouches[0].clientX - touchStartRef.current; - if (touchStartRef.current < 30 && diff > 40) { - setOpen(true); - } - } - touchStartRef.current = null; - }; - window.addEventListener('touchstart', onTouchStart, { passive: true }); - window.addEventListener('touchend', onTouchEnd); - return () => { - window.removeEventListener('touchstart', onTouchStart); - window.removeEventListener('touchend', onTouchEnd); - }; - }, []); + if (items.length === 0) return; - // 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) { - setOpen(true); - } - }; - window.addEventListener('touchstart', onTouchTap, { passive: true }); - return () => { - window.removeEventListener('touchstart', onTouchTap); - }; - }, []); + const allIds = items.flatMap((item) => [ + item.id, + ...item.children.map((c) => c.id), + ]); - const handleMouseLeave = () => { - if (!pinned) { - setOpen(false); - } - }; + const elements = allIds + .map((id) => document.getElementById(id)) + .filter(Boolean) as HTMLElement[]; - const handleLinkClick = (e: React.MouseEvent) => { - if (!pinned) { - setOpen(false); - } + if (elements.length === 0) return; - // 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); - } - } - } - }; - - const handleTriggerClick = () => { - setOpen(!open); - }; + // Track which headings are visible + const visibleSet = new Set(); - // Handle click outside on mobile and always unpin when mobile - useEffect(() => { - openRef.current = open; - pinnedRef.current = pinned; - }, [open, pinned]); + observerRef.current = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + visibleSet.add(entry.target.id); + } else { + visibleSet.delete(entry.target.id); + } + } - 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; + // Pick the first visible heading in document order + for (const el of elements) { + if (visibleSet.has(el.id)) { + setActiveId(el.id); + return; + } + } + }, + { + rootMargin: '-80px 0px -60% 0px', + threshold: 0, } - const target = event.target as Element; - const tocNav = document.querySelector('nav[style*="top:"]'); - const tocContent = tocNav?.querySelector('.scrollbar-hide'); + ); - if (tocContent && !tocContent.contains(target)) { - setOpen(false); - } - }; + for (const el of elements) { + observerRef.current.observe(el); + } - document.addEventListener('mousedown', handleClickOutside); return () => { - document.removeEventListener('mousedown', handleClickOutside); + observerRef.current?.disconnect(); }; - }, []); + }, [items]); + + const handleLinkClick = useCallback( + (e: React.MouseEvent) => { + 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' }); + history.replaceState(null, '', href); + setActiveId(targetId); + } + } + setMobileOpen(false); + }, + [] + ); - // Don't render until content is loaded to prevent flickering - if (!isLoaded) { - return null; - } + if (items.length === 0) return null; + + const tocContent = ( +
    + {items.map((section) => { + const isSectionActive = activeId === section.id; + return ( +
  • + + {section.text} + + + {section.children.length > 0 && ( +
      + {section.children.map((child) => { + const isChildActive = activeId === child.id; + const isSubheader = child.level === 4; + + return ( +
    • + + {child.text} + +
    • + ); + })} +
    + )} +
  • + ); + })} +
+ ); return ( - + ); }; diff --git a/src/components/typography/callout.tsx b/src/components/typography/callout.tsx index bdc7fd0..c9a5148 100644 --- a/src/components/typography/callout.tsx +++ b/src/components/typography/callout.tsx @@ -2,10 +2,10 @@ import type { CalloutProps } from '@/types'; export const Callout = ({ children, className = '' }: CalloutProps) => { return ( -

{children} -

+
); }; diff --git a/src/components/typography/code-span.tsx b/src/components/typography/code-span.tsx index 13778e9..d859595 100644 --- a/src/components/typography/code-span.tsx +++ b/src/components/typography/code-span.tsx @@ -5,11 +5,11 @@ export const CodeSpan = ({ className = '', size = 'normal', }: CodeSpanProps) => { - const sizeClass = size === 'small' ? 'text-sm' : ''; + const sizeClass = size === 'small' ? 'text-[12px]' : 'text-[13px]'; return ( {children} diff --git a/src/components/typography/header.tsx b/src/components/typography/header.tsx index 2d7e70b..680a58e 100644 --- a/src/components/typography/header.tsx +++ b/src/components/typography/header.tsx @@ -6,10 +6,10 @@ export const Header = ({ children, className = '', id }: HeaderProps) => { const headerId = id || slugify(text); return (

- {children} + {children}

); }; diff --git a/src/components/typography/subheader.tsx b/src/components/typography/subheader.tsx index 0dc75bf..f90aef7 100644 --- a/src/components/typography/subheader.tsx +++ b/src/components/typography/subheader.tsx @@ -7,7 +7,7 @@ export const Subheader = ({ children, className = '', id }: SubheaderProps) => { return (

{children} diff --git a/src/components/typography/text.tsx b/src/components/typography/text.tsx index f7a06bc..23e0a56 100644 --- a/src/components/typography/text.tsx +++ b/src/components/typography/text.tsx @@ -5,7 +5,13 @@ export const Text = ({ className = '', variant = 'default', }: TextProps) => { - const variantClass = variant === 'muted' ? 'text-zinc-300' : 'text-white'; + const variantClass = variant === 'muted' ? 'text-zinc-400' : 'text-zinc-200'; - return

{children}

; + return ( +

+ {children} +

+ ); }; diff --git a/src/components/width-switcher/width-switcher.tsx b/src/components/width-switcher/width-switcher.tsx index d478462..c74aa2b 100644 --- a/src/components/width-switcher/width-switcher.tsx +++ b/src/components/width-switcher/width-switcher.tsx @@ -1,61 +1,44 @@ 'use client'; -import { useEffect, useState } from 'react'; import type { WidthPreset } from '@/types'; type WidthSwitcherProps = { currentWidth: WidthPreset; onChangeWidth: (next: WidthPreset) => void; - headerHeightFallback?: number; }; +const presets: { value: WidthPreset; label: string }[] = [ + { value: 'narrow', label: 'S' }, + { value: 'comfortable', label: 'M' }, + { value: 'wide', label: 'L' }, + { value: 'full', label: 'XL' }, +]; + export function WidthSwitcher({ currentWidth, onChangeWidth, - headerHeightFallback = 120, }: WidthSwitcherProps) { - // Track live header height via ResizeObserver as a fallback when the CSS var is not yet set - const [headerHeight, setHeaderHeight] = - useState(headerHeightFallback); - - useEffect(() => { - if (typeof window === 'undefined') return; - const header = document.getElementById('page-header'); - if (!header) return; - const update = () => setHeaderHeight(header.getBoundingClientRect().height); - update(); - const observer = new ResizeObserver(update); - observer.observe(header); - return () => observer.disconnect(); - }, []); - return ( -
-
- {(['narrow', 'comfortable', 'wide', 'full'] as WidthPreset[]).map( - (preset) => ( - - ) - )} +
+ + Width + +
+ {presets.map((preset) => ( + + ))}
);