forked from OpenNeo/impress
Remove the item page drawer, just link to the item page instead
The wardrobe-2020 app had a cute drawer that embeds the item page, but honestly I don't think it was that valuable, and especially not when it means we have to basically maintain two item pages lol. Let's decrease the surface area!
This commit is contained in:
parent
a2feee2d9b
commit
a18ffb22a7
5 changed files with 22 additions and 1213 deletions
|
@ -1,30 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
DrawerOverlay,
|
||||
useBreakpointValue,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { ItemPageContent } from "./ItemPage";
|
||||
|
||||
function ItemPageDrawer({ item, isOpen, onClose }) {
|
||||
const placement = useBreakpointValue({ base: "bottom", lg: "right" });
|
||||
|
||||
return (
|
||||
<Drawer placement={placement} size="md" isOpen={isOpen} onClose={onClose}>
|
||||
<DrawerOverlay>
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerBody>
|
||||
<ItemPageContent itemId={item.id} isEmbedded />
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</DrawerOverlay>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemPageDrawer;
|
|
@ -1,411 +0,0 @@
|
|||
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="https://www.neopets.com/~owls">
|
||||
OWLS: {item?.ncTradeValueText}
|
||||
</LinkBadge>
|
||||
)}
|
||||
</SubtleSkeleton>
|
||||
)}
|
||||
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
||||
{!item?.isNc && !item?.isPb && (
|
||||
<LinkBadge
|
||||
href={
|
||||
"https://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={
|
||||
"https://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={
|
||||
"https://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={
|
||||
"https://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;
|
|
@ -1,771 +1,37 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import gql from "graphql-tag";
|
||||
import {
|
||||
AspectRatio,
|
||||
Button,
|
||||
Box,
|
||||
HStack,
|
||||
IconButton,
|
||||
SkeletonText,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
VStack,
|
||||
useBreakpointValue,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
useToast,
|
||||
Button,
|
||||
Flex,
|
||||
usePrefersReducedMotion,
|
||||
Grid,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
usePrefersReducedMotion,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
EditIcon,
|
||||
StarIcon,
|
||||
WarningIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { EditIcon, WarningIcon } from "@chakra-ui/icons";
|
||||
import { MdPause, MdPlayArrow } from "react-icons/md";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
|
||||
import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
|
||||
import {
|
||||
Delay,
|
||||
logAndCapture,
|
||||
MajorErrorMessage,
|
||||
useLocalStorage,
|
||||
} from "./util";
|
||||
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
|
||||
import {
|
||||
itemAppearanceFragment,
|
||||
petAppearanceFragment,
|
||||
} from "./components/useOutfitAppearance";
|
||||
import { useOutfitPreview } from "./components/OutfitPreview";
|
||||
import SpeciesColorPicker, {
|
||||
useAllValidPetPoses,
|
||||
getValidPoses,
|
||||
getClosestPose,
|
||||
} from "./components/SpeciesColorPicker";
|
||||
import useCurrentUser from "./components/useCurrentUser";
|
||||
import SpeciesFacesPicker, {
|
||||
colorIsBasic,
|
||||
} from "./ItemPage/SpeciesFacesPicker";
|
||||
import {
|
||||
itemAppearanceFragment,
|
||||
petAppearanceFragment,
|
||||
} from "./components/useOutfitAppearance";
|
||||
import { useOutfitPreview } from "./components/OutfitPreview";
|
||||
import { logAndCapture, useLocalStorage } from "./util";
|
||||
|
||||
// Removed for the wardrobe-2020 case.
|
||||
// TODO: Refactor this stuff, do we even need ItemPageContent really?
|
||||
// function ItemPage() {
|
||||
// const { query } = useRouter();
|
||||
// return <ItemPageContent itemId={query.itemId} />;
|
||||
// }
|
||||
|
||||
/**
|
||||
* 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 = false }) {
|
||||
const { isLoggedIn } = useCurrentUser();
|
||||
|
||||
const { error, data } = useQuery(
|
||||
gql`
|
||||
query ItemPage($itemId: ID!) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
name
|
||||
isNc
|
||||
isPb
|
||||
thumbnailUrl
|
||||
description
|
||||
createdAt
|
||||
ncTradeValueText
|
||||
|
||||
# For Support users.
|
||||
rarityIndex
|
||||
isManuallyNc
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ variables: { itemId }, returnPartialData: true },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <MajorErrorMessage error={error} />;
|
||||
}
|
||||
|
||||
const item = data?.item;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ItemPageLayout item={item} isEmbedded={isEmbedded}>
|
||||
<VStack spacing="8" marginTop="4">
|
||||
<ItemPageDescription
|
||||
description={item?.description}
|
||||
isEmbedded={isEmbedded}
|
||||
/>
|
||||
<VStack spacing="4">
|
||||
<ItemPageTradeLinks itemId={itemId} isEmbedded={isEmbedded} />
|
||||
{isLoggedIn && <ItemPageOwnWantButtons itemId={itemId} />}
|
||||
</VStack>
|
||||
{!isEmbedded && <ItemPageOutfitPreview itemId={itemId} />}
|
||||
</VStack>
|
||||
</ItemPageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageDescription({ description, isEmbedded }) {
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<Box width="100%" alignSelf="flex-start">
|
||||
{description ? (
|
||||
description
|
||||
) : description === "" ? (
|
||||
<i>(This item has no description.)</i>
|
||||
) : (
|
||||
<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>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const ITEM_PAGE_OWN_WANT_BUTTONS_QUERY = gql`
|
||||
query ItemPageOwnWantButtons($itemId: ID!) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
name
|
||||
currentUserOwnsThis
|
||||
currentUserWantsThis
|
||||
}
|
||||
currentUser {
|
||||
closetLists {
|
||||
id
|
||||
name
|
||||
isDefaultList
|
||||
ownsOrWantsItems
|
||||
hasItem(itemId: $itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function ItemPageOwnWantButtons({ itemId }) {
|
||||
const { loading, error, data } = useQuery(ITEM_PAGE_OWN_WANT_BUTTONS_QUERY, {
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <Box color="red.400">{error.message}</Box>;
|
||||
}
|
||||
|
||||
const closetLists = data?.currentUser?.closetLists || [];
|
||||
const realLists = closetLists.filter((cl) => !cl.isDefaultList);
|
||||
const ownedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "OWNS");
|
||||
const wantedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "WANTS");
|
||||
|
||||
return (
|
||||
<Grid
|
||||
templateRows="auto auto"
|
||||
templateColumns="160px 160px"
|
||||
gridAutoFlow="column"
|
||||
rowGap="0.5"
|
||||
columnGap="4"
|
||||
justifyItems="center"
|
||||
>
|
||||
<SubtleSkeleton isLoaded={!loading}>
|
||||
<ItemPageOwnButton
|
||||
itemId={itemId}
|
||||
isChecked={data?.item?.currentUserOwnsThis}
|
||||
/>
|
||||
</SubtleSkeleton>
|
||||
<ItemPageOwnWantListsDropdown
|
||||
closetLists={ownedLists}
|
||||
item={data?.item}
|
||||
// Show the dropdown if the user owns this, and has at least one custom
|
||||
// list it could belong to.
|
||||
isVisible={data?.item?.currentUserOwnsThis && ownedLists.length >= 1}
|
||||
popoverPlacement="bottom-end"
|
||||
/>
|
||||
|
||||
<SubtleSkeleton isLoaded={!loading}>
|
||||
<ItemPageWantButton
|
||||
itemId={itemId}
|
||||
isChecked={data?.item?.currentUserWantsThis}
|
||||
/>
|
||||
</SubtleSkeleton>
|
||||
<ItemPageOwnWantListsDropdown
|
||||
closetLists={wantedLists}
|
||||
item={data?.item}
|
||||
// Show the dropdown if the user wants this, and has at least one
|
||||
// custom list it could belong to.
|
||||
isVisible={data?.item?.currentUserWantsThis && wantedLists.length >= 1}
|
||||
popoverPlacement="bottom-start"
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageOwnWantListsDropdown({
|
||||
closetLists,
|
||||
item,
|
||||
isVisible,
|
||||
popoverPlacement,
|
||||
}) {
|
||||
return (
|
||||
<Popover placement={popoverPlacement}>
|
||||
<PopoverTrigger>
|
||||
<ItemPageOwnWantListsDropdownButton
|
||||
closetLists={closetLists}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent padding="2" width="64">
|
||||
<ItemPageOwnWantListsDropdownContent
|
||||
closetLists={closetLists}
|
||||
item={item}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const ItemPageOwnWantListsDropdownButton = React.forwardRef(
|
||||
({ closetLists, isVisible, ...props }, ref) => {
|
||||
const listsToShow = closetLists.filter((cl) => cl.hasItem);
|
||||
|
||||
let buttonText;
|
||||
if (listsToShow.length === 1) {
|
||||
buttonText = `In list: "${listsToShow[0].name}"`;
|
||||
} else if (listsToShow.length > 1) {
|
||||
const listNames = listsToShow.map((cl) => `"${cl.name}"`).join(", ");
|
||||
buttonText = `${listsToShow.length} lists: ${listNames}`;
|
||||
} else {
|
||||
buttonText = "Add to list";
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={ref}
|
||||
as="button"
|
||||
fontSize="xs"
|
||||
alignItems="center"
|
||||
borderRadius="sm"
|
||||
width="100%"
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
_focus={{
|
||||
textDecoration: "underline",
|
||||
outline: "0",
|
||||
boxShadow: "outline",
|
||||
}}
|
||||
// Even when the button isn't visible, we still render it for layout
|
||||
// purposes, but hidden and disabled.
|
||||
opacity={isVisible ? 1 : 0}
|
||||
aria-hidden={!isVisible}
|
||||
disabled={!isVisible}
|
||||
{...props}
|
||||
>
|
||||
{/* Flex tricks to center the text, ignoring the caret */}
|
||||
<Box flex="1 0 0" />
|
||||
<Box textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
|
||||
{buttonText}
|
||||
</Box>
|
||||
<Flex flex="1 0 0">
|
||||
<ChevronDownIcon marginLeft="1" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function ItemPageOwnWantListsDropdownContent({ closetLists, item }) {
|
||||
return (
|
||||
<Box as="ul" listStyleType="none">
|
||||
{closetLists.map((closetList) => (
|
||||
<Box key={closetList.id} as="li">
|
||||
<ItemPageOwnWantsListsDropdownRow
|
||||
closetList={closetList}
|
||||
item={item}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageOwnWantsListsDropdownRow({ closetList, item }) {
|
||||
const toast = useToast();
|
||||
|
||||
const [sendAddToListMutation] = useMutation(
|
||||
gql`
|
||||
mutation ItemPage_AddToClosetList($listId: ID!, $itemId: ID!) {
|
||||
addItemToClosetList(
|
||||
listId: $listId
|
||||
itemId: $itemId
|
||||
removeFromDefaultList: true
|
||||
) {
|
||||
id
|
||||
hasItem(itemId: $itemId)
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ context: { sendAuth: true } },
|
||||
);
|
||||
|
||||
const [sendRemoveFromListMutation] = useMutation(
|
||||
gql`
|
||||
mutation ItemPage_RemoveFromClosetList($listId: ID!, $itemId: ID!) {
|
||||
removeItemFromClosetList(
|
||||
listId: $listId
|
||||
itemId: $itemId
|
||||
ensureInSomeList: true
|
||||
) {
|
||||
id
|
||||
hasItem(itemId: $itemId)
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ context: { sendAuth: true } },
|
||||
);
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(e) => {
|
||||
if (e.target.checked) {
|
||||
sendAddToListMutation({
|
||||
variables: { listId: closetList.id, itemId: item.id },
|
||||
optimisticResponse: {
|
||||
addItemToClosetList: {
|
||||
__typename: "ClosetList",
|
||||
id: closetList.id,
|
||||
hasItem: true,
|
||||
},
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
toast({
|
||||
status: "error",
|
||||
title: `Oops, error adding "${item.name}" to "${closetList.name}!"`,
|
||||
description:
|
||||
"Check your connection and try again? Sorry about this!",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
sendRemoveFromListMutation({
|
||||
variables: { listId: closetList.id, itemId: item.id },
|
||||
optimisticResponse: {
|
||||
removeItemFromClosetList: {
|
||||
__typename: "ClosetList",
|
||||
id: closetList.id,
|
||||
hasItem: false,
|
||||
},
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
toast({
|
||||
status: "error",
|
||||
title: `Oops, error removing "${item.name}" from "${closetList.name}!"`,
|
||||
description:
|
||||
"Check your connection and try again? Sorry about this!",
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
closetList,
|
||||
item,
|
||||
sendAddToListMutation,
|
||||
sendRemoveFromListMutation,
|
||||
toast,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
width="100%"
|
||||
value={closetList.id}
|
||||
isChecked={closetList.hasItem}
|
||||
onChange={onChange}
|
||||
>
|
||||
{closetList.name}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageOwnButton({ itemId, isChecked }) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
|
||||
const [sendAddMutation] = useMutation(
|
||||
gql`
|
||||
mutation ItemPageOwnButtonAdd($itemId: ID!) {
|
||||
addToItemsCurrentUserOwns(itemId: $itemId) {
|
||||
id
|
||||
currentUserOwnsThis
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
addToItemsCurrentUserOwns: {
|
||||
__typename: "Item",
|
||||
id: itemId,
|
||||
currentUserOwnsThis: true,
|
||||
},
|
||||
},
|
||||
// TODO: Refactor the mutation result to include closet lists
|
||||
refetchQueries: [
|
||||
{
|
||||
query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const [sendRemoveMutation] = useMutation(
|
||||
gql`
|
||||
mutation ItemPageOwnButtonRemove($itemId: ID!) {
|
||||
removeFromItemsCurrentUserOwns(itemId: $itemId) {
|
||||
id
|
||||
currentUserOwnsThis
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
removeFromItemsCurrentUserOwns: {
|
||||
__typename: "Item",
|
||||
id: itemId,
|
||||
currentUserOwnsThis: false,
|
||||
},
|
||||
},
|
||||
// TODO: Refactor the mutation result to include closet lists
|
||||
refetchQueries: [
|
||||
{
|
||||
query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box as="label">
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
sendAddMutation().catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
title: "We had trouble adding this to the items you own.",
|
||||
description:
|
||||
"Check your internet connection, and try again.",
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
sendRemoveMutation().catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
title:
|
||||
"We had trouble removing this from the items you own.",
|
||||
description:
|
||||
"Check your internet connection, and try again.",
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
as="div"
|
||||
colorScheme={isChecked ? "green" : "gray"}
|
||||
size="lg"
|
||||
cursor="pointer"
|
||||
transitionDuration="0.4s"
|
||||
className={css`
|
||||
input:focus + & {
|
||||
box-shadow: ${theme.shadows.outline};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<IconCheckbox
|
||||
icon={<CheckIcon />}
|
||||
isChecked={isChecked}
|
||||
marginRight="0.5em"
|
||||
/>
|
||||
I own this
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageWantButton({ itemId, isChecked }) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
|
||||
const [sendAddMutation] = useMutation(
|
||||
gql`
|
||||
mutation ItemPageWantButtonAdd($itemId: ID!) {
|
||||
addToItemsCurrentUserWants(itemId: $itemId) {
|
||||
id
|
||||
currentUserWantsThis
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
addToItemsCurrentUserWants: {
|
||||
__typename: "Item",
|
||||
id: itemId,
|
||||
currentUserWantsThis: true,
|
||||
},
|
||||
},
|
||||
// TODO: Refactor the mutation result to include closet lists
|
||||
refetchQueries: [
|
||||
{
|
||||
query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const [sendRemoveMutation] = useMutation(
|
||||
gql`
|
||||
mutation ItemPageWantButtonRemove($itemId: ID!) {
|
||||
removeFromItemsCurrentUserWants(itemId: $itemId) {
|
||||
id
|
||||
currentUserWantsThis
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
removeFromItemsCurrentUserWants: {
|
||||
__typename: "Item",
|
||||
id: itemId,
|
||||
currentUserWantsThis: false,
|
||||
},
|
||||
},
|
||||
// TODO: Refactor the mutation result to include closet lists
|
||||
refetchQueries: [
|
||||
{
|
||||
query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
|
||||
variables: { itemId },
|
||||
context: { sendAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box as="label">
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
sendAddMutation().catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
title: "We had trouble adding this to the items you want.",
|
||||
description:
|
||||
"Check your internet connection, and try again.",
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
sendRemoveMutation().catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
title:
|
||||
"We had trouble removing this from the items you want.",
|
||||
description:
|
||||
"Check your internet connection, and try again.",
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
as="div"
|
||||
colorScheme={isChecked ? "blue" : "gray"}
|
||||
size="lg"
|
||||
cursor="pointer"
|
||||
transitionDuration="0.4s"
|
||||
className={css`
|
||||
input:focus + & {
|
||||
box-shadow: ${theme.shadows.outline};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<IconCheckbox
|
||||
icon={<StarIcon />}
|
||||
isChecked={isChecked}
|
||||
marginRight="0.5em"
|
||||
/>
|
||||
I want this
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageTradeLinks({ itemId, isEmbedded }) {
|
||||
const { data, loading, error } = useQuery(
|
||||
gql`
|
||||
query ItemPageTradeLinks($itemId: ID!) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
numUsersOfferingThis
|
||||
numUsersSeekingThis
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ variables: { itemId } },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Box color="red.400">{error.message}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack spacing="2">
|
||||
<Box as="header" fontSize="sm" fontWeight="bold">
|
||||
Trading:
|
||||
</Box>
|
||||
<SubtleSkeleton isLoaded={!loading}>
|
||||
<ItemPageTradeLink
|
||||
href={`/items/${itemId}/trades/offering`}
|
||||
count={data?.item?.numUsersOfferingThis || 0}
|
||||
label="offering"
|
||||
colorScheme="green"
|
||||
isEmbedded={isEmbedded}
|
||||
/>
|
||||
</SubtleSkeleton>
|
||||
<SubtleSkeleton isLoaded={!loading}>
|
||||
<ItemPageTradeLink
|
||||
href={`/items/${itemId}/trades/seeking`}
|
||||
count={data?.item?.numUsersSeekingThis || 0}
|
||||
label="seeking"
|
||||
colorScheme="blue"
|
||||
isEmbedded={isEmbedded}
|
||||
/>
|
||||
</SubtleSkeleton>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageTradeLink({ href, count, label, colorScheme, isEmbedded }) {
|
||||
return (
|
||||
<Button
|
||||
as="a"
|
||||
href={href}
|
||||
target={isEmbedded ? "_blank" : undefined}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme={colorScheme}
|
||||
borderRadius="full"
|
||||
paddingRight="1"
|
||||
>
|
||||
<Box display="grid" gridTemplateAreas="single-area">
|
||||
<Box gridArea="single-area" display="flex" justifyContent="center">
|
||||
{count} {label} <ChevronRightIcon minHeight="1.2em" />
|
||||
</Box>
|
||||
<Box
|
||||
gridArea="single-area"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
visibility="hidden"
|
||||
>
|
||||
888 offering <ChevronRightIcon minHeight="1.2em" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemPageOutfitPreview({ itemId }) {
|
||||
function ItemPageOutfitPreview({ itemId }) {
|
||||
const idealPose = React.useMemo(
|
||||
() => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
|
||||
[],
|
||||
|
@ -1260,10 +526,7 @@ function PlayPauseButton({ isPaused, onClick }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function ItemZonesInfo({
|
||||
compatibleBodiesAndTheirZones,
|
||||
restrictedZones,
|
||||
}) {
|
||||
function ItemZonesInfo({ compatibleBodiesAndTheirZones, restrictedZones }) {
|
||||
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
|
||||
// merging zones with the same label, because that's how user-facing zone UI
|
||||
// generally works!
|
||||
|
@ -1434,3 +697,5 @@ function buildSortKeyForZoneLabelsAndTheirBodies({ zoneLabel, bodies }) {
|
|||
|
||||
return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
|
||||
}
|
||||
|
||||
export default ItemPageOutfitPreview;
|
|
@ -24,7 +24,6 @@ import {
|
|||
import SupportOnly from "./support/SupportOnly";
|
||||
import useSupport from "./support/useSupport";
|
||||
|
||||
const LoadableItemPageDrawer = loadable(() => import("../ItemPageDrawer"));
|
||||
const LoadableItemSupportDrawer = loadable(() =>
|
||||
import("./support/ItemSupportDrawer"),
|
||||
);
|
||||
|
@ -56,7 +55,6 @@ function Item({
|
|||
onRemove,
|
||||
isDisabled = false,
|
||||
}) {
|
||||
const [infoDrawerIsOpen, setInfoDrawerIsOpen] = React.useState(false);
|
||||
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
|
@ -97,24 +95,10 @@ function Item({
|
|||
icon={<InfoIcon />}
|
||||
label="More info"
|
||||
to={`/items/${item.id}`}
|
||||
onClick={(e) => {
|
||||
const willProbablyOpenInNewTab =
|
||||
e.metaKey || e.shiftKey || e.altKey || e.ctrlKey;
|
||||
if (willProbablyOpenInNewTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInfoDrawerIsOpen(true);
|
||||
e.preventDefault();
|
||||
}}
|
||||
target="_blank"
|
||||
/>
|
||||
</Box>
|
||||
</ItemContainer>
|
||||
<LoadableItemPageDrawer
|
||||
item={item}
|
||||
isOpen={infoDrawerIsOpen}
|
||||
onClose={() => setInfoDrawerIsOpen(false)}
|
||||
/>
|
||||
<SupportOnly>
|
||||
<LoadableItemSupportDrawer
|
||||
item={item}
|
||||
|
@ -232,7 +216,7 @@ function ItemBadges({ item }) {
|
|||
/**
|
||||
* ItemActionButton is one of a list of actions a user can take for this item.
|
||||
*/
|
||||
function ItemActionButton({ icon, label, to, onClick }) {
|
||||
function ItemActionButton({ icon, label, to, onClick, ...props }) {
|
||||
const theme = useTheme();
|
||||
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
|
@ -249,6 +233,7 @@ function ItemActionButton({ icon, label, to, onClick }) {
|
|||
{({ css }) => (
|
||||
<Tooltip label={label} placement="top">
|
||||
<LinkOrButton
|
||||
{...props}
|
||||
component={IconButton}
|
||||
href={to}
|
||||
icon={icon}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import AppProvider from "./AppProvider";
|
||||
import { ItemPageOutfitPreview } from "./ItemPage";
|
||||
import ItemPageOutfitPreview from "./ItemPageOutfitPreview";
|
||||
import WardrobePage from "./WardrobePage";
|
||||
|
||||
export { AppProvider, ItemPageOutfitPreview, WardrobePage };
|
||||
|
|
Loading…
Reference in a new issue