mirror of
https://github.com/andwati/personal-website.git
synced 2026-10-24 21:42:15 +00:00
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|