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;