import React, { ReactNode, ComponentType, useState, useRef, useEffect, } from "react"; import styles from "./OrganizedContent.module.css"; export interface LinkProps { className?: string; id: string; children: ReactNode; } type Link = ComponentType; interface Section { id: string; title: string; Content: ComponentType; } const READ_ALL_TITLE = "Read All"; export const READ_ALL_ID = "read-all"; interface Props { sections: Section[]; currentId: string; pageTitle: string; link: Link; } export function OrganizedContent(props: Props) { const sections = createSections(props.sections); const currentIndex = sections.findIndex(({ id }) => id === props.currentId); const [open, setOpen] = useState(false); if (currentIndex < 0) { throw new Error(`Section with ID ${props.currentId} was not found`); } const section = sections[currentIndex]; const isReadAll = section.id === READ_ALL_ID; useEffect(() => { open ? (document.body.style.overflow = "hidden") : (document.body.style.overflow = "visible"); }, [open]); const ref = useRef(null); const isVisible = useOnScreen(ref); return (
); } interface NavProps { sections: Section[]; currentIndex: number; link: Link; pageTitle?: string; } function Nav({ sections, currentIndex, link: Link, pageTitle }: NavProps) { return (
{pageTitle &&

{pageTitle}

} {sections.map((section, index) => { const classNames = [styles.navItem]; if (index === currentIndex) { classNames.push(styles.selected); } if (section.id === READ_ALL_ID) { classNames.push(styles.readAll); } return (
{section.title}
); })}
); } interface FooterProps { sections: Section[]; currentIndex: number; link: Link; } function Footer({ sections, currentIndex, link: Link }: FooterProps) { const prevSection = currentIndex > 0 && sections[currentIndex - 1].id !== READ_ALL_ID ? sections[currentIndex - 1] : undefined; const nextSection = currentIndex < sections.length - 1 && sections[currentIndex + 1].id !== READ_ALL_ID ? sections[currentIndex + 1] : undefined; return (
{prevSection && (
Previous
{prevSection.title}
)} {nextSection && (
Next
{nextSection.title}
)}
); } interface MobileProps { open: boolean; setOpen: React.Dispatch>; sections: Section[]; currentIndex: number; link: Link; pageTitle: string; componentIsVisible: boolean; } function MobileWrapper(mobileProps: MobileProps) { const wrapperRef = useRef(null); useOutsideAlerter(wrapperRef, mobileProps.setOpen); return (
); } interface BurgerProps { open: boolean; setOpen: React.Dispatch>; componentIsVisible: boolean; } const Burger = ({ open, setOpen, componentIsVisible }: BurgerProps) => { const [prevScrollPos, setPrevScrollPos] = useState(0); const [burgerVisible, setBurgerVisible] = useState(true); const debouncedPrevScrollPos = useDebounce(prevScrollPos, 100); useEffect(() => { const handleScroll = () => { // find current scroll position const currentScrollPos = window.pageYOffset; // set state based on location info (explained in more detail below) setBurgerVisible( componentIsVisible && ((debouncedPrevScrollPos > currentScrollPos && debouncedPrevScrollPos - currentScrollPos > 70) || currentScrollPos < 10) ); // set state to new scroll position setPrevScrollPos(currentScrollPos); }; window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); }, [componentIsVisible, debouncedPrevScrollPos, burgerVisible]); return (
setOpen(!open)} >
); }; interface MenuProps { open: boolean; sections: Section[]; currentIndex: number; link: Link; pageTitle: string; } const Menu = ({ open, sections, currentIndex, link, pageTitle }: MenuProps) => { const mobileNav = open ? styles.mobileNav : styles.mobileNav + " " + styles.mobileNavClosed; return (
); }; function useOutsideAlerter( ref: React.RefObject, setOpen: React.Dispatch> ) { useEffect(() => { const handleClickOutside = (event: Event) => { if (ref.current && !ref.current.contains(event.target as Node)) { setOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [ref, setOpen]); } function useDebounce(value: T, delay?: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay || 500); return () => { clearTimeout(timer); }; }, [value, delay]); return debouncedValue; } function createSections(sections: Section[]) { return [ { id: READ_ALL_ID, title: READ_ALL_TITLE, Content() { return ( <> {sections.map(({ id, title, Content: SectionContent }) => (

{title}

))} ); }, }, ...sections, ]; } function Arrow({ direction }: { direction: "left" | "right" }) { return ( ); } function useOnScreen(ref: React.RefObject) { const [isIntersecting, setIntersecting] = useState(false); useEffect(() => { const observer = new IntersectionObserver(([entry]) => setIntersecting(entry.isIntersecting) ); if (ref.current) { observer.observe(ref.current); } // Remove the observer as soon as the component is unmounted return () => { observer.disconnect(); }; }, [ref]); return isIntersecting; }