refactor ItemPage components to be more reusable

this is because I'm about to make trades pages!
This commit is contained in:
Emi Matchu 2020-11-23 09:34:46 -08:00
parent 3b4d2ac390
commit 1af034e4e7
2 changed files with 295 additions and 273 deletions

View file

@ -2,12 +2,10 @@ import React from "react";
import { css } from "emotion"; import { css } from "emotion";
import { import {
AspectRatio, AspectRatio,
Badge,
Button, Button,
Box, Box,
HStack, HStack,
IconButton, IconButton,
Skeleton,
SkeletonText, SkeletonText,
Tooltip, Tooltip,
VisuallyHidden, VisuallyHidden,
@ -19,7 +17,6 @@ import {
} from "@chakra-ui/core"; } from "@chakra-ui/core";
import { import {
CheckIcon, CheckIcon,
ExternalLinkIcon,
ChevronRightIcon, ChevronRightIcon,
StarIcon, StarIcon,
WarningIcon, WarningIcon,
@ -29,12 +26,8 @@ import gql from "graphql-tag";
import { useQuery, useMutation } from "@apollo/client"; import { useQuery, useMutation } from "@apollo/client";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
ItemBadgeList, import { Delay, usePageTitle } from "./util";
ItemKindBadge,
ItemThumbnail,
} from "./components/ItemCard";
import { Delay, Heading1, usePageTitle } from "./util";
import { import {
itemAppearanceFragment, itemAppearanceFragment,
petAppearanceFragment, petAppearanceFragment,
@ -57,19 +50,6 @@ function ItemPage() {
export function ItemPageContent({ itemId, isEmbedded }) { export function ItemPageContent({ itemId, isEmbedded }) {
const { isLoggedIn } = useCurrentUser(); const { isLoggedIn } = useCurrentUser();
return (
<VStack spacing="8">
<ItemPageHeader itemId={itemId} isEmbedded={isEmbedded} />
<VStack spacing="4">
{isLoggedIn && <ItemPageOwnWantButtons itemId={itemId} />}
<ItemPageTradeLinks itemId={itemId} />
</VStack>
{!isEmbedded && <ItemPageOutfitPreview itemId={itemId} />}
</VStack>
);
}
function ItemPageHeader({ itemId, isEmbedded }) {
const { error, data } = useQuery( const { error, data } = useQuery(
gql` gql`
query ItemPage($itemId: ID!) { query ItemPage($itemId: ID!) {
@ -89,12 +69,6 @@ function ItemPageHeader({ itemId, isEmbedded }) {
usePageTitle(data?.item?.name, { skip: isEmbedded }); usePageTitle(data?.item?.name, { skip: isEmbedded });
// Show 2 lines of description text placeholder on small screens, or when
// embedded in the wardrobe page's narrow drawer. In larger contexts, show
// just 1 line.
const viewportNumDescriptionLines = useBreakpointValue({ base: 2, md: 1 });
const numDescriptionLines = isEmbedded ? 2 : viewportNumDescriptionLines;
if (error) { if (error) {
return <Box color="red.400">{error.message}</Box>; return <Box color="red.400">{error.message}</Box>;
} }
@ -102,213 +76,46 @@ function ItemPageHeader({ itemId, isEmbedded }) {
const item = data?.item; const item = data?.item;
return ( return (
<> <ItemPageLayout item={item} isEmbedded={isEmbedded}>
<Box <VStack spacing="8" marginTop="4">
display="flex" <ItemPageDescription
alignItems="center" description={item?.description}
justifyContent="flex-start" isEmbedded={isEmbedded}
width="100%" />
> <VStack spacing="4">
<SubtleSkeleton isLoaded={item?.thumbnailUrl} marginRight="4"> {isLoggedIn && <ItemPageOwnWantButtons itemId={itemId} />}
<ItemThumbnail item={item} size="lg" isActive flex="0 0 auto" /> <ItemPageTradeLinks itemId={itemId} />
</SubtleSkeleton> </VStack>
<Box> {!isEmbedded && <ItemPageOutfitPreview itemId={itemId} />}
<SubtleSkeleton isLoaded={item?.name}> </VStack>
<Heading1 </ItemPageLayout>
lineHeight="1.1" );
// Nudge down the size a bit in the embed case, to better fit the }
// tighter layout!
size={isEmbedded ? "xl" : "2xl"} function ItemPageDescription({ description, isEmbedded }) {
> // Show 2 lines of description text placeholder on small screens, or when
{item?.name || "Item name here"} // embedded in the wardrobe page's narrow drawer. In larger contexts, show
</Heading1> // just 1 line.
</SubtleSkeleton> const viewportNumDescriptionLines = useBreakpointValue({ base: 2, md: 1 });
<ItemPageBadges item={item} isEmbedded={isEmbedded} /> const numDescriptionLines = isEmbedded ? 2 : viewportNumDescriptionLines;
return (
<Box width="100%" alignSelf="flex-start">
{description || (
<Box
maxWidth="40em"
minHeight={numDescriptionLines * 1.5 + "em"}
display="flex"
flexDirection="column"
alignItems="stretch"
justifyContent="center"
>
<Delay ms={500}>
<SkeletonText noOfLines={numDescriptionLines} spacing="4" />
</Delay>
</Box> </Box>
</Box> )}
<Box width="100%" alignSelf="flex-start"> </Box>
{item?.description || (
<Box
maxWidth="40em"
minHeight={numDescriptionLines * 1.5 + "em"}
display="flex"
flexDirection="column"
alignItems="stretch"
justifyContent="center"
>
<Delay ms={500}>
<SkeletonText noOfLines={numDescriptionLines} spacing="4" />
</Delay>
</Box>
)}
</Box>
</>
);
}
function ItemPageBadges({ item, isEmbedded }) {
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
return (
<ItemBadgeList marginTop="1">
<SubtleSkeleton isLoaded={item?.isNc != null}>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
</SubtleSkeleton>
{
// If the createdAt date is null (loaded and empty), hide the badge.
item.createdAt !== null && (
<SubtleSkeleton
// Distinguish between undefined (still loading) and null (loaded and
// empty).
isLoaded={item.createdAt !== undefined}
>
<Badge
display="block"
minWidth="5.25em"
boxSizing="content-box"
textAlign="center"
>
{item.createdAt && <ShortTimestamp when={item.createdAt} />}
</Badge>
</SubtleSkeleton>
)
}
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
<LinkBadge
href={`https://impress.openneo.net/items/${item.id}`}
isEmbedded={isEmbedded}
>
Classic DTI
</LinkBadge>
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
<LinkBadge
href={
"https://items.jellyneo.net/search/?name=" +
encodeURIComponent(item.name) +
"&name_type=3"
}
isEmbedded={isEmbedded}
>
Jellyneo
</LinkBadge>
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/market.phtml?type=wizard&string=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Shop Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/portal/supershopwiz.phtml?string=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Super Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&search_string=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Trade Post
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/genie.phtml?type=process_genie&criteria=exact&auctiongenie=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Auctions
</LinkBadge>
)}
</SubtleSkeleton>
</ItemBadgeList>
);
}
function LinkBadge({ children, href, isEmbedded }) {
return (
<Badge
as="a"
href={href}
display="flex"
alignItems="center"
// Normally we want to act like a normal webpage, and treat links as
// normal. But when we're on the wardrobe page, we want to avoid
// disrupting the outfit, and open in a new window instead.
target={isEmbedded ? "_blank" : undefined}
>
{children}
{
// We also change the icon to signal whether this will launch in a new
// window or not!
isEmbedded ? <ExternalLinkIcon marginLeft="1" /> : <ChevronRightIcon />
}
</Badge>
);
}
const fullDateFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
});
const monthYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
});
const monthDayYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
function ShortTimestamp({ when }) {
const date = new Date(when);
// To find the start of last month, take today, then set its date to the 1st
// and its time to midnight (the start of this month), and subtract one
// month. (JS handles negative months and rolls them over correctly.)
const startOfLastMonth = new Date();
startOfLastMonth.setDate(1);
startOfLastMonth.setHours(0);
startOfLastMonth.setMinutes(0);
startOfLastMonth.setSeconds(0);
startOfLastMonth.setMilliseconds(0);
startOfLastMonth.setMonth(startOfLastMonth.getMonth() - 1);
const dateIsOlderThanLastMonth = date < startOfLastMonth;
return (
<Tooltip
label={`First seen on ${fullDateFormatter.format(date)}`}
placement="top"
openDelay={400}
>
{dateIsOlderThanLastMonth
? monthYearFormatter.format(date)
: monthDayYearFormatter.format(date)}
</Tooltip>
); );
} }
@ -860,43 +667,4 @@ function ItemPageOutfitPreview({ itemId }) {
); );
} }
/**
* SubtleSkeleton hides the skeleton animation until a second has passed, and
* doesn't fade in the content if it loads near-instantly. This helps avoid
* flash-of-content stuff!
*
* For plain Skeletons, we often use <Delay><Skeleton /></Delay> instead. But
* that pattern doesn't work as well for wrapper skeletons where we're using
* placeholder content for layout: we don't want the delay if the content
* really _is_ present!
*/
function SubtleSkeleton({ isLoaded, ...props }) {
const [shouldFadeIn, setShouldFadeIn] = React.useState(false);
const [shouldShowSkeleton, setShouldShowSkeleton] = React.useState(false);
React.useEffect(() => {
const t = setTimeout(() => {
if (!isLoaded) {
setShouldFadeIn(true);
}
}, 150);
return () => clearTimeout(t);
});
React.useEffect(() => {
const t = setTimeout(() => setShouldShowSkeleton(true), 500);
return () => clearTimeout(t);
});
return (
<Skeleton
fadeDuration={shouldFadeIn ? undefined : 0}
startColor={shouldShowSkeleton ? undefined : "transparent"}
endColor={shouldShowSkeleton ? undefined : "transparent"}
isLoaded={isLoaded}
{...props}
/>
);
}
export default ItemPage; export default ItemPage;

254
src/app/ItemPageLayout.js Normal file
View file

@ -0,0 +1,254 @@
import React from "react";
import { Badge, Box, Skeleton, Tooltip } from "@chakra-ui/core";
import { ExternalLinkIcon, ChevronRightIcon } from "@chakra-ui/icons";
import {
ItemBadgeList,
ItemKindBadge,
ItemThumbnail,
} from "./components/ItemCard";
import { Heading1 } from "./util";
function ItemPageLayout({ children, item, isEmbedded }) {
return (
<Box>
<ItemPageHeader item={item} isEmbedded={isEmbedded} />
<Box>{children}</Box>
</Box>
);
}
function ItemPageHeader({ item, isEmbedded }) {
return (
<Box
display="flex"
alignItems="center"
justifyContent="flex-start"
width="100%"
>
<SubtleSkeleton isLoaded={item?.thumbnailUrl} marginRight="4">
<ItemThumbnail item={item} size="lg" isActive flex="0 0 auto" />
</SubtleSkeleton>
<Box>
<SubtleSkeleton isLoaded={item?.name}>
<Heading1
lineHeight="1.1"
// Nudge down the size a bit in the embed case, to better fit the
// tighter layout!
size={isEmbedded ? "xl" : "2xl"}
>
{item?.name || "Item name here"}
</Heading1>
</SubtleSkeleton>
<ItemPageBadges item={item} isEmbedded={isEmbedded} />
</Box>
</Box>
);
}
/**
* SubtleSkeleton hides the skeleton animation until a second has passed, and
* doesn't fade in the content if it loads near-instantly. This helps avoid
* flash-of-content stuff!
*
* For plain Skeletons, we often use <Delay><Skeleton /></Delay> instead. But
* that pattern doesn't work as well for wrapper skeletons where we're using
* placeholder content for layout: we don't want the delay if the content
* really _is_ present!
*/
export function SubtleSkeleton({ isLoaded, ...props }) {
const [shouldFadeIn, setShouldFadeIn] = React.useState(false);
const [shouldShowSkeleton, setShouldShowSkeleton] = React.useState(false);
React.useEffect(() => {
const t = setTimeout(() => {
if (!isLoaded) {
setShouldFadeIn(true);
}
}, 150);
return () => clearTimeout(t);
});
React.useEffect(() => {
const t = setTimeout(() => setShouldShowSkeleton(true), 500);
return () => clearTimeout(t);
});
return (
<Skeleton
fadeDuration={shouldFadeIn ? undefined : 0}
startColor={shouldShowSkeleton ? undefined : "transparent"}
endColor={shouldShowSkeleton ? undefined : "transparent"}
isLoaded={isLoaded}
{...props}
/>
);
}
function ItemPageBadges({ item, isEmbedded }) {
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
return (
<ItemBadgeList marginTop="1">
<SubtleSkeleton isLoaded={item?.isNc != null}>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
</SubtleSkeleton>
{
// If the createdAt date is null (loaded and empty), hide the badge.
item.createdAt !== null && (
<SubtleSkeleton
// Distinguish between undefined (still loading) and null (loaded and
// empty).
isLoaded={item.createdAt !== undefined}
>
<Badge
display="block"
minWidth="5.25em"
boxSizing="content-box"
textAlign="center"
>
{item.createdAt && <ShortTimestamp when={item.createdAt} />}
</Badge>
</SubtleSkeleton>
)
}
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
<LinkBadge
href={`https://impress.openneo.net/items/${item.id}`}
isEmbedded={isEmbedded}
>
Classic DTI
</LinkBadge>
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
<LinkBadge
href={
"https://items.jellyneo.net/search/?name=" +
encodeURIComponent(item.name) +
"&name_type=3"
}
isEmbedded={isEmbedded}
>
Jellyneo
</LinkBadge>
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/market.phtml?type=wizard&string=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Shop Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/portal/supershopwiz.phtml?string=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Super Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&search_string=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Trade Post
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/genie.phtml?type=process_genie&criteria=exact&auctiongenie=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Auctions
</LinkBadge>
)}
</SubtleSkeleton>
</ItemBadgeList>
);
}
function LinkBadge({ children, href, isEmbedded }) {
return (
<Badge
as="a"
href={href}
display="flex"
alignItems="center"
// Normally we want to act like a normal webpage, and treat links as
// normal. But when we're on the wardrobe page, we want to avoid
// disrupting the outfit, and open in a new window instead.
target={isEmbedded ? "_blank" : undefined}
>
{children}
{
// We also change the icon to signal whether this will launch in a new
// window or not!
isEmbedded ? <ExternalLinkIcon marginLeft="1" /> : <ChevronRightIcon />
}
</Badge>
);
}
const fullDateFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
});
const monthYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
});
const monthDayYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
function ShortTimestamp({ when }) {
const date = new Date(when);
// To find the start of last month, take today, then set its date to the 1st
// and its time to midnight (the start of this month), and subtract one
// month. (JS handles negative months and rolls them over correctly.)
const startOfLastMonth = new Date();
startOfLastMonth.setDate(1);
startOfLastMonth.setHours(0);
startOfLastMonth.setMinutes(0);
startOfLastMonth.setSeconds(0);
startOfLastMonth.setMilliseconds(0);
startOfLastMonth.setMonth(startOfLastMonth.getMonth() - 1);
const dateIsOlderThanLastMonth = date < startOfLastMonth;
return (
<Tooltip
label={`First seen on ${fullDateFormatter.format(date)}`}
placement="top"
openDelay={400}
>
{dateIsOlderThanLastMonth
? monthYearFormatter.format(date)
: monthDayYearFormatter.format(date)}
</Tooltip>
);
}
export default ItemPageLayout;