impress/app/javascript/wardrobe-2020/ItemPageLayout.js
Matchu e300b2d342 Run Prettier on all wardrobe-2020 JS
Looks like the version of Prettier I just installed is v3, whereas our
last run in the impress-2020 repo was with v2. I don't think we had any
special config in that project, I think these are just changes to
Prettier's defaults, and I'm comfortable accepting them! (Mostly seems
like a lot of trailing commas.)
2023-10-24 16:45:49 -07:00

411 lines
12 KiB
JavaScript

import React from "react";
import {
Badge,
Box,
Flex,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
Portal,
Select,
Skeleton,
Spinner,
Tooltip,
useToast,
VStack,
} from "@chakra-ui/react";
import { ExternalLinkIcon, ChevronRightIcon } from "@chakra-ui/icons";
import { gql, useMutation } from "@apollo/client";
import {
ItemBadgeList,
ItemKindBadge,
ItemThumbnail,
} from "./components/ItemCard";
import { Heading1 } from "./util";
import useSupport from "./WardrobePage/support/useSupport";
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}>
<ItemKindBadgeWithSupportTools item={item} />
</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>
{item?.isNc && (
<SubtleSkeleton
isLoaded={
// Distinguish between undefined (still loading) and null (loaded
// and empty).
item?.ncTradeValueText !== undefined
}
>
{item?.ncTradeValueText && (
<LinkBadge href="http://www.neopets.com/~owls">
OWLS: {item?.ncTradeValueText}
</LinkBadge>
)}
</SubtleSkeleton>
)}
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/shops/wizard.phtml?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 ItemKindBadgeWithSupportTools({ item }) {
const { isSupportUser, supportSecret } = useSupport();
const toast = useToast();
const ncRef = React.useRef(null);
const isNcAutoDetectedFromRarity =
item?.rarityIndex === 500 || item?.rarityIndex === 0;
const [mutate, { loading }] = useMutation(gql`
mutation ItemPageSupportSetIsManuallyNc(
$itemId: ID!
$isManuallyNc: Boolean!
$supportSecret: String!
) {
setItemIsManuallyNc(
itemId: $itemId
isManuallyNc: $isManuallyNc
supportSecret: $supportSecret
) {
id
isNc
isManuallyNc
}
}
`);
if (
isSupportUser &&
item?.rarityIndex != null &&
item?.isManuallyNc != null
) {
// TODO: Could code-split this into a SupportOnly file...
return (
<Popover placement="bottom-start" initialFocusRef={ncRef} showArrow>
<PopoverTrigger>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} isEditButton />
</PopoverTrigger>
<Portal>
<PopoverContent padding="4">
<PopoverArrow />
<VStack spacing="2" align="flex-start">
<Flex align="center">
<Box as="span" fontWeight="600" marginRight="2">
NC:
</Box>
<Select
ref={ncRef}
size="xs"
value={item.isManuallyNc ? "true" : "false"}
onChange={(e) => {
const isManuallyNc = e.target.value === "true";
mutate({
variables: {
itemId: item.id,
isManuallyNc,
supportSecret,
},
optimisticResponse: {
setItemIsManuallyNc: {
__typename: "Item",
id: item.id,
isNc: isManuallyNc || isNcAutoDetectedFromRarity,
isManuallyNc,
},
},
}).catch((e) => {
console.error(e);
toast({
status: "error",
title:
"Could not set NC status for this item. Try again?",
});
});
}}
>
<option value="false">
Auto-detect: {isNcAutoDetectedFromRarity ? "Yes" : "No"}.{" "}
(Rarity {item.rarityIndex})
</option>
<option value="true">Manually set: Yes.</option>
</Select>
{loading && <Spinner size="sm" marginLeft="2" />}
</Flex>
<Flex align="center">
<Box as="span" fontWeight="600" marginRight="1">
PB:
</Box>
<Select size="xs" isReadOnly value="auto-detect">
<option value="auto-detect">
Auto-detect: {item.isPb ? "Yes" : "No"}. (from description)
</option>
<option style={{ fontStyle: "italic" }}>
(This cannot be manually set.)
</option>
</Select>
</Flex>
<Badge
colorScheme="pink"
alignSelf="flex-end"
marginBottom="-2"
marginRight="-2"
>
Support <span aria-hidden="true">💖</span>
</Badge>
</VStack>
</PopoverContent>
</Portal>
</Popover>
);
}
return <ItemKindBadge isNc={item?.isNc} isPb={item?.isPb} />;
}
const LinkBadge = React.forwardRef(
({ children, href, isEmbedded, ...props }, ref) => {
return (
<Badge
ref={ref}
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}
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
{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;