|
|
|
import NextLink from "next/link";
|
|
|
|
import React, {
|
|
|
|
ReactNode,
|
|
|
|
ComponentType,
|
|
|
|
useState,
|
|
|
|
useRef,
|
|
|
|
useEffect,
|
|
|
|
useCallback,
|
|
|
|
} from "react";
|
|
|
|
|
|
|
|
import styles from "./OrganizedContent.module.css";
|
|
|
|
|
|
|
|
type Link = ComponentType<LinkProps>;
|
|
|
|
|
|
|
|
interface Section {
|
|
|
|
id: string;
|
|
|
|
title: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
const READ_ALL_TITLE = "Read All";
|
|
|
|
export const READ_ALL_ID = "read-all";
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
sections: Section[];
|
|
|
|
id: string;
|
|
|
|
children: ReactNode;
|
|
|
|
pageTitle: string;
|
|
|
|
link: Link;
|
|
|
|
numberedSections?: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function OrganizedContent({
|
|
|
|
sections,
|
|
|
|
id,
|
|
|
|
children,
|
|
|
|
pageTitle,
|
|
|
|
link: Link,
|
|
|
|
numberedSections = false,
|
|
|
|
}: Props) {
|
|
|
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
|
|
|
const currentIndex = sections.findIndex(
|
|
|
|
({ id: sectionId }) => sectionId === id
|
|
|
|
);
|
|
|
|
|
|
|
|
if (currentIndex < 0) {
|
|
|
|
throw new Error(`Section with ID ${id} was not found`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const section = sections[currentIndex];
|
|
|
|
const isReadAll = section.id === READ_ALL_ID;
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const isVisible = useOnScreen(ref.current);
|
|
|
|
const burgerVisible = useBurger(isVisible);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
mobileNavOpen
|
|
|
|
? (document.body.style.overflow = "hidden")
|
|
|
|
: (document.body.style.overflow = "visible");
|
|
|
|
}, [mobileNavOpen]);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className={styles.wrapper} ref={ref}>
|
|
|
|
<div
|
|
|
|
className={
|
|
|
|
mobileNavOpen
|
|
|
|
? `${styles.navMobileBackground} ${styles.show}`
|
|
|
|
: styles.navMobileBackground
|
|
|
|
}
|
|
|
|
onClick={() => setMobileNavOpen(false)}
|
|
|
|
/>
|
|
|
|
<Nav
|
|
|
|
sections={sections}
|
|
|
|
currentIndex={currentIndex}
|
|
|
|
link={Link}
|
|
|
|
pageTitle={pageTitle}
|
|
|
|
numberedSections={numberedSections}
|
|
|
|
mobileNavOpen={mobileNavOpen}
|
|
|
|
setMobileNavOpen={setMobileNavOpen}
|
|
|
|
/>
|
|
|
|
<div className={styles.content}>
|
|
|
|
{isReadAll ? (
|
|
|
|
children
|
|
|
|
) : (
|
|
|
|
<>
|
|
|
|
<section>
|
|
|
|
<h1>
|
|
|
|
{numberedSections
|
|
|
|
? `${currentIndex}. ${section.title}`
|
|
|
|
: section.title}
|
|
|
|
</h1>
|
|
|
|
{children}
|
|
|
|
</section>
|
|
|
|
<Footer
|
|
|
|
sections={sections}
|
|
|
|
currentIndex={currentIndex}
|
|
|
|
link={Link}
|
|
|
|
/>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
<button
|
|
|
|
className={`${styles.burger} ${
|
|
|
|
burgerVisible ? styles.burgerVisible : ""
|
|
|
|
}`}
|
|
|
|
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
|
|
|
>
|
|
|
|
<Burger />
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
interface NavProps {
|
|
|
|
sections: Section[];
|
|
|
|
currentIndex: number;
|
|
|
|
link: Link;
|
|
|
|
pageTitle: string;
|
|
|
|
numberedSections: boolean;
|
|
|
|
mobileNavOpen: boolean;
|
|
|
|
setMobileNavOpen: (mobileNavOpen: boolean) => void;
|
|
|
|
}
|
|
|
|
|
|
|
|
function Nav({
|
|
|
|
sections,
|
|
|
|
currentIndex,
|
|
|
|
link: Link,
|
|
|
|
pageTitle,
|
|
|
|
numberedSections,
|
|
|
|
mobileNavOpen,
|
|
|
|
setMobileNavOpen,
|
|
|
|
}: NavProps) {
|
|
|
|
const navStyles = mobileNavOpen
|
|
|
|
? [styles.nav, styles.mobileNavOpen]
|
|
|
|
: [styles.nav];
|
|
|
|
|
|
|
|
return (
|
|
|
|
<nav
|
|
|
|
className={navStyles.join(" ")}
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
>
|
|
|
|
<h1 className={styles.mobileNavTitle}>{pageTitle}</h1>
|
|
|
|
{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 (
|
|
|
|
<div
|
|
|
|
onClick={() => {
|
|
|
|
setMobileNavOpen(false);
|
|
|
|
}}
|
|
|
|
key={section.id}
|
|
|
|
>
|
|
|
|
<Link className={classNames.join(" ")} id={section.id}>
|
|
|
|
<span className={styles.marker} />
|
|
|
|
<div>
|
|
|
|
{numberedSections && section.id !== READ_ALL_ID
|
|
|
|
? `${index}. ${section.title}`
|
|
|
|
: section.title}
|
|
|
|
</div>
|
|
|
|
</Link>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</nav>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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 (
|
|
|
|
<div className={styles.footer}>
|
|
|
|
{prevSection && (
|
|
|
|
<Link className={styles.previous} id={prevSection.id}>
|
|
|
|
<Arrow direction="left" />
|
|
|
|
<div>
|
|
|
|
<div>Previous</div>
|
|
|
|
<div className={styles.arrowHeading}>{prevSection.title}</div>
|
|
|
|
</div>
|
|
|
|
</Link>
|
|
|
|
)}
|
|
|
|
{nextSection && (
|
|
|
|
<Link className={styles.next} id={nextSection.id}>
|
|
|
|
<div>
|
|
|
|
<div>Next</div>
|
|
|
|
<div className={styles.arrowHeading}>{nextSection.title}</div>
|
|
|
|
</div>
|
|
|
|
<Arrow direction="right" />
|
|
|
|
</Link>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function useDebounce(func: () => void, delay = 300) {
|
|
|
|
const timerRef = useRef<number | undefined>(undefined);
|
|
|
|
return useCallback(() => {
|
|
|
|
if (timerRef.current != null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
timerRef.current = window.setTimeout(() => {
|
|
|
|
func();
|
|
|
|
timerRef.current = undefined;
|
|
|
|
}, delay);
|
|
|
|
}, [func, delay]);
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface SectionWithContent {
|
|
|
|
section: Section;
|
|
|
|
Content: ComponentType;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function createReadAllSection(
|
|
|
|
sections: Section[],
|
|
|
|
content: false,
|
|
|
|
numberedSections?: undefined
|
|
|
|
): Section;
|
|
|
|
export function createReadAllSection(
|
|
|
|
sections: SectionWithContent[],
|
|
|
|
content: true,
|
|
|
|
numberedSections: boolean
|
|
|
|
): SectionWithContent;
|
|
|
|
export function createReadAllSection(
|
|
|
|
sections: SectionWithContent[] | Section[],
|
|
|
|
content = true,
|
|
|
|
numberedSections?: boolean
|
|
|
|
): SectionWithContent | Section {
|
|
|
|
const readAllSection = {
|
|
|
|
id: READ_ALL_ID,
|
|
|
|
title: READ_ALL_TITLE,
|
|
|
|
};
|
|
|
|
|
|
|
|
return content
|
|
|
|
? {
|
|
|
|
section: readAllSection,
|
|
|
|
Content: function ReadAllContent() {
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
{(sections as SectionWithContent[]).map(
|
|
|
|
({ section: { id, title }, Content }, index) => (
|
|
|
|
<section key={id}>
|
|
|
|
<h1>
|
|
|
|
{numberedSections ? `${index + 1}. ${title}` : title}
|
|
|
|
</h1>
|
|
|
|
<Content />
|
|
|
|
</section>
|
|
|
|
)
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
},
|
|
|
|
}
|
|
|
|
: readAllSection;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface LinkProps {
|
|
|
|
className?: string;
|
|
|
|
id: string;
|
|
|
|
children: ReactNode;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function createLink(page: string) {
|
|
|
|
let base = page.startsWith("/") ? page : `/${page}`;
|
|
|
|
base = base.endsWith("/") ? base : `${base}/`;
|
|
|
|
|
|
|
|
return function Link({ className, id, children }: LinkProps) {
|
|
|
|
const href = id === READ_ALL_ID ? base : base + id;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<NextLink href={href}>
|
|
|
|
<a className={`${styles.link} ${className ?? ""}`}>{children}</a>
|
|
|
|
</NextLink>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function Arrow({ direction }: { direction: "left" | "right" }) {
|
|
|
|
return (
|
|
|
|
<svg
|
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
|
width="14"
|
|
|
|
height="9"
|
|
|
|
viewBox="0 0 14 9"
|
|
|
|
className={`${styles.arrow} ${
|
|
|
|
direction === "left" ? styles.prevArrow : styles.nextArrow
|
|
|
|
}`}
|
|
|
|
>
|
|
|
|
<path d="M6.24407 8.12713C6.64284 8.58759 7.35716 8.58759 7.75593 8.12713L13.3613 1.65465C13.9221 1.00701 13.4621 0 12.6053 0H1.39467C0.537918 0 0.0778675 1.00701 0.638743 1.65465L6.24407 8.12713Z" />
|
|
|
|
</svg>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function useOnScreen(element: HTMLDivElement | null) {
|
|
|
|
const [isIntersecting, setIntersecting] = useState(false);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const observer = new IntersectionObserver(([entry]) =>
|
|
|
|
setIntersecting(entry.isIntersecting)
|
|
|
|
);
|
|
|
|
|
|
|
|
if (element) {
|
|
|
|
observer.observe(element);
|
|
|
|
}
|
|
|
|
// Remove the observer as soon as the component is unmounted
|
|
|
|
return () => {
|
|
|
|
observer.disconnect();
|
|
|
|
};
|
|
|
|
}, [element]);
|
|
|
|
|
|
|
|
return isIntersecting;
|
|
|
|
}
|
|
|
|
|
|
|
|
function useBurger(componentIsVisible: boolean): boolean {
|
|
|
|
const [prevScrollPos, setPrevScrollPos] = useState(0);
|
|
|
|
const [burgerVisible, setBurgerVisible] = useState(true);
|
|
|
|
|
|
|
|
const handleScroll = useDebounce(() => {
|
|
|
|
// find current scroll position
|
|
|
|
const currentScrollPos = window.pageYOffset;
|
|
|
|
setBurgerVisible(
|
|
|
|
componentIsVisible &&
|
|
|
|
((prevScrollPos > currentScrollPos &&
|
|
|
|
prevScrollPos - currentScrollPos > 70) ||
|
|
|
|
currentScrollPos < 10)
|
|
|
|
);
|
|
|
|
|
|
|
|
// set state to new scroll position
|
|
|
|
setPrevScrollPos(currentScrollPos);
|
|
|
|
});
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
window.addEventListener("scroll", handleScroll);
|
|
|
|
|
|
|
|
return () => window.removeEventListener("scroll", handleScroll);
|
|
|
|
}, [handleScroll]);
|
|
|
|
|
|
|
|
return burgerVisible;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Inlining this svg because we want to fill in colors using css variables
|
|
|
|
function Burger() {
|
|
|
|
return (
|
|
|
|
<svg
|
|
|
|
width="30"
|
|
|
|
height="23"
|
|
|
|
viewBox="0 0 30 23"
|
|
|
|
fill="none"
|
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
|
>
|
|
|
|
<line
|
|
|
|
x1="28"
|
|
|
|
y1="2"
|
|
|
|
x2="2"
|
|
|
|
y2="2"
|
|
|
|
strokeWidth="4"
|
|
|
|
strokeLinecap="round"
|
|
|
|
strokeLinejoin="round"
|
|
|
|
/>
|
|
|
|
<line
|
|
|
|
x1="28"
|
|
|
|
y1="11.375"
|
|
|
|
x2="2"
|
|
|
|
y2="11.375"
|
|
|
|
strokeWidth="4"
|
|
|
|
strokeLinecap="round"
|
|
|
|
strokeLinejoin="round"
|
|
|
|
/>
|
|
|
|
<line
|
|
|
|
x1="28"
|
|
|
|
y1="20.75"
|
|
|
|
x2="2"
|
|
|
|
y2="20.75"
|
|
|
|
strokeWidth="4"
|
|
|
|
strokeLinecap="round"
|
|
|
|
strokeLinejoin="round"
|
|
|
|
/>
|
|
|
|
</svg>
|
|
|
|
);
|
|
|
|
}
|