forked from OpenNeo/impress
Matchu
81b2a2b4a2
We add jsbuilding-rails to get esbuild running in the app, and then we copy-paste the files we need from impress-2020 into here! I stopped at the point where it was building successfully, but it's not running correctly: it's not sure about `process.env` in `next`, and I think the right next step is to delete the NextJS deps altogether and use React Router instead.
411 lines
12 KiB
JavaScript
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;
|