diff --git a/components/Bookshelf.tsx b/components/Bookshelf.tsx new file mode 100644 index 0000000..032f253 --- /dev/null +++ b/components/Bookshelf.tsx @@ -0,0 +1,381 @@ +import { + Box, + Icon, + HStack, + Flex, + Heading, + Image, + Center, + useDimensions, + useBreakpointValue, +} from "@chakra-ui/react"; +import React from "react"; +import { Book } from "../lib/books"; +import { FaChevronLeft, FaChevronRight } from "react-icons/fa"; +import { useRouter } from "next/router"; + +interface BookshelfProps { + books: Book[]; +} + +export function Bookshelf({ books }: BookshelfProps) { + const router = useRouter(); + const [bookIndex, setBookIndex] = React.useState(-1); + const [scroll, setScroll] = React.useState(-200); + + const bookshelfRef = React.useRef(null); + const viewportRef = React.useRef(null); + const scrollRightRef = React.useRef(null); + const scrollLeftRef = React.useRef(null); + const viewportDimensions = useDimensions(viewportRef, true); + const [isScrolling, setIsScrolling] = React.useState(false); + const [booksInViewport, setBooksInViewport] = React.useState(0); + const scrollEvents = useBreakpointValue({ + base: { start: "touchstart", stop: "touchend" }, + sm: { start: "mouseenter", stop: "mouseleave" }, + }); + + const width = 41.5; + const height = 220; + + const spineWidth = `${width}px`; + const coverWidth = `${width * 4}px`; + const bookWidth = `${width * 5}px`; + const bookHeight = `${height}px`; + + const minScroll = 0; + const maxScroll = React.useMemo(() => { + return ( + (width + 12) * (books.length - booksInViewport) + + (bookIndex > -1 ? width * 4 : 0) + + 5 + ); + }, [bookIndex, books.length, booksInViewport]); + + const boundedScroll = (scrollX: number) => { + setScroll(Math.max(minScroll, Math.min(maxScroll, scrollX))); + }; + + const boundedRelativeScroll = React.useCallback( + (incrementX: number) => { + setScroll((_scroll) => + Math.max(minScroll, Math.min(maxScroll, _scroll + incrementX)) + ); + }, + [maxScroll] + ); + + React.useEffect(() => { + if (router.query.slug && router.query.slug.length > 0 && bookIndex === -1) { + const idx = books.findIndex((b) => + b.slug + .toLowerCase() + .includes((router.query.slug as string[])[0].toLowerCase()) + ); + setBookIndex(idx); + } + }, []); + + React.useEffect(() => { + if (bookIndex === -1) { + boundedRelativeScroll(0); + } else { + boundedScroll((bookIndex - (booksInViewport - 4.5) / 2) * (width + 11)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookIndex, boundedRelativeScroll]); + + React.useEffect(() => { + if (viewportDimensions) { + boundedRelativeScroll(0); + const numberOfBooks = viewportDimensions.contentBox.width / (width + 11); + setBooksInViewport(numberOfBooks); + } + }, [viewportDimensions, boundedRelativeScroll]); + + React.useEffect(() => { + if (!scrollEvents) { + return; + } + + // Create a copy of the scroll events to save for clean-up + // So it doesn't switch underneath causing us to clean-up the wrong listeners + const currentScrollEvents = { ...scrollEvents }; + + const currentScrollRightRef = scrollRightRef.current; + const currentScrollLeftRef = scrollLeftRef.current; + + let scrollInterval: NodeJS.Timeout | null = null; + + const setScrollRightInterval = () => { + setIsScrolling(true); + scrollInterval = setInterval(() => { + boundedRelativeScroll(3); + }, 10); + }; + + const setScrollLeftInterval = () => { + setIsScrolling(true); + scrollInterval = setInterval(() => { + boundedRelativeScroll(-3); + }, 10); + }; + + const clearScrollInterval = () => { + setIsScrolling(false); + if (scrollInterval) { + clearInterval(scrollInterval); + } + }; + + currentScrollRightRef!.addEventListener( + currentScrollEvents.start, + setScrollRightInterval + ); + currentScrollRightRef!.addEventListener( + currentScrollEvents.stop, + clearScrollInterval + ); + + currentScrollLeftRef!.addEventListener( + currentScrollEvents.start, + setScrollLeftInterval + ); + currentScrollLeftRef!.addEventListener( + currentScrollEvents.stop, + clearScrollInterval + ); + + return () => { + clearScrollInterval(); + + currentScrollRightRef!.removeEventListener( + currentScrollEvents.start, + setScrollRightInterval + ); + currentScrollRightRef!.removeEventListener( + currentScrollEvents.stop, + clearScrollInterval + ); + + currentScrollLeftRef!.removeEventListener( + currentScrollEvents.start, + setScrollLeftInterval + ); + currentScrollLeftRef!.removeEventListener( + currentScrollEvents.stop, + clearScrollInterval + ); + }; + }, [boundedRelativeScroll]); + + return ( + <> + + + + + + + + + + + + + minScroll ? "block" : "none"} + > +
+ +
+
+ + {books.map((book, index) => { + return ( + + ); + })} + + +
+ +
+
+
+ + ); +} diff --git a/components/Layout.tsx b/components/Layout.tsx new file mode 100644 index 0000000..8d31afc --- /dev/null +++ b/components/Layout.tsx @@ -0,0 +1,164 @@ +import { + Container, + VStack, + Text, + Flex, + Box, + HStack, + Menu, + MenuButton, + IconButton, + MenuList, + MenuItem, + Icon, + MenuGroup, + Link, +} from "@chakra-ui/react"; +import { useRouter } from "next/router"; +import { PropsWithChildren } from "react"; +import { FiMenu } from "react-icons/fi"; + +function Navigation({ + link, + children, + isExternal, +}: { + link: string; + children: string; + isExternal?: boolean; +}) { + const router = useRouter(); + const isActive = + link === "/" ? router.asPath === link : router.asPath.includes(link); + + return ( + + {children} + + ); +} + +function Layout({ children }: PropsWithChildren) { + return ( + + + + + + NAVIGATION + + Home + Reading + Writing + Deep Dives + + + + FIND ME ON + + + Twitter + + + Substack + + + GitHub + + + + + + + + + + + Home + Reading + Writing + + + } + variant="outline" + size="sm" + /> + + + + Home + Reading + Writing + Deep Dives + + + + + + Twitter + + + Substack + + + GitHub + + + + + + + + + {children} + + + ); +} + +export default Layout; diff --git a/lib/book.ts b/lib/books.ts similarity index 100% rename from lib/book.ts rename to lib/books.ts