impress-2020/src/app/ItemPage.js

592 lines
17 KiB
JavaScript
Raw Normal View History

2020-09-11 23:56:47 -07:00
import React from "react";
import { css } from "emotion";
import {
AspectRatio,
Badge,
2020-09-12 22:21:00 -07:00
Button,
Box,
Skeleton,
2020-09-12 23:23:46 -07:00
SkeletonText,
Tooltip,
2020-09-12 22:21:00 -07:00
VisuallyHidden,
VStack,
2020-09-12 23:23:46 -07:00
useBreakpointValue,
useColorModeValue,
useTheme,
2020-09-12 22:21:00 -07:00
useToast,
} from "@chakra-ui/core";
import {
CheckIcon,
ExternalLinkIcon,
ChevronRightIcon,
StarIcon,
WarningIcon,
} from "@chakra-ui/icons";
2020-09-11 23:56:47 -07:00
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import { useParams } from "react-router-dom";
import {
ItemBadgeList,
ItemThumbnail,
NcBadge,
NpBadge,
} from "./components/ItemCard";
import { Delay, Heading1, usePageTitle } from "./util";
import OutfitPreview from "./components/OutfitPreview";
import SpeciesColorPicker from "./components/SpeciesColorPicker";
2020-09-11 23:56:47 -07:00
function ItemPage() {
const { itemId } = useParams();
return <ItemPageContent itemId={itemId} />;
}
2020-09-11 23:56:47 -07:00
/**
* ItemPageContent is the content of ItemPage, but we also use it as the
* entry point for ItemPageDrawer! When embedded in ItemPageDrawer, the
* `isEmbedded` prop is true, so we know not to e.g. set the page title.
*/
export function ItemPageContent({ itemId, isEmbedded }) {
return (
2020-09-12 22:21:00 -07:00
<VStack spacing="8">
<ItemPageHeader itemId={itemId} isEmbedded={isEmbedded} />
2020-09-12 22:21:00 -07:00
<ItemPageOwnWantButtons itemId={itemId} />
<ItemPageOutfitPreview itemId={itemId} />
</VStack>
);
}
function ItemPageHeader({ itemId, isEmbedded }) {
const { error, data } = useQuery(
2020-09-11 23:56:47 -07:00
gql`
query ItemPage($itemId: ID!) {
item(id: $itemId) {
id
name
isNc
thumbnailUrl
2020-09-12 23:23:46 -07:00
description
createdAt
2020-09-11 23:56:47 -07:00
}
}
`,
{ variables: { itemId }, returnPartialData: true }
2020-09-11 23:56:47 -07:00
);
usePageTitle(data?.item?.name, { skip: isEmbedded });
2020-09-11 23:56:47 -07:00
// 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;
2020-09-12 23:23:46 -07:00
2020-09-11 23:56:47 -07:00
if (error) {
return <Box color="red.400">{error.message}</Box>;
}
const item = data?.item;
2020-09-11 23:56:47 -07:00
return (
2020-09-12 23:23:46 -07:00
<>
<Box
display="flex"
alignItems="center"
justifyContent="flex-start"
width="100%"
>
<SubtleSkeleton isLoaded={item?.thumbnailUrl} marginRight="4">
2020-09-12 23:23:46 -07:00
<ItemThumbnail item={item} size="lg" isActive flex="0 0 auto" />
</SubtleSkeleton>
2020-09-12 23:23:46 -07:00
<Box>
<SubtleSkeleton isLoaded={item?.name}>
2020-09-12 23:23:46 -07:00
<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>
2020-09-12 23:23:46 -07:00
<ItemPageBadges item={item} isEmbedded={isEmbedded} />
</Box>
</Box>
<Box width="100%" alignSelf="flex-start">
{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>
2020-09-12 23:23:46 -07:00
</Box>
)}
2020-09-11 23:56:47 -07:00
</Box>
2020-09-12 23:23:46 -07:00
</>
2020-09-11 23:56:47 -07:00
);
}
function ItemPageBadges({ item, isEmbedded }) {
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
return (
<ItemBadgeList>
<SubtleSkeleton isLoaded={item?.isNc != null}>
{item?.isNc ? <NcBadge /> : <NpBadge />}
</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}
>
Old 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 && (
<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 && (
<LinkBadge
href={
"http://www.neopets.com/portal/supershopwiz.phtml?string=" +
encodeURIComponent(item.name)
}
isEmbedded={isEmbedded}
>
Super Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && (
<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 && (
<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 }) {
2020-09-11 23:56:47 -07:00
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}
>
2020-09-11 23:56:47 -07:00
{children}
{
// We also change the icon to signal whether this will launch in a new
// window or not!
isEmbedded ? <ExternalLinkIcon marginLeft="1" /> : <ChevronRightIcon />
}
2020-09-11 23:56:47 -07:00
</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>
);
}
2020-09-12 22:21:00 -07:00
function ItemPageOwnWantButtons({ itemId }) {
const theme = useTheme();
const toast = useToast();
2020-09-12 22:21:00 -07:00
const [currentUserOwnsThis, setCurrentUserOwnsThis] = React.useState(false);
const [currentUserWantsThis, setCurrentUserWantsThis] = React.useState(false);
const { loading, error } = useQuery(
gql`
query ItemPageOwnWantButtons($itemId: ID!) {
item(id: $itemId) {
id
currentUserOwnsThis
currentUserWantsThis
}
}
`,
{
variables: { itemId },
onCompleted: (data) => {
setCurrentUserOwnsThis(data?.item?.currentUserOwnsThis || false);
setCurrentUserWantsThis(data?.item?.currentUserWantsThis || false);
2020-09-12 22:21:00 -07:00
},
}
);
if (error) {
return <Box color="red.400">{error.message}</Box>;
}
return (
<Box display="flex">
<SubtleSkeleton isLoaded={!loading} marginRight="4">
2020-09-12 22:21:00 -07:00
<Box as="label">
<VisuallyHidden
as="input"
type="checkbox"
checked={currentUserOwnsThis}
2020-09-12 22:21:00 -07:00
onChange={(e) => {
setCurrentUserOwnsThis(e.target.checked);
toast({
title: "Todo: This doesn't actually work yet!",
status: "info",
duration: 1500,
});
}}
/>
<Button
as="div"
colorScheme={currentUserOwnsThis ? "green" : "gray"}
size="lg"
cursor="pointer"
transitionDuration="0.4s"
className={css`
input:focus + & {
box-shadow: ${theme.shadows.outline};
}
`}
2020-09-12 22:21:00 -07:00
>
<IconCheckbox
icon={<CheckIcon />}
isChecked={currentUserOwnsThis}
marginRight="0.5em"
/>
I own this
</Button>
</Box>
</SubtleSkeleton>
2020-09-12 22:21:00 -07:00
<SubtleSkeleton isLoaded={!loading}>
2020-09-12 22:21:00 -07:00
<Box as="label">
<VisuallyHidden
as="input"
type="checkbox"
isChecked={currentUserWantsThis}
onChange={(e) => {
setCurrentUserWantsThis(e.target.checked);
toast({
title: "Todo: This doesn't actually work yet!",
status: "info",
duration: 1500,
});
}}
/>
<Button
as="div"
colorScheme={currentUserWantsThis ? "blue" : "gray"}
size="lg"
cursor="pointer"
transitionDuration="0.4s"
className={css`
input:focus + & {
box-shadow: ${theme.shadows.outline};
}
`}
2020-09-12 22:21:00 -07:00
>
<IconCheckbox
icon={<StarIcon />}
isChecked={currentUserWantsThis}
marginRight="0.5em"
/>
I want this
</Button>
</Box>
</SubtleSkeleton>
2020-09-12 22:21:00 -07:00
</Box>
);
}
function IconCheckbox({ icon, isChecked, ...props }) {
return (
<Box display="grid" gridTemplateAreas="the-same-area" {...props}>
<Box
gridArea="the-same-area"
width="1em"
height="1em"
border="2px solid currentColor"
borderRadius="md"
opacity={isChecked ? "0" : "0.75"}
transform={isChecked ? "scale(0.75)" : "none"}
transition="all 0.4s"
/>
<Box
gridArea="the-same-area"
display="flex"
opacity={isChecked ? "1" : "0"}
transform={isChecked ? "none" : "scale(0.1)"}
transition="all 0.4s"
>
{icon}
</Box>
</Box>
);
}
function ItemPageOutfitPreview({ itemId }) {
const idealPose = React.useMemo(
() => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
[]
);
const [petState, setPetState] = React.useState({
speciesId: "1",
colorId: "8",
pose: idealPose,
});
// To check whether the item is compatible with this pet, query for the
// appearance, but only against the cache. That way, we don't send a
// redundant network request just for this (the OutfitPreview component will
// handle it!), but we'll get an update once it arrives in the cache.
const { data } = useQuery(
gql`
query ItemPageOutfitPreview_CacheOnly(
$itemId: ID!
$speciesId: ID!
$colorId: ID!
) {
item(id: $itemId) {
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
layers {
id
}
}
}
}
`,
{
variables: {
itemId,
speciesId: petState.speciesId,
colorId: petState.colorId,
},
fetchPolicy: "cache-only",
}
);
// If the layers are null-y, then we're still loading. Otherwise, if the
// layers are an empty array, then we're incomaptible. Or, if they're a
// non-empty array, then we're compatible!
const layers = data?.item?.appearanceOn?.layers;
const isIncompatible = Array.isArray(layers) && layers.length === 0;
const borderColor = useColorModeValue("green.700", "green.400");
const errorColor = useColorModeValue("red.600", "red.400");
return (
<VStack spacing="3" width="100%">
<AspectRatio
width="300px"
maxWidth="100%"
ratio="1"
border="1px"
borderColor={borderColor}
transition="border-color 0.2s"
borderRadius="lg"
boxShadow="lg"
overflow="hidden"
>
<Box>
<OutfitPreview
speciesId={petState.speciesId}
colorId={petState.colorId}
pose={petState.pose}
wornItemIds={[itemId]}
spinnerVariant="corner"
loadingDelayMs={2000}
/>
</Box>
</AspectRatio>
<Box display="flex" width="100%" alignItems="center">
<Box
// This empty box grows at the same rate as the box on the right, so
// the middle box will be centered, if there's space!
flex="1 0 0"
/>
<SpeciesColorPicker
speciesId={petState.speciesId}
colorId={petState.colorId}
pose={petState.pose}
idealPose={idealPose}
onChange={(species, color, _, closestPose) => {
setPetState({
speciesId: species.id,
colorId: color.id,
pose: closestPose,
});
}}
size="sm"
showPlaceholders
// This is just a UX affordance: while we could handle invalid states
// from a UI perspective, we figure that, if a pet preview is already
// visible and responsive to changes, it feels better to treat the
// changes as atomic and always-valid.
stateMustAlwaysBeValid
/>
<Box flex="1 0 0" lineHeight="1">
{isIncompatible && (
<Tooltip label="Incompatible" placement="top">
<WarningIcon
color={errorColor}
transition="color 0.2"
marginLeft="2"
/>
</Tooltip>
)}
</Box>
</Box>
</VStack>
);
}
/**
* 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}
/>
);
}
2020-09-11 23:56:47 -07:00
export default ItemPage;