From 180f4d99e2881b4abbd3e0fa2ca39a212f0ef934 Mon Sep 17 00:00:00 2001 From: Chris Schlensker Date: Tue, 11 Jul 2023 10:20:41 -0700 Subject: [PATCH] Build 404 page --- .../full-height-container.tsx | 33 ++ components/molecules/DevCard/dev-card.tsx | 30 +- .../organisms/DevCardWall/dev-card-wall.tsx | 300 ++++++++++++++++++ img/icons/chevron-left.svg | 5 + pages/404.tsx | 139 ++++++++ pages/user/[username]/card.tsx | 39 +-- stories/organisms/dev-card-wall.stories.tsx | 42 +++ 7 files changed, 545 insertions(+), 43 deletions(-) create mode 100644 components/atoms/FullHeightContainer/full-height-container.tsx create mode 100644 components/organisms/DevCardWall/dev-card-wall.tsx create mode 100644 img/icons/chevron-left.svg create mode 100644 pages/404.tsx create mode 100644 stories/organisms/dev-card-wall.stories.tsx diff --git a/components/atoms/FullHeightContainer/full-height-container.tsx b/components/atoms/FullHeightContainer/full-height-container.tsx new file mode 100644 index 0000000000..435a475c92 --- /dev/null +++ b/components/atoms/FullHeightContainer/full-height-container.tsx @@ -0,0 +1,33 @@ +/** + * A react component that will ensure that its min-height is the same as the screen height. + * This is not possible with pure css because some mobile browsers don't account for the top and bottom bars + * when calculating `vh`. + * see https://dev.to/nirazanbasnet/dont-use-100vh-for-mobile-responsive-3o97 + */ + +import clsx from "clsx"; +import React, { useEffect, useState } from "react"; +import { useWindowSize } from "react-use"; + +interface Props extends React.HTMLAttributes { +} + +export default function FullHeightContainer(props: Props) { + const { children, className, ...rest } = props; + const { height: innerHeight } = useWindowSize(); + const [minHeight, setMinHeight] = useState("100vh"); + + useEffect(() => { + setMinHeight(`${innerHeight}px`); + }, [innerHeight]); + + return ( +
+ {children} +
+ ); +} diff --git a/components/molecules/DevCard/dev-card.tsx b/components/molecules/DevCard/dev-card.tsx index 9c75b741fa..455844f168 100644 --- a/components/molecules/DevCard/dev-card.tsx +++ b/components/molecules/DevCard/dev-card.tsx @@ -27,6 +27,7 @@ export interface DevCardProps { isLoading?: boolean; isFlipped?: boolean; isInteractive?: boolean; + hideProfileButton?: boolean; age?: number; onFlip?: () => void; } @@ -50,6 +51,9 @@ export default function DevCard(props: DevCardProps) { const profileHref = `/user/${props.username}`; function handleCardClick(event: React.MouseEvent) { + if (!isInteractive) { + return; + } // flip the card if the click is not on the button if (!(event.target instanceof HTMLAnchorElement || event.target instanceof HTMLButtonElement)) { if (props.isFlipped === undefined) { @@ -73,7 +77,7 @@ export default function DevCard(props: DevCardProps) { "h-full", "absolute", "left-0", - "top-0", + "top-0" ); const faceStyle: React.CSSProperties = { @@ -85,11 +89,10 @@ export default function DevCard(props: DevCardProps) { return (
@@ -238,11 +236,13 @@ export default function DevCard(props: DevCardProps) {
{/* bottom */}
- - - + {!props.hideProfileButton && ( + + + + )}
Open Sauced Logo

diff --git a/components/organisms/DevCardWall/dev-card-wall.tsx b/components/organisms/DevCardWall/dev-card-wall.tsx new file mode 100644 index 0000000000..f64cc78ab8 --- /dev/null +++ b/components/organisms/DevCardWall/dev-card-wall.tsx @@ -0,0 +1,300 @@ +import { useKey, useKeyPress, useMeasure } from "react-use"; +import { useEffect, useState } from "react"; +import { animated, useSpring, useSprings } from "react-spring"; +import { useGesture } from "@use-gesture/react"; +import { useOutsideClickRef } from "rooks"; +import DevCard, { DevCardProps } from "components/molecules/DevCard/dev-card"; +import Button from "components/atoms/Button/button"; +import ChevronLeft from "../../../img/icons/chevron-left.svg"; + +const LOADING_TILES_COUNT = 20; + +const cellHeight = 348; +const cellWidth = 245; +const gapSize = 20; + +const coordinatesForIndex = (height: number) => (index: number) => { + const extraYOffset = cellHeight * 0.4; + const tilesPerColumn = Math.floor((height - gapSize) / (cellHeight + gapSize)) + 1; + const columnRow = index % tilesPerColumn; + const columnIsOdd = Math.floor(index / tilesPerColumn) % 2 === 1; + const yOffsetForCell = columnIsOdd ? 0 : extraYOffset; + const y = columnRow * (cellHeight + gapSize) - yOffsetForCell; + const x = Math.floor(index / tilesPerColumn) * (cellWidth + gapSize); + return { + x, + y, + }; +}; + +interface DevCardWallProps { + cards: DevCardProps[]; + isLoading?: boolean; + initialCardIndex?: number; +} + +export default function DevCardWall({ isLoading = false, cards, initialCardIndex }: DevCardWallProps) { + const [containerRef, { height }] = useMeasure(); + const [activeCardIndex, setActiveCardIndex] = useState(null); + const [outsideClickRef] = useOutsideClickRef(() => { + setActiveCardIndex(null); + }); + + const [arrowIsPressed] = useKeyPress("ArrowLeft"); + + const pulseAnimation = useSpring({ + from: { + opacity: 0.1, + }, + to: isLoading + ? [ + { + opacity: 0.2, + }, + { + opacity: 0.1, + }, + ] + : { opacity: 0 }, + loop: isLoading, + config: { + duration: 1000, + }, + }); + + const nextButtonActiveStyle = { + opacity: 0.8, + translateY: 4, + config: { + duration: 100, + }, + }; + + const nextButtonDefaultStyle = { + opacity: 1, + translateY: 0, + }; + + const [nextButtonSpringStyle, nextButtonSpringApi] = useSpring( + () => (arrowIsPressed ? nextButtonActiveStyle : nextButtonDefaultStyle), + [arrowIsPressed] + ); + + const bindHover = useGesture({ + onHover: (state) => { + const hoverIndex = state.args[0]; + // setHoverIndex(hoverIndex); + cardApi.start((i) => { + const hoverStyle = { translateY: -20 }; + const defaultStyle = { translateY: 0 }; + // move the card up in y value while hovering + return state.hovering && i === hoverIndex && i !== activeCardIndex ? hoverStyle : defaultStyle; + }); + }, + }); + + const [cardSprings, cardApi] = useSprings( + cards.length, + (index) => ({ + from: { + x: 0, + y: 0, + opacity: 1, + translateY: 0, + scale: 1, + zIndex: 0, + }, + to: { + scale: 1, + opacity: 1, + zIndex: 0, + delay: index * 100, + ...coordinatesForIndex(height)(index), + }, + }), + [cards, height] + ); + + const [cardButtonSprings, cardButtonApi] = useSprings( + cards.length, + (index) => ({ + opacity: 0, + translateY: 50, + }), + [cards, activeCardIndex] + ); + + useEffect(() => { + (async function animate() { + cardApi.start((i) => { + return i === activeCardIndex + ? { + scale: 1.1, + translateY: 0, + opacity: 1, + zIndex: 49, + x: 0, + y: height / 2 - cellHeight / 2, + immediate: "zIndex", + } + : { scale: 1, translateY: 0, opacity: 1, zIndex: 0, ...coordinatesForIndex(height)(i), immediate: "zIndex" }; + }); + + cardButtonApi.start((i) => { + return i === activeCardIndex + ? { + opacity: 1, + translateY: 0, + delay: 250, + } + : { + opacity: 0, + translateY: 50, + }; + }); + })(); + }, [activeCardIndex, cardApi, cardButtonApi, height]); + + useEffect(() => { + if (initialCardIndex !== undefined) { + setActiveCardIndex(initialCardIndex); + } + }, [initialCardIndex]); + + function nextCard() { + setActiveCardIndex((index) => { + if (index === null) { + return 0; + } + return (index + 1) % cards.length; + }); + } + + useKey("ArrowLeft", nextCard, {}); + + useEffect(() => { + if (arrowIsPressed) { + nextButtonSpringApi.start({ + opacity: 0.8, + translateY: 4, + config: { + duration: 100, + }, + }); + } else { + nextButtonSpringApi.start({ + opacity: 1, + translateY: 0, + }); + } + }, [arrowIsPressed, nextButtonSpringApi]); + + function handleNextButtonClick(event: React.MouseEvent) { + event.stopPropagation(); + nextCard(); + } + + const loadingSkeleton = Array.from({ length: LOADING_TILES_COUNT }, (_, i) => { + const { x, y } = coordinatesForIndex(height)(i); + return ( + + ); + }); + + const cardElements = cardSprings.map(({ x, y, translateY, scale, zIndex }, i) => { + const cardProps = cards[i]; + const buttonSpring = cardButtonSprings[i]; + return ( + { + event.stopPropagation(); + setActiveCardIndex(i); + }} + style={{ + width: `${cellWidth}px`, + height: `${cellHeight}px`, + left: x.to((x) => `${x}px`), + top: y.to((y) => `${y}px`), + translateY: translateY.to((y) => `${y}px`), + scale: scale, + zIndex, + }} + > + + + + + + ); + }); + + return ( +

{ + setActiveCardIndex(null); + }} + > +
+ {/* card wall surface area, should extend beyond the top and bottom */} + {/* {isLoadingUsernames ? loadingSkeleton : cardElements} */} + {loadingSkeleton} + {cardElements} +
+
+
+ nextButtonSpringApi.start(nextButtonActiveStyle)} + onMouseUp={(event) => nextButtonSpringApi.start(nextButtonDefaultStyle)} + className="rounded-md border border-amber-700 w-10 h-10 absolute left-0 top-1/2 block z-50 active:outline-none" + style={{ + backgroundColor: "#271700", + backgroundImage: `url(${ChevronLeft.src})`, + backgroundRepeat: "no-repeat", + backgroundPosition: "center center", + ...nextButtonSpringStyle, + }} + > +
+ ); +} diff --git a/img/icons/chevron-left.svg b/img/icons/chevron-left.svg new file mode 100644 index 0000000000..a4860f73d3 --- /dev/null +++ b/img/icons/chevron-left.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/pages/404.tsx b/pages/404.tsx new file mode 100644 index 0000000000..bd15fdd37f --- /dev/null +++ b/pages/404.tsx @@ -0,0 +1,139 @@ +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { differenceInDays } from "date-fns"; +import FullHeightContainer from "components/atoms/FullHeightContainer/full-height-container"; +import HeaderLogo from "components/molecules/HeaderLogo/header-logo"; +import { useFetchTopContributors } from "lib/hooks/useFetchTopContributors"; +import DevCardWall from "components/organisms/DevCardWall/dev-card-wall"; +import { DevCardProps } from "components/molecules/DevCard/dev-card"; +import { fetchContributorPRs } from "lib/hooks/api/useContributorPullRequests"; +import { getRepoList } from "lib/hooks/useRepoList"; +import getContributorPullRequestVelocity from "lib/utils/get-contributor-pr-velocity"; +import getPercent from "lib/utils/get-percent"; +import { getAvatarByUsername } from "lib/utils/github"; +import BubbleBG from "../img/bubble-bg.svg"; + +export default function Custom404() { + const { data } = useFetchTopContributors(); + const [cards, setCards] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [initialCardIndex, setInitialCardIndex] = useState(); + + useEffect(() => { + async function loadCards() { + const cardData = await Promise.all(data.map((user) => getAllCardData(user.login))); + // randomize cards + cardData.sort(() => Math.random() - 0.5); + setCards(cardData); + setIsLoading(false); + setTimeout(() => { + setInitialCardIndex(0); + }, 500); + } + + loadCards(); + }, [data]); + + return ( + +
+
+ + + Signup with Github + +
+
+
+

404

+
uh oh page not found
+
while you're here, you can check out some of our amazing contributors!
+ + Take me home → + +
+
+
+ +
+
+
+
+
+ ); +} + +async function getAllCardData(username: string): Promise { + const [basicData, contributorData] = await Promise.all([ + fetchBasicCardData(username), + fetchContributorCardData(username), + ]); + + return { + ...basicData, + ...contributorData, + isLoading: false, + }; +} + +async function fetchUserData(username: string) { + const req = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/users/${username}`, { + headers: { + accept: "application/json", + }, + }); + + return (await req.json()) as DbUser; +} + +async function fetchBasicCardData(username: string): Promise { + const user = await fetchUserData(username); + const githubAvatar = getAvatarByUsername(username, 300); + + const ageInDays = user.first_opened_pr_at ? differenceInDays(new Date(), new Date(user.first_opened_pr_at)) : 0; + + return { + username, + avatarURL: githubAvatar, + name: user.name || username, + bio: user.bio, + age: ageInDays, + }; +} + +async function fetchContributorCardData( + username: string +): Promise> { + const { data, meta } = await fetchContributorPRs(username, undefined, "*", [], 100); + const prs = data.length; + const prVelocity = getContributorPullRequestVelocity(data); + const prTotal = meta.itemCount; + const mergedPrs = data.filter((prData) => prData.merged); + const prMergePercentage = getPercent(prTotal, mergedPrs.length || 0); + const repos = getRepoList(Array.from(new Set(data.map((prData) => prData.full_name))).join(",")).length; + return { + prs, + prVelocity, + prMergePercentage, + repos, + }; +} diff --git a/pages/user/[username]/card.tsx b/pages/user/[username]/card.tsx index 02f804df3f..be59711418 100644 --- a/pages/user/[username]/card.tsx +++ b/pages/user/[username]/card.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from "react"; import { useTransition, animated } from "react-spring"; import Image from "next/image"; import cntl from "cntl"; -import { useWindowSize } from "rooks"; import Button from "components/atoms/Button/button"; import HeaderLogo from "components/molecules/HeaderLogo/header-logo"; import DevCardCarousel, { DevCardCarouselProps } from "components/organisms/DevCardCarousel/dev-card-carousel"; @@ -17,10 +16,10 @@ import { DevCardProps } from "components/molecules/DevCard/dev-card"; import SEO from "layouts/SEO/SEO"; import useSupabaseAuth from "lib/hooks/useSupabaseAuth"; import { cardImageUrl, linkedinCardShareUrl, twitterCardShareUrl } from "lib/utils/urls"; +import FullHeightContainer from "components/atoms/FullHeightContainer/full-height-container"; import TwitterIcon from "../../../img/icons/social-twitter.svg"; import LinkinIcon from "../../../img/icons/social-linkedin.svg"; import BubbleBG from "../../../img/bubble-bg.svg"; -; const ADDITIONAL_PROFILES_TO_LOAD = [ "bdougie", "nickytonline", @@ -61,7 +60,6 @@ async function fetchInitialCardData(username: string): Promise { ? Math.floor((Date.now() - Date.parse(user.first_opened_pr_at)) / 86400000) : 0; - return { username, avatarURL: githubAvatar, @@ -101,7 +99,6 @@ export const getServerSideProps: GetServerSideProps = async ( const uniqueUsernames = [...new Set([username, ...ADDITIONAL_PROFILES_TO_LOAD])]; const cards = await Promise.all(uniqueUsernames.map(fetchInitialCardData)); - return { props: { username, @@ -112,8 +109,6 @@ export const getServerSideProps: GetServerSideProps = async ( const Card: NextPage = ({ username, cards }) => { const { user: loggedInUser } = useSupabaseAuth(); - const { innerHeight } = useWindowSize(); - const [minHeight, setMinHeight] = useState("100vh"); const [selectedUserName, setSelectedUserName] = useState(username); const iframeTransition = useTransition(selectedUserName, { from: { opacity: 0, transform: "translate3d(100%, 0, 0)" }, @@ -152,18 +147,8 @@ const Card: NextPage = ({ username, cards }) => { }); }, [cards]); - useEffect(() => { - setMinHeight(`${innerHeight}px`); - }, [innerHeight]); - - return ( -
+ = ({ username, cards }) => { twitterCard="summary_large_image" />
@@ -249,23 +233,22 @@ const Card: NextPage = ({ username, cards }) => { ) : (
)}
-
+ ); }; export default Card; - -function SocialButtons({username, summary} : {username: string, summary: string }) { +function SocialButtons({ username, summary }: { username: string; summary: string }) { const icons = [ { name: "Twitter", @@ -301,7 +284,7 @@ function SocialButtons({username, summary} : {username: string, summary: string key={icon.src} href={icon.url} className={linkStyle} - style={{ backgroundColor: icon.color, borderColor: "rgba(255,255,255,0.2)"}} + style={{ backgroundColor: icon.color, borderColor: "rgba(255,255,255,0.2)" }} target="_blank" rel="noreferrer" > @@ -311,4 +294,4 @@ function SocialButtons({username, summary} : {username: string, summary: string
); -} \ No newline at end of file +} diff --git a/stories/organisms/dev-card-wall.stories.tsx b/stories/organisms/dev-card-wall.stories.tsx new file mode 100644 index 0000000000..cdc4a5e666 --- /dev/null +++ b/stories/organisms/dev-card-wall.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import DevCardWall from "../../components/organisms/DevCardWall/dev-card-wall"; + +const cards = Array.from({ length: 10 }, (_, i) => ({ + username: `test${i}`, + name: "test", + avatarURL: "https://avatars.githubusercontent.com/u/54212428?v=4", +})); + +export default { + title: "Design System/Organisms/DevCard Wall", + component: DevCardWall, + parameters: { + layout: "fullscreen", + backgrounds: { + default: "dark", + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + cards: [...cards], + isLoading: false, + }, +}; + +export const Loading: Story = { + args: { + cards: [], + isLoading: true, + }, +};