mirror of
https://github.com/andwati/personal-website.git
synced 2026-10-18 02:32:54 +00:00
Add Bookshelf component for displaying book collection with navigation
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollRightRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollLeftRef = React.useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<svg
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
visibility: "hidden",
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<filter id="paper" x="0%" y="0%" width="100%" height="100%">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.9"
|
||||
numOctaves="8"
|
||||
result="noise"
|
||||
/>
|
||||
<feDiffuseLighting
|
||||
in="noise"
|
||||
lightingColor="white"
|
||||
surfaceScale="1"
|
||||
result="diffLight"
|
||||
>
|
||||
<feDistantLight azimuth="45" elevation="35" />
|
||||
</feDiffuseLighting>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<Box position="relative" ref={bookshelfRef}>
|
||||
<Box
|
||||
position="absolute"
|
||||
left={{ base: "-28px", md: "-36px" }}
|
||||
height="100%"
|
||||
display={scroll > minScroll ? "block" : "none"}
|
||||
>
|
||||
<Center
|
||||
ref={scrollLeftRef}
|
||||
borderRadius="md"
|
||||
height="100%"
|
||||
width="28px"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
borderRightRadius={{ base: 0, md: undefined }}
|
||||
>
|
||||
<Icon as={FaChevronLeft} boxSize={3} />
|
||||
</Center>
|
||||
</Box>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
overflowX="hidden"
|
||||
cursor="grab"
|
||||
ref={viewportRef}
|
||||
>
|
||||
{books.map((book, index) => {
|
||||
return (
|
||||
<button
|
||||
key={book.title}
|
||||
onClick={() => {
|
||||
if (index === bookIndex) {
|
||||
setBookIndex(-1);
|
||||
router.push(`/reading`);
|
||||
} else {
|
||||
setBookIndex(index);
|
||||
router.push(book.slug);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
outline: "none",
|
||||
flexShrink: 0,
|
||||
transform: `translateX(-${scroll}px)`,
|
||||
width: bookIndex === index ? bookWidth : spineWidth,
|
||||
perspective: "1000px",
|
||||
WebkitPerspective: "1000px",
|
||||
gap: "0px",
|
||||
transition: isScrolling
|
||||
? `transform 100ms linear`
|
||||
: `all 500ms ease`,
|
||||
willChange: "auto",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
alignItems="flex-start"
|
||||
justifyContent="center"
|
||||
width={spineWidth}
|
||||
height={bookHeight}
|
||||
flexShrink={0}
|
||||
transformOrigin="right"
|
||||
backgroundColor={book.spineColor}
|
||||
color={book.textColor}
|
||||
transform={`translate3d(0px, 0px, 0px) scale3d(1, 1, 1) rotateX(0deg) rotateY(${
|
||||
bookIndex === index ? "-60deg" : "0deg"
|
||||
}) rotateZ(0deg) skew(0deg, 0deg)`}
|
||||
transition={"all 500ms ease"}
|
||||
willChange="auto"
|
||||
filter="brightness(0.8) contrast(2)"
|
||||
style={{
|
||||
transformStyle: "preserve-3d",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 50,
|
||||
height: bookHeight,
|
||||
width: spineWidth,
|
||||
opacity: 0.4,
|
||||
filter: "url(#paper)",
|
||||
}}
|
||||
/>
|
||||
<Heading
|
||||
mt="12px"
|
||||
as="h2"
|
||||
fontSize="xs"
|
||||
fontFamily={`"DM Sans", sans-serif`}
|
||||
style={{ writingMode: "vertical-rl" }}
|
||||
userSelect="none"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
maxHeight={`${height - 24}px`}
|
||||
>
|
||||
{book.title}
|
||||
</Heading>
|
||||
</Flex>
|
||||
<Box
|
||||
position="relative"
|
||||
flexShrink={0}
|
||||
overflow="hidden"
|
||||
transformOrigin="left"
|
||||
transform={`translate3d(0px, 0px, 0px) scale3d(1, 1, 1) rotateX(0deg) rotateY(${
|
||||
bookIndex === index ? "30deg" : "88.8deg"
|
||||
}) rotateZ(0deg) skew(0deg, 0deg)`}
|
||||
transition={"all 500ms ease"}
|
||||
willChange="auto"
|
||||
filter="brightness(0.8) contrast(2)"
|
||||
style={{
|
||||
transformStyle: "preserve-3d",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
height: bookHeight,
|
||||
width: coverWidth,
|
||||
opacity: 0.4,
|
||||
filter: "url(#paper)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 50,
|
||||
height: bookHeight,
|
||||
width: coverWidth,
|
||||
background: `linear-gradient(to right, rgba(255, 255, 255, 0) 2px, rgba(255, 255, 255, 0.5) 3px, rgba(255, 255, 255, 0.25) 4px, rgba(255, 255, 255, 0.25) 6px, transparent 7px, transparent 9px, rgba(255, 255, 255, 0.25) 9px, transparent 12px)`,
|
||||
}}
|
||||
/>
|
||||
<Image
|
||||
src={book.coverImage}
|
||||
alt={book.title}
|
||||
width={coverWidth}
|
||||
height={bookHeight}
|
||||
style={{
|
||||
transition: "all 500ms ease",
|
||||
willChange: "auto",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
<Box
|
||||
position="absolute"
|
||||
right={{ base: "-28px", md: "-36px" }}
|
||||
pl="10px"
|
||||
height="100%"
|
||||
top={0}
|
||||
display={scroll < maxScroll ? "block" : "none"}
|
||||
>
|
||||
<Center
|
||||
borderLeftRadius={{ base: 0, md: undefined }}
|
||||
ref={scrollRightRef}
|
||||
height="100%"
|
||||
borderRadius="md"
|
||||
width="28px"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
>
|
||||
<Icon as={FaChevronRight} boxSize={3} />
|
||||
</Center>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Link
|
||||
href={link}
|
||||
target={isExternal ? "_blank" : "_self"}
|
||||
color={isActive ? "black" : "gray.500"}
|
||||
_hover={{ color: "black" }}
|
||||
>
|
||||
<Text fontSize="lg">{children}</Text>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Layout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Container
|
||||
position="relative"
|
||||
mt={{ base: 16 }}
|
||||
pb={{ base: 8, md: "10em" }}
|
||||
gap={{ md: 10 }}
|
||||
>
|
||||
<Flex
|
||||
position="absolute"
|
||||
right="100%"
|
||||
mr="160px"
|
||||
display={{ base: "none", lg: "flex" }}
|
||||
>
|
||||
<VStack position="fixed" align="flex-start" spacing={10}>
|
||||
<VStack align="flex-start">
|
||||
<Text fontWeight="bold" fontSize="smaller">
|
||||
NAVIGATION
|
||||
</Text>
|
||||
<Navigation link="/">Home</Navigation>
|
||||
<Navigation link="/reading">Reading</Navigation>
|
||||
<Navigation link="/writing">Writing</Navigation>
|
||||
<Navigation link="/deep-dives">Deep Dives</Navigation>
|
||||
</VStack>
|
||||
<VStack align="flex-start">
|
||||
<Text fontWeight="bold" fontSize="smaller">
|
||||
FIND ME ON
|
||||
</Text>
|
||||
<Navigation link="https://twitter.com/majmudaradam" isExternal>
|
||||
Twitter
|
||||
</Navigation>
|
||||
<Navigation link="https://substack.com/@adammaj" isExternal>
|
||||
Substack
|
||||
</Navigation>
|
||||
<Navigation link="https://github.com/adam-maj" isExternal>
|
||||
GitHub
|
||||
</Navigation>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Flex>
|
||||
<Container width={{ md: "container.md" }} position="relative">
|
||||
<Box
|
||||
width="100%"
|
||||
bg="white"
|
||||
height={16}
|
||||
position="fixed"
|
||||
top={0}
|
||||
zIndex={100}
|
||||
display={{ base: "none", lg: "block" }}
|
||||
/>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
position="fixed"
|
||||
top={0}
|
||||
display={{ base: "flex", lg: "none" }}
|
||||
height={12}
|
||||
zIndex={50}
|
||||
left={0}
|
||||
width="100%"
|
||||
align="center"
|
||||
borderBottom="1px solid"
|
||||
borderBottomColor="gray.200"
|
||||
bg="white"
|
||||
>
|
||||
<Container px={8}>
|
||||
<Flex justify="space-between" width="100%">
|
||||
<HStack spacing={8}>
|
||||
<Navigation link="/">Home</Navigation>
|
||||
<Navigation link="/reading">Reading</Navigation>
|
||||
<Navigation link="/writing">Writing</Navigation>
|
||||
</HStack>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<Icon as={FiMenu} boxSize={4} />}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuGroup title="NAVIGATION">
|
||||
<VStack align="flex-start" px={4} spacing={3} mb={4}>
|
||||
<Navigation link="/">Home</Navigation>
|
||||
<Navigation link="/reading">Reading</Navigation>
|
||||
<Navigation link="/writing">Writing</Navigation>
|
||||
<Navigation link="/deep-dives">Deep Dives</Navigation>
|
||||
</VStack>
|
||||
</MenuGroup>
|
||||
<MenuGroup title="FIND ME ON">
|
||||
<VStack align="flex-start" px={4} spacing={3} mb={2}>
|
||||
<Navigation
|
||||
link="https://twitter.com/majmudaradam"
|
||||
isExternal
|
||||
>
|
||||
Twitter
|
||||
</Navigation>
|
||||
<Navigation
|
||||
link="https://substack.com/@adammaj"
|
||||
isExternal
|
||||
>
|
||||
Substack
|
||||
</Navigation>
|
||||
<Navigation link="https://github.com/adam-maj" isExternal>
|
||||
GitHub
|
||||
</Navigation>
|
||||
</VStack>
|
||||
</MenuGroup>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
{children}
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
Reference in New Issue
Block a user