255 lines
7.4 KiB
JavaScript
255 lines
7.4 KiB
JavaScript
|
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;
|