diff --git a/app/components/page-toc.tsx b/app/components/page-toc.tsx new file mode 100644 index 0000000..2e6dccd --- /dev/null +++ b/app/components/page-toc.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import { usePathname } from "next/navigation"; +import { tocId } from "./toc-section"; + +function useIsClient() { + return useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); +} + +type TocEntry = { + id: string; + label: string; + level: number; +}; + +const EXCLUDED_PATHS = new Set(["/", "/playlists/map"]); +const SCROLL_OFFSET = 112; +/** Minimum clear pixels between page content and viewport right edge. */ +const TOC_MIN_RIGHT_GUTTER = 28; + +function findPageContentColumn(): HTMLElement | null { + return document.querySelector('main [class*="max-w-"]'); +} + +function hasRoomForToc(): boolean { + const content = findPageContentColumn(); + if (!content) return false; + const gutter = window.innerWidth - content.getBoundingClientRect().right; + return gutter >= TOC_MIN_RIGHT_GUTTER; +} + +function getLabelText(el: HTMLElement): string { + return el.textContent?.trim().replace(/\s+/g, " ") ?? ""; +} + +function resolveScrollTarget(labelEl: HTMLElement): HTMLElement | null { + const inset = labelEl.closest(".mag-card-inset"); + if (inset) return inset; + + const card = labelEl.closest(".mag-card"); + if (card) return card; + + const section = labelEl.closest("section"); + if (section) return section; + + const fadeUp = labelEl.closest(".fade-up"); + if (fadeUp) return fadeUp; + + return labelEl.parentElement; +} + +function ensureScrollTarget(el: HTMLElement, label: string): string { + if (el.id) { + el.style.scrollMarginTop = "5rem"; + return el.id; + } + + let candidate = tocId(label); + let suffix = 2; + while (document.getElementById(candidate) && document.getElementById(candidate) !== el) { + candidate = `${tocId(label)}-${suffix++}`; + } + + el.id = candidate; + el.style.scrollMarginTop = "5rem"; + return candidate; +} + +function isInsideExplicitToc(el: HTMLElement): boolean { + return Boolean(el.closest("[data-toc-item]")); +} + +function compareDocumentOrder(a: HTMLElement, b: HTMLElement): number { + if (a === b) return 0; + const position = a.compareDocumentPosition(b); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; +} + +function scanTocItems(): TocEntry[] { + const entries: { el: HTMLElement; label: string; level: number }[] = []; + const claimed = new Set(); + + const claim = (el: HTMLElement, label: string, level: number) => { + if (!label || claimed.has(el)) return; + claimed.add(el); + entries.push({ el, label, level }); + }; + + document.querySelectorAll("[data-toc-item]").forEach((el) => { + const label = el.getAttribute("data-toc-label") ?? ""; + const level = Number(el.getAttribute("data-toc-level") ?? 0); + if (label) claim(el, label, level); + }); + + document.querySelectorAll(".mag-label").forEach((labelEl) => { + const target = resolveScrollTarget(labelEl); + if (!target || isInsideExplicitToc(target)) return; + + const label = getLabelText(labelEl); + const level = labelEl.closest(".mag-card-inset") ? 1 : 0; + claim(target, label, level); + }); + + document.querySelectorAll(".mag-card h2, .mag-card h1").forEach((heading) => { + const target = heading.closest(".fade-up") ?? heading.closest(".mag-card"); + if (!target || isInsideExplicitToc(target) || claimed.has(target)) return; + + const label = getLabelText(heading); + claim(target, label, 0); + }); + + return entries + .sort((a, b) => compareDocumentOrder(a.el, b.el)) + .map(({ el, label, level }) => ({ + id: ensureScrollTarget(el, label), + label, + level, + })); +} + +export default function PageToc() { + const pathname = usePathname(); + const isClient = useIsClient(); + const [items, setItems] = useState([]); + const [activeId, setActiveId] = useState(null); + const [expanded, setExpanded] = useState(false); + const [hasRoom, setHasRoom] = useState(false); + + useEffect(() => { + const refresh = () => { + const next = scanTocItems(); + setItems(next); + setActiveId((prev) => { + if (prev && next.some((item) => item.id === prev)) return prev; + return next[0]?.id ?? null; + }); + }; + + refresh(); + const timer = window.setTimeout(refresh, 150); + return () => window.clearTimeout(timer); + }, [pathname]); + + useEffect(() => { + const updateRoom = () => { + setHasRoom(hasRoomForToc()); + }; + + updateRoom(); + const timer = window.setTimeout(updateRoom, 150); + window.addEventListener("resize", updateRoom); + + const content = findPageContentColumn(); + const observer = content ? new ResizeObserver(updateRoom) : null; + if (content) observer?.observe(content); + + return () => { + window.clearTimeout(timer); + window.removeEventListener("resize", updateRoom); + observer?.disconnect(); + }; + }, [pathname]); + + useEffect(() => { + if (items.length === 0) return; + + const onScroll = () => { + let current: string | null = items[0].id; + for (const { id } of items) { + const el = document.getElementById(id); + if (el && el.getBoundingClientRect().top <= SCROLL_OFFSET) { + current = id; + } + } + setActiveId(current); + }; + + const frame = requestAnimationFrame(onScroll); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + }; + }, [items]); + + const scrollTo = (id: string) => { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + if (EXCLUDED_PATHS.has(pathname) || items.length < 2 || !isClient || !hasRoom) { + return null; + } + + return createPortal( + , + document.body, + ); +} diff --git a/app/components/subpage-enter.tsx b/app/components/subpage-enter.tsx index cad283b..03ee4a3 100644 --- a/app/components/subpage-enter.tsx +++ b/app/components/subpage-enter.tsx @@ -1,6 +1,7 @@ "use client"; import { usePathname } from "next/navigation"; +import PageToc from "./page-toc"; export default function SubpageEnter({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -13,6 +14,7 @@ export default function SubpageEnter({ children }: { children: React.ReactNode } return (
{children}
+
); } diff --git a/app/components/toc-section.tsx b/app/components/toc-section.tsx new file mode 100644 index 0000000..9b50fb8 --- /dev/null +++ b/app/components/toc-section.tsx @@ -0,0 +1,43 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +export function tocId(label: string): string { + return label + .toLowerCase() + .replace(/['']/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +export type TocSectionProps = { + label: string; + level?: 0 | 1; + id?: string; + children: ReactNode; +} & Omit, "id">; + +/** Optional explicit scroll target — PageToc also auto-discovers `.mag-label` sections. */ +export function TocSection({ + label, + level = 0, + id, + className, + children, + style, + ...rest +}: TocSectionProps) { + const sectionId = id ?? tocId(label); + + return ( +
+ {children} +
+ ); +} diff --git a/app/globals.css b/app/globals.css index 509383b..d0b045c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -202,6 +202,163 @@ body { } .dark .mag-label::after { background: var(--color-border-secondary); } +/* Floating page TOC — Notion-style rail + hover panel */ +.page-toc { + position: fixed; + right: 1.25rem; + top: 50%; + transform: translateY(-50%); + z-index: 30; + display: none; +} +.page-toc.page-toc--visible { + display: block; +} +.page-toc-rail { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + padding: 0.35rem 0; + cursor: default; + opacity: 1; + visibility: visible; + transition: + opacity 120ms ease, + visibility 120ms ease; + transition-delay: 140ms; +} +.page-toc:has(.page-toc-panel--open) .page-toc-rail { + opacity: 0; + visibility: hidden; + transition-delay: 0ms; +} +.page-toc-line { + display: block; + height: 2px; + border-radius: 9999px; + background: #d4d4d8; + transition: background-color 0.15s ease, width 0.15s ease; +} +.dark .page-toc-line { + background: #52525b; +} +.page-toc-line--l0 { + width: 0.875rem; +} +.page-toc-line--l1 { + width: 0.5rem; +} +.page-toc-line--active { + background: #3f3f46; +} +.dark .page-toc-line--active { + background: #e4e4e7; +} +.page-toc-panel { + position: absolute; + right: -0.75rem; + top: 50%; + z-index: 2; + transform: translateY(-50%) translateX(calc(100% + 0.5rem)); + min-width: 11rem; + max-width: 13.5rem; + padding: 0.45rem 0.55rem 0.45rem 0.35rem; + border-radius: 10px; + border: 1px solid var(--color-border-secondary); + background: #ffffff; + box-shadow: + 0 10px 30px rgba(24, 24, 27, 0.08), + 0 2px 8px rgba(24, 24, 27, 0.04); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + transform 240ms cubic-bezier(0.16, 1, 0.3, 1), + opacity 200ms cubic-bezier(0.16, 1, 0.3, 1), + visibility 200ms cubic-bezier(0.16, 1, 0.3, 1); + transition-delay: 0ms, 0ms, 0ms; +} +.dark .page-toc-panel { + background: #252019; + box-shadow: + 0 10px 30px rgba(0, 0, 0, 0.28), + 0 2px 8px rgba(0, 0, 0, 0.18); +} +.page-toc-panel--open { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(-50%) translateX(0); + transition-delay: 80ms, 80ms, 80ms; +} +.page-toc-list { + list-style: none; + margin: 0; + padding: 0; +} +.page-toc-item--l1 .page-toc-link { + padding-left: 1rem; +} +.page-toc-link { + display: block; + width: 100%; + border: 0; + background: transparent; + text-align: left; + font-family: var(--font-ui-en); + font-size: 0.72rem; + line-height: 1.35; + font-weight: 500; + color: #71717a; + padding: 0.3rem 0.55rem; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background-color 0.12s ease, color 0.12s ease; +} +.dark .page-toc-link { + color: #a1a1aa; +} +.page-toc-link:hover { + background: #f4f4f5; + color: #3f3f46; +} +.dark .page-toc-link:hover { + background: #3f3f46; + color: #e4e4e7; +} +.page-toc-link--active { + color: #C4894F; +} +.dark .page-toc-link--active { + color: #D9A870; +} +.page-toc-link--active:hover { + color: #C4894F; + background: #faf5f0; +} +.dark .page-toc-link--active:hover { + color: #D9A870; + background: #322820; +} +@media (prefers-reduced-motion: reduce) { + .page-toc-rail { + transition: opacity 80ms ease; + } + .page-toc-panel { + transform: translateY(-50%); + transition: opacity 80ms ease, visibility 80ms ease; + transition-delay: 0ms; + } + .page-toc-panel--open { + transform: translateY(-50%); + transition-delay: 0ms; + } +} + /* Nav links — pill-shaped active underline */ .nav-link { position: relative;