diff --git a/src/components/TableOfContentsClient.tsx b/src/components/TableOfContentsClient.tsx index 4ba70f5..4d58bb3 100644 --- a/src/components/TableOfContentsClient.tsx +++ b/src/components/TableOfContentsClient.tsx @@ -1,11 +1,57 @@ -import { memo } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import type { MarkdownHeading } from '@/lib/markdown-headings'; interface Props { headings: MarkdownHeading[]; + activeGuideId?: string; } -const TableOfContentsClient = memo(({ headings }) => { +const TableOfContentsClient = memo(({ headings, activeGuideId }) => { + const [activeHeadingSlug, setActiveHeadingSlug] = useState(headings[0]?.slug ?? null); + + const headingSlugs = useMemo(() => headings.map((heading) => heading.slug), [headings]); + + useEffect(() => { + setActiveHeadingSlug(headings[0]?.slug ?? null); + }, [activeGuideId, headings]); + + useEffect(() => { + if (!headingSlugs.length) return; + if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') return; + + const safeGuideId = activeGuideId ? CSS.escape(activeGuideId) : null; + + const activeGuideArticle = safeGuideId + ? document.querySelector(`[data-guide-id="${safeGuideId}"]`) + : null; + + const headingElements = headingSlugs + .map((slug) => { + const safeSlug = CSS.escape(slug); + return activeGuideArticle?.querySelector(`[id="${safeSlug}"]`) ?? null; + }) + .filter((element): element is HTMLElement => Boolean(element)); + + if (!headingElements.length) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => b.intersectionRatio - a.intersectionRatio); + + if (visible.length) { + const headingId = visible[0].target.id; + if (headingId) setActiveHeadingSlug(headingId); + } + }, + { rootMargin: '-88px 0px -50% 0px', threshold: [0.1, 0.45, 1] } + ); + + headingElements.forEach((element) => observer.observe(element)); + return () => observer.disconnect(); + }, [headingSlugs, activeGuideId]); + if (!headings.length) return null; return ( @@ -14,7 +60,26 @@ const TableOfContentsClient = memo(({ headings }) => {
    {headings.map((heading) => (
  • - + { + if (!activeGuideId) return; + if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') return; + event.preventDefault(); + const safeGuideId = CSS.escape(activeGuideId); + const safeSlug = CSS.escape(heading.slug); + const activeGuideArticle = document.querySelector( + `[data-guide-id="${safeGuideId}"]` + ); + const target = activeGuideArticle?.querySelector(`[id="${safeSlug}"]`); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setActiveHeadingSlug(heading.slug); + }} + aria-current={activeHeadingSlug === heading.slug ? 'location' : undefined} + > {heading.text}
  • diff --git a/src/components/installation/InstallationSidebarClient.tsx b/src/components/installation/InstallationSidebarClient.tsx new file mode 100644 index 0000000..8a1207d --- /dev/null +++ b/src/components/installation/InstallationSidebarClient.tsx @@ -0,0 +1,124 @@ +import { memo, useEffect, useMemo, useState } from 'react'; +import TableOfContentsClient from '@/components/TableOfContentsClient'; +import type { MarkdownHeading } from '@/lib/markdown-headings'; + +interface InstallationGuideMeta { + id: string; + label: string; + href: string; + headings: MarkdownHeading[]; +} + +interface InstallationSidebarClientProps { + guides: InstallationGuideMeta[]; + initialGuideId: string; +} + +const InstallationSidebarClient = memo(({ guides, initialGuideId }) => { + const guideIds = useMemo(() => guides.map((guide) => guide.id), [guides]); + const [activeGuideId, setActiveGuideId] = useState(initialGuideId); + + const activeGuide = useMemo( + () => guides.find((guide) => guide.id === activeGuideId) ?? guides[0], + [activeGuideId, guides] + ); + + useEffect(() => { + const hashGuideId = window.location.hash.slice(1); + if (hashGuideId && guideIds.includes(hashGuideId)) { + setActiveGuideId(hashGuideId); + } + }, [guideIds]); + + useEffect(() => { + const updateVisibleGuide = () => { + const guideArticles = document.querySelectorAll('[data-guide-id]'); + guideArticles.forEach((article) => { + const isActive = article.dataset.guideId === activeGuideId; + article.classList.toggle('hidden', !isActive); + article.classList.toggle('flex', isActive); + article.setAttribute('aria-hidden', isActive ? 'false' : 'true'); + }); + }; + + updateVisibleGuide(); + }, [activeGuideId]); + + useEffect(() => { + const onHashChange = () => { + const hashGuideId = window.location.hash.slice(1); + if (hashGuideId && guideIds.includes(hashGuideId)) { + setActiveGuideId(hashGuideId); + } + }; + + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, [guideIds]); + + const onGuideChange = (guideId: string) => { + setActiveGuideId(guideId); + window.history.pushState(null, '', `#${guideId}`); + }; + + if (!guides.length || !activeGuide) return null; + + return ( +
    +
    + Apps +
    + +
    + + +
    + + + +
    + +
    +
    + ); +}); + +InstallationSidebarClient.displayName = 'InstallationSidebarClient'; + +export default InstallationSidebarClient; diff --git a/src/components/installation/ShowcaseMediaFrame.tsx b/src/components/installation/ShowcaseMediaFrame.tsx index 9535fb5..6cbc5fb 100644 --- a/src/components/installation/ShowcaseMediaFrame.tsx +++ b/src/components/installation/ShowcaseMediaFrame.tsx @@ -35,6 +35,7 @@ const ShowcaseMediaFrame = memo(({ src, alt, ratio, mob alt={alt} style={{ width: '100%', height: '100%', display: 'block', objectFit: 'cover' }} loading="lazy" + decoding="async" /> ); @@ -82,6 +83,7 @@ const ShowcaseMediaFrame = memo(({ src, alt, ratio, mob objectPosition: MOBILE_IMAGE_OBJECT_POSITION, }} loading="lazy" + decoding="async" /> diff --git a/src/pages/installation.astro b/src/pages/installation.astro index 1a8e87b..93366aa 100644 --- a/src/pages/installation.astro +++ b/src/pages/installation.astro @@ -4,7 +4,7 @@ import BaseLayout from '@/layouts/BaseLayout.astro'; import PageSEO from '@/components/seo/PageSEO.astro'; import MainNavbar from '@/components/shared/MainNavbar'; import ShowcaseViewer from '@/components/installation/ShowcaseViewer'; -import TableOfContentsClient from '@/components/TableOfContentsClient'; +import InstallationSidebarClient from '@/components/installation/InstallationSidebarClient'; import { AmberTag } from '@/components/shared/primitives'; import { extractMarkdownHeadings } from '@/lib/markdown-headings'; import '@/styles/global.css'; @@ -36,6 +36,7 @@ const sidebarLinks = preparedGuides.map((g) => ({ id: g.guide.id, label: g.name, href: `#${g.guide.id}`, + headings: g.headings, })); --- @@ -47,9 +48,9 @@ const sidebarLinks = preparedGuides.map((g) => ({ > -
    +
    ({
    -
    +
    -
    +
    -

    +

    Installation

    -

    +

    Setup Posterium in Plex, Emby, Jellyfin, Stremio, Kodi, and more.

    @@ -113,12 +88,13 @@ const sidebarLinks = preparedGuides.map((g) => ({
    -

    +

    {name}

    @@ -131,13 +107,8 @@ const sidebarLinks = preparedGuides.map((g) => ({ /> -
    -

    +
    +

    Guide

    @@ -149,50 +120,15 @@ const sidebarLinks = preparedGuides.map((g) => ({
    -