forked from OpenNeo/impress
Emi Matchu
c13c6e7bd8
Now that we're tracking tab state ourselves, it's pretty easy to just pass the `initialFocusRef` to the right place instead of to both! This helps switching between the tabs feel a lot smoother, because we don't have to re-render and fade-in all the poses again.
1028 lines
30 KiB
JavaScript
1028 lines
30 KiB
JavaScript
import React from "react";
|
|
import gql from "graphql-tag";
|
|
import { useQuery } from "@apollo/client";
|
|
import { ClassNames } from "@emotion/react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Flex,
|
|
Popover,
|
|
PopoverArrow,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
Portal,
|
|
Tab,
|
|
Tabs,
|
|
TabList,
|
|
TabPanel,
|
|
TabPanels,
|
|
VisuallyHidden,
|
|
useBreakpointValue,
|
|
useColorModeValue,
|
|
useTheme,
|
|
useToast,
|
|
useToken,
|
|
} from "@chakra-ui/react";
|
|
import { ChevronDownIcon, WarningTwoIcon } from "@chakra-ui/icons";
|
|
import { loadable } from "../util";
|
|
|
|
import { petAppearanceFragment } from "../components/useOutfitAppearance";
|
|
import getVisibleLayers from "../components/getVisibleLayers";
|
|
import { OutfitLayers } from "../components/OutfitPreview";
|
|
import SupportOnly from "./support/SupportOnly";
|
|
import { useAltStylesForSpecies } from "../loaders/alt-styles";
|
|
import useSupport from "./support/useSupport";
|
|
import { useLocalStorage } from "../util";
|
|
|
|
// From https://twemoji.twitter.com/, thank you!
|
|
import twemojiSmile from "../images/twemoji/smile.svg";
|
|
import twemojiCry from "../images/twemoji/cry.svg";
|
|
import twemojiSick from "../images/twemoji/sick.svg";
|
|
import twemojiSunglasses from "../images/twemoji/sunglasses.svg";
|
|
import twemojiQuestion from "../images/twemoji/question.svg";
|
|
import twemojiMasc from "../images/twemoji/masc.svg";
|
|
import twemojiFem from "../images/twemoji/fem.svg";
|
|
import twemojiHourglass from "../images/twemoji/hourglass.svg";
|
|
|
|
const PosePickerSupport = loadable(() => import("./support/PosePickerSupport"));
|
|
|
|
const PosePickerSupportSwitch = loadable(() =>
|
|
import("./support/PosePickerSupport").then((m) => m.PosePickerSupportSwitch),
|
|
);
|
|
|
|
/**
|
|
* PosePicker shows the pet poses available on the current species/color, and
|
|
* lets the user choose which want they want!
|
|
*
|
|
* NOTE: This component is memoized with React.memo. It's relatively expensive
|
|
* to re-render on every outfit change - the contents update even if the
|
|
* popover is closed! This makes wearing/unwearing items noticeably
|
|
* slower on lower-power devices.
|
|
*
|
|
* So, instead of using `outfitState` like most components, we specify
|
|
* exactly which props we need, so that `React.memo` can see the changes
|
|
* that matter, and skip updates that don't.
|
|
*/
|
|
function PosePicker({
|
|
speciesId,
|
|
colorId,
|
|
pose,
|
|
altStyleId,
|
|
appearanceId,
|
|
dispatchToOutfit,
|
|
onLockFocus,
|
|
onUnlockFocus,
|
|
...props
|
|
}) {
|
|
const initialFocusRef = React.useRef();
|
|
const posesQuery = usePoses(speciesId, colorId, pose);
|
|
const altStylesQuery = useAltStylesForSpecies(speciesId);
|
|
const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
|
|
"DTIPosePickerIsInSupportMode",
|
|
false,
|
|
);
|
|
const { isSupportUser } = useSupport();
|
|
const toast = useToast();
|
|
|
|
const loading = posesQuery.loading || altStylesQuery.isLoading;
|
|
const error = posesQuery.error ?? altStylesQuery.error;
|
|
const poseInfos = posesQuery.poseInfos;
|
|
const altStyles = altStylesQuery.data ?? [];
|
|
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
const [tabIndex, setTabIndex] = React.useState(0);
|
|
|
|
const altStyle = altStyles.find((s) => s.id === altStyleId);
|
|
|
|
const placement = useBreakpointValue({ base: "bottom-end", md: "top-end" });
|
|
|
|
React.useEffect(() => {
|
|
// While the popover is open, don't change which tab is open.
|
|
if (isOpen) {
|
|
return;
|
|
}
|
|
|
|
// Otherwise, set the tab to Styles if we're wearing an Alt Style, or
|
|
// Expressions if we're not.
|
|
setTabIndex(altStyle != null ? 1 : 0);
|
|
}, [altStyle, isOpen]);
|
|
|
|
// Resize the Popover when we toggle support mode, because it probably will
|
|
// affect the content size.
|
|
React.useLayoutEffect(() => {
|
|
// HACK: To trigger a Popover resize, we simulate a window resize event,
|
|
// because Popover listens for window resizes to reposition itself.
|
|
// I've also filed an issue requesting an official API!
|
|
// https://github.com/chakra-ui/chakra-ui/issues/1853
|
|
window.dispatchEvent(new Event("resize"));
|
|
}, [isInSupportMode]);
|
|
|
|
// Generally, the app tries to never put us in an invalid pose state. But it
|
|
// can happen with direct URL navigation, or pet loading when modeling isn't
|
|
// updated! Let's do some recovery.
|
|
const selectedPoseIsAvailable = Object.values(poseInfos).some(
|
|
(pi) => pi.isSelected && pi.isAvailable,
|
|
);
|
|
const firstAvailablePose = Object.values(poseInfos).find(
|
|
(pi) => pi.isAvailable,
|
|
)?.pose;
|
|
React.useEffect(() => {
|
|
if (loading) {
|
|
return;
|
|
}
|
|
|
|
if (!selectedPoseIsAvailable) {
|
|
if (!firstAvailablePose) {
|
|
// TODO: I suppose this error would fit better in SpeciesColorPicker!
|
|
toast({
|
|
status: "error",
|
|
title: "Oops, we don't have data for this pet color!",
|
|
description:
|
|
"If it's new, this might be a modeling issue—try modeling it on " +
|
|
"Classic DTI first. Sorry!",
|
|
duration: null,
|
|
isClosable: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.warn(
|
|
`Pose ${pose} not found for speciesId=${speciesId}, ` +
|
|
`colorId=${colorId}. Redirecting to pose ${firstAvailablePose}.`,
|
|
);
|
|
dispatchToOutfit({ type: "setPose", pose: firstAvailablePose });
|
|
}
|
|
}, [
|
|
loading,
|
|
selectedPoseIsAvailable,
|
|
firstAvailablePose,
|
|
speciesId,
|
|
colorId,
|
|
pose,
|
|
toast,
|
|
dispatchToOutfit,
|
|
]);
|
|
|
|
// This is a low-stakes enough control, where enough pairs don't have data
|
|
// anyway, that I think I want to just not draw attention to failures.
|
|
if (error) {
|
|
return null;
|
|
}
|
|
|
|
const numStandardPoses = Object.values(poseInfos).filter(
|
|
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
|
|
).length;
|
|
|
|
const onChangePose = (e) => {
|
|
dispatchToOutfit({ type: "setPose", pose: e.target.value });
|
|
};
|
|
|
|
const onChangeStyle = (altStyleId) => {
|
|
dispatchToOutfit({ type: "setStyle", altStyleId });
|
|
};
|
|
|
|
return (
|
|
<Popover
|
|
placement={placement}
|
|
returnFocusOnClose
|
|
onOpen={() => {
|
|
setIsOpen(true);
|
|
onLockFocus();
|
|
}}
|
|
onClose={() => {
|
|
setIsOpen(false);
|
|
onUnlockFocus();
|
|
}}
|
|
initialFocusRef={initialFocusRef}
|
|
isLazy
|
|
lazyBehavior="keepMounted"
|
|
>
|
|
{() => (
|
|
<>
|
|
<PopoverTrigger>
|
|
<PosePickerButton
|
|
pose={pose}
|
|
altStyle={altStyle}
|
|
isOpen={isOpen}
|
|
loading={loading}
|
|
{...props}
|
|
/>
|
|
</PopoverTrigger>
|
|
<Portal>
|
|
<PopoverContent>
|
|
<Tabs
|
|
size="sm"
|
|
variant="soft-rounded"
|
|
display="flex"
|
|
flexDirection={{ base: "column", md: "column-reverse" }}
|
|
paddingY="2"
|
|
gap="2"
|
|
index={tabIndex}
|
|
onChange={setTabIndex}
|
|
>
|
|
<TabList paddingX="2" paddingY="0">
|
|
<Tab width="50%">Expressions</Tab>
|
|
<Tab width="50%">Styles</Tab>
|
|
</TabList>
|
|
<TabPanels position="relative">
|
|
<TabPanel paddingX="4" paddingY="0">
|
|
{isInSupportMode ? (
|
|
<PosePickerSupport
|
|
speciesId={speciesId}
|
|
colorId={colorId}
|
|
pose={pose}
|
|
appearanceId={appearanceId}
|
|
initialFocusRef={
|
|
tabIndex === 0 ? initialFocusRef : null
|
|
}
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
/>
|
|
) : (
|
|
<>
|
|
<PosePickerTable
|
|
poseInfos={poseInfos}
|
|
onChange={onChangePose}
|
|
initialFocusRef={
|
|
tabIndex === 0 ? initialFocusRef : null
|
|
}
|
|
/>
|
|
{numStandardPoses == 0 && (
|
|
<PosePickerEmptyExplanation />
|
|
)}
|
|
</>
|
|
)}
|
|
<SupportOnly>
|
|
<Box position="absolute" top="1" left="3">
|
|
<PosePickerSupportSwitch
|
|
isChecked={isInSupportMode}
|
|
onChange={(e) => setIsInSupportMode(e.target.checked)}
|
|
/>
|
|
</Box>
|
|
</SupportOnly>
|
|
</TabPanel>
|
|
<TabPanel paddingX="4" paddingY="0">
|
|
<StyleSelect
|
|
selectedStyleId={altStyleId}
|
|
altStyles={altStyles}
|
|
onChange={onChangeStyle}
|
|
initialFocusRef={tabIndex === 1 ? initialFocusRef : null}
|
|
/>
|
|
<StyleExplanation />
|
|
</TabPanel>
|
|
</TabPanels>
|
|
</Tabs>
|
|
<PopoverArrow />
|
|
</PopoverContent>
|
|
</Portal>
|
|
</>
|
|
)}
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function PosePickerButton({ pose, altStyle, isOpen, loading, ...props }, ref) {
|
|
const theme = useTheme();
|
|
|
|
const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
|
|
const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
|
|
|
|
return (
|
|
<ClassNames>
|
|
{({ css, cx }) => (
|
|
<Button
|
|
variant="unstyled"
|
|
textShadow={`${theme.colors.blackAlpha["700"]} 0 1px 2px`}
|
|
d="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
_focus={{ borderColor: "gray.50" }}
|
|
_hover={{ borderColor: "gray.50" }}
|
|
outline="initial"
|
|
fontSize="sm"
|
|
fontWeight="normal"
|
|
minWidth="12ch"
|
|
disabled={loading}
|
|
className={cx(
|
|
css`
|
|
border: 1px solid transparent !important;
|
|
color: ${theme.colors.gray["100"]};
|
|
cursor: ${loading ? "wait" : "pointer"} !important;
|
|
transition:
|
|
color 0.2s,
|
|
background: 0.2s,
|
|
border-color 0.2s !important;
|
|
padding-left: 0.75em;
|
|
padding-right: 0.5em;
|
|
|
|
&:hover,
|
|
&.is-open {
|
|
border-color: ${theme.colors.gray["50"]} !important;
|
|
color: ${theme.colors.gray["50"]};
|
|
background: ${theme.colors.blackAlpha["600"]};
|
|
text-shadow: transparent 0 1px 2px;
|
|
}
|
|
|
|
&:focus {
|
|
border-color: ${theme.colors.gray["50"]} !important;
|
|
box-shadow: ${theme.shadows.outline};
|
|
}
|
|
|
|
&.is-open {
|
|
border-width: 2px !important;
|
|
}
|
|
`,
|
|
isOpen && "is-open",
|
|
)}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<EmojiImage src={icon} alt="Style" />
|
|
<Box width=".5em" />
|
|
{label}
|
|
<Box width=".5em" />
|
|
<ChevronDownIcon />
|
|
</Button>
|
|
)}
|
|
</ClassNames>
|
|
);
|
|
}
|
|
PosePickerButton = React.forwardRef(PosePickerButton);
|
|
|
|
function PosePickerTable({ poseInfos, onChange, initialFocusRef }) {
|
|
return (
|
|
<Box display="flex" flexDirection="column" alignItems="center">
|
|
<table width="100%">
|
|
<thead>
|
|
<tr>
|
|
<th />
|
|
<Cell as="th">
|
|
<EmojiImage src={twemojiSmile} alt="Happy" />
|
|
</Cell>
|
|
<Cell as="th">
|
|
<EmojiImage src={twemojiCry} alt="Sad" />
|
|
</Cell>
|
|
<Cell as="th">
|
|
<EmojiImage src={twemojiSick} alt="Sick" />
|
|
</Cell>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<Cell as="th">
|
|
<EmojiImage src={twemojiMasc} alt="Masculine" />
|
|
</Cell>
|
|
<Cell as="td">
|
|
<PoseOption
|
|
poseInfo={poseInfos.happyMasc}
|
|
onChange={onChange}
|
|
inputRef={poseInfos.happyMasc.isSelected && initialFocusRef}
|
|
/>
|
|
</Cell>
|
|
<Cell as="td">
|
|
<PoseOption
|
|
poseInfo={poseInfos.sadMasc}
|
|
onChange={onChange}
|
|
inputRef={poseInfos.sadMasc.isSelected && initialFocusRef}
|
|
/>
|
|
</Cell>
|
|
<Cell as="td">
|
|
<PoseOption
|
|
poseInfo={poseInfos.sickMasc}
|
|
onChange={onChange}
|
|
inputRef={poseInfos.sickMasc.isSelected && initialFocusRef}
|
|
/>
|
|
</Cell>
|
|
</tr>
|
|
<tr>
|
|
<Cell as="th">
|
|
<EmojiImage src={twemojiFem} alt="Feminine" />
|
|
</Cell>
|
|
<Cell as="td">
|
|
<PoseOption
|
|
poseInfo={poseInfos.happyFem}
|
|
onChange={onChange}
|
|
inputRef={poseInfos.happyFem.isSelected && initialFocusRef}
|
|
/>
|
|
</Cell>
|
|
<Cell as="td">
|
|
<PoseOption
|
|
poseInfo={poseInfos.sadFem}
|
|
onChange={onChange}
|
|
inputRef={poseInfos.sadFem.isSelected && initialFocusRef}
|
|
/>
|
|
</Cell>
|
|
<Cell as="td">
|
|
<PoseOption
|
|
poseInfo={poseInfos.sickFem}
|
|
onChange={onChange}
|
|
inputRef={poseInfos.sickFem.isSelected && initialFocusRef}
|
|
/>
|
|
</Cell>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
{poseInfos.unconverted.isAvailable && (
|
|
<Flex
|
|
align="center"
|
|
justify="center"
|
|
gap="1"
|
|
marginTop="2"
|
|
marginBottom="2"
|
|
>
|
|
<PoseOption
|
|
poseInfo={poseInfos.unconverted}
|
|
onChange={onChange}
|
|
inputRef={poseInfos.unconverted.isSelected && initialFocusRef}
|
|
size="sm"
|
|
label="Retired UC"
|
|
/>
|
|
<RetiredUCWarning isSelected={poseInfos.unconverted.isSelected} />
|
|
</Flex>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function Cell({ children, as }) {
|
|
const Tag = as;
|
|
return (
|
|
<Tag>
|
|
<Flex justify="center" p="1">
|
|
{children}
|
|
</Flex>
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
const EMOTION_STRINGS = {
|
|
HAPPY_MASC: "Happy",
|
|
HAPPY_FEM: "Happy",
|
|
SAD_MASC: "Sad",
|
|
SAD_FEM: "Sad",
|
|
SICK_MASC: "Sick",
|
|
SICK_FEM: "Sick",
|
|
};
|
|
|
|
const GENDER_PRESENTATION_STRINGS = {
|
|
HAPPY_MASC: "Masculine",
|
|
SAD_MASC: "Masculine",
|
|
SICK_MASC: "Masculine",
|
|
HAPPY_FEM: "Feminine",
|
|
SAD_FEM: "Feminine",
|
|
SICK_FEM: "Feminine",
|
|
};
|
|
|
|
const STANDARD_POSES = Object.keys(EMOTION_STRINGS);
|
|
|
|
function PoseOption({
|
|
poseInfo,
|
|
onChange,
|
|
inputRef,
|
|
size = "md",
|
|
label,
|
|
...otherProps
|
|
}) {
|
|
const theme = useTheme();
|
|
const genderPresentationStr = GENDER_PRESENTATION_STRINGS[poseInfo.pose];
|
|
const emotionStr = EMOTION_STRINGS[poseInfo.pose];
|
|
|
|
let poseName =
|
|
poseInfo.pose === "UNCONVERTED"
|
|
? "Unconverted"
|
|
: `${emotionStr} and ${genderPresentationStr}`;
|
|
if (!poseInfo.isAvailable) {
|
|
poseName += ` (not modeled yet)`;
|
|
}
|
|
|
|
const borderColor = useColorModeValue(
|
|
theme.colors.green["600"],
|
|
theme.colors.green["300"],
|
|
);
|
|
|
|
return (
|
|
<ClassNames>
|
|
{({ css, cx }) => (
|
|
<Box
|
|
as="label"
|
|
cursor="pointer"
|
|
display="flex"
|
|
alignItems="center"
|
|
borderColor={poseInfo.isSelected ? borderColor : "gray.400"}
|
|
boxShadow={label ? "md" : "none"}
|
|
borderWidth={label ? "1px" : "0"}
|
|
borderRadius={label ? "full" : "0"}
|
|
paddingRight={label ? "3" : "0"}
|
|
onClick={(e) => {
|
|
// HACK: We need the timeout to beat the popover's focus stealing!
|
|
const input = e.currentTarget.querySelector("input");
|
|
setTimeout(() => input.focus(), 0);
|
|
}}
|
|
{...otherProps}
|
|
>
|
|
<VisuallyHidden
|
|
as="input"
|
|
type="radio"
|
|
aria-label={poseName}
|
|
name="pose"
|
|
value={poseInfo.pose}
|
|
checked={poseInfo.isSelected}
|
|
disabled={!poseInfo.isAvailable}
|
|
onChange={onChange}
|
|
ref={inputRef || null}
|
|
/>
|
|
<Box
|
|
aria-hidden
|
|
borderRadius="full"
|
|
boxShadow="md"
|
|
overflow="hidden"
|
|
width={size === "sm" ? "30px" : "50px"}
|
|
height={size === "sm" ? "30px" : "50px"}
|
|
title={
|
|
poseInfo.isAvailable
|
|
? // A lil debug output, so that we can quickly identify glitched
|
|
// PetStates and manually mark them as glitched!
|
|
window.location.hostname.includes("localhost") &&
|
|
`#${poseInfo.id}`
|
|
: "Not modeled yet"
|
|
}
|
|
position="relative"
|
|
className={css`
|
|
transform: scale(0.8);
|
|
opacity: 0.8;
|
|
transition: all 0.2s;
|
|
|
|
input:checked + & {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
`}
|
|
>
|
|
<Box
|
|
borderRadius="full"
|
|
position="absolute"
|
|
top="0"
|
|
bottom="0"
|
|
left="0"
|
|
right="0"
|
|
zIndex="2"
|
|
className={cx(
|
|
css`
|
|
border: 0px solid ${borderColor};
|
|
transition: border-width 0.2s;
|
|
|
|
&.not-available {
|
|
border-color: ${theme.colors.gray["500"]};
|
|
border-width: 1px;
|
|
}
|
|
|
|
input:checked + * & {
|
|
border-width: 1px;
|
|
}
|
|
|
|
input:focus + * & {
|
|
border-width: 3px;
|
|
}
|
|
`,
|
|
!poseInfo.isAvailable && "not-available",
|
|
)}
|
|
/>
|
|
{poseInfo.isAvailable ? (
|
|
<Box
|
|
width="100%"
|
|
height="100%"
|
|
transform={getTransform(poseInfo)}
|
|
>
|
|
<OutfitLayers visibleLayers={getVisibleLayers(poseInfo, [])} />
|
|
</Box>
|
|
) : (
|
|
<Flex align="center" justify="center" width="100%" height="100%">
|
|
<EmojiImage src={twemojiQuestion} boxSize={24} />
|
|
</Flex>
|
|
)}
|
|
</Box>
|
|
{label && (
|
|
<Box
|
|
marginLeft="2"
|
|
fontSize="xs"
|
|
fontWeight={poseInfo.isSelected ? "bold" : "normal"}
|
|
>
|
|
{label}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</ClassNames>
|
|
);
|
|
}
|
|
|
|
function PosePickerEmptyExplanation() {
|
|
return (
|
|
<Box
|
|
fontSize="xs"
|
|
fontStyle="italic"
|
|
textAlign="center"
|
|
opacity="0.7"
|
|
marginTop="2"
|
|
>
|
|
We're still working on labeling these! For now, we're just giving you one
|
|
of the expressions we have.
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function RetiredUCWarning({ isSelected }) {
|
|
return (
|
|
<Popover placement="right" trigger="hover">
|
|
<PopoverTrigger>
|
|
<Box
|
|
as="button"
|
|
tabIndex="0"
|
|
aria-label="Warning"
|
|
cursor="help"
|
|
lineHeight="1"
|
|
opacity={isSelected ? "1" : "0.75"}
|
|
transform={isSelected ? "scale(1)" : "scale(0.8)"}
|
|
color={isSelected ? "yellow.500" : "inherit"}
|
|
transition="all 0.2s"
|
|
padding="1"
|
|
>
|
|
<WarningTwoIcon />
|
|
</Box>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
background="blackAlpha.800"
|
|
borderColor="blackAlpha.900"
|
|
color="white"
|
|
padding="2"
|
|
fontSize="sm"
|
|
>
|
|
"Unconverted" pets are no longer available on Neopets.com, and have been
|
|
replaced with the very similar Styles feature. We're just keeping this
|
|
as an archive!
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function StyleSelect({
|
|
selectedStyleId,
|
|
altStyles,
|
|
onChange,
|
|
initialFocusRef,
|
|
}) {
|
|
const defaultStyle = { id: null, adjectiveName: "Default" };
|
|
|
|
const styles = [defaultStyle, ...altStyles];
|
|
|
|
return (
|
|
<Flex
|
|
as="form"
|
|
direction="column"
|
|
gap="2"
|
|
maxHeight="40vh"
|
|
padding="4px"
|
|
overflow="auto"
|
|
>
|
|
{styles.map((altStyle) => (
|
|
<StyleOption
|
|
key={altStyle.id}
|
|
altStyle={altStyle}
|
|
checked={selectedStyleId === altStyle.id}
|
|
onChange={onChange}
|
|
inputRef={selectedStyleId === altStyle.id ? initialFocusRef : null}
|
|
/>
|
|
))}
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
function StyleOption({ altStyle, checked, onChange, inputRef }) {
|
|
const theme = useTheme();
|
|
const selectedBorderColor = useColorModeValue(
|
|
theme.colors.green["600"],
|
|
theme.colors.green["300"],
|
|
);
|
|
const outlineShadow = useToken("shadows", "outline");
|
|
|
|
return (
|
|
<ClassNames>
|
|
{({ css, cx }) => (
|
|
<Box
|
|
as="label"
|
|
cursor="pointer"
|
|
onClick={(e) => {
|
|
// HACK: We need the timeout to beat the popover's focus stealing!
|
|
const input = e.currentTarget.querySelector("input");
|
|
setTimeout(() => input.focus(), 0);
|
|
}}
|
|
>
|
|
<VisuallyHidden
|
|
as="input"
|
|
type="radio"
|
|
name="style"
|
|
value={altStyle.id}
|
|
checked={checked}
|
|
onChange={(e) => onChange(altStyle.id)}
|
|
ref={inputRef}
|
|
/>
|
|
<Flex
|
|
alignItems="center"
|
|
gap="2"
|
|
borderRadius="md"
|
|
// HACK: Don't let the thumbnail image overlap the border
|
|
paddingLeft="2px"
|
|
paddingY="2px"
|
|
className={cx(css`
|
|
border: 2px solid transparent;
|
|
opacity: 0.8;
|
|
transition: all 0.2s;
|
|
|
|
input:focus + & {
|
|
box-shadow: ${outlineShadow};
|
|
}
|
|
|
|
input:checked + & {
|
|
border-color: ${selectedBorderColor};
|
|
opacity: 1;
|
|
font-weight: bold;
|
|
}
|
|
`)}
|
|
>
|
|
{altStyle.thumbnailUrl ? (
|
|
<Box
|
|
as="img"
|
|
src={altStyle.thumbnailUrl}
|
|
alt="Item thumbnail"
|
|
width="40px"
|
|
height="40px"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<Box width="40px" height="40px" />
|
|
)}
|
|
<Box width="2" />
|
|
{altStyle.adjectiveName}
|
|
</Flex>
|
|
</Box>
|
|
)}
|
|
</ClassNames>
|
|
);
|
|
}
|
|
|
|
function StyleExplanation() {
|
|
return (
|
|
<Box
|
|
fontSize="xs"
|
|
fontStyle="italic"
|
|
textAlign="center"
|
|
opacity="0.7"
|
|
marginTop="2"
|
|
>
|
|
"Alt Styles" are NC items that override the pet's appearance via the{" "}
|
|
<Box
|
|
as="a"
|
|
href="https://www.neopets.com/stylingchamber/"
|
|
target="_blank"
|
|
textDecoration="underline"
|
|
>
|
|
Styling Chamber
|
|
</Box>
|
|
. Not all items fit Alt Style pets. The pet's color doesn't have to match.
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function EmojiImage({ src, alt, boxSize = 16 }) {
|
|
return <img src={src} alt={alt} width={boxSize} height={boxSize} />;
|
|
}
|
|
|
|
function usePoses(speciesId, colorId, selectedPose) {
|
|
const { loading, error, data } = useQuery(
|
|
gql`
|
|
query PosePicker($speciesId: ID!, $colorId: ID!) {
|
|
happyMasc: petAppearance(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
pose: HAPPY_MASC
|
|
) {
|
|
...PetAppearanceForPosePicker
|
|
}
|
|
sadMasc: petAppearance(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
pose: SAD_MASC
|
|
) {
|
|
...PetAppearanceForPosePicker
|
|
}
|
|
sickMasc: petAppearance(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
pose: SICK_MASC
|
|
) {
|
|
...PetAppearanceForPosePicker
|
|
}
|
|
happyFem: petAppearance(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
pose: HAPPY_FEM
|
|
) {
|
|
...PetAppearanceForPosePicker
|
|
}
|
|
sadFem: petAppearance(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
pose: SAD_FEM
|
|
) {
|
|
...PetAppearanceForPosePicker
|
|
}
|
|
sickFem: petAppearance(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
pose: SICK_FEM
|
|
) {
|
|
...PetAppearanceForPosePicker
|
|
}
|
|
unconverted: petAppearance(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
pose: UNCONVERTED
|
|
) {
|
|
...PetAppearanceForPosePicker
|
|
}
|
|
unknown: petAppearance(
|
|
speciesId: $speciesId
|
|
colorId: $colorId
|
|
pose: UNKNOWN
|
|
) {
|
|
...PetAppearanceForPosePicker
|
|
}
|
|
}
|
|
|
|
${petAppearanceForPosePickerFragment}
|
|
`,
|
|
{ variables: { speciesId, colorId }, onError: (e) => console.error(e) },
|
|
);
|
|
|
|
const poseInfos = {
|
|
happyMasc: {
|
|
...data?.happyMasc,
|
|
pose: "HAPPY_MASC",
|
|
isAvailable: Boolean(data?.happyMasc),
|
|
isSelected: selectedPose === "HAPPY_MASC",
|
|
},
|
|
sadMasc: {
|
|
...data?.sadMasc,
|
|
pose: "SAD_MASC",
|
|
isAvailable: Boolean(data?.sadMasc),
|
|
isSelected: selectedPose === "SAD_MASC",
|
|
},
|
|
sickMasc: {
|
|
...data?.sickMasc,
|
|
pose: "SICK_MASC",
|
|
isAvailable: Boolean(data?.sickMasc),
|
|
isSelected: selectedPose === "SICK_MASC",
|
|
},
|
|
happyFem: {
|
|
...data?.happyFem,
|
|
pose: "HAPPY_FEM",
|
|
isAvailable: Boolean(data?.happyFem),
|
|
isSelected: selectedPose === "HAPPY_FEM",
|
|
},
|
|
sadFem: {
|
|
...data?.sadFem,
|
|
pose: "SAD_FEM",
|
|
isAvailable: Boolean(data?.sadFem),
|
|
isSelected: selectedPose === "SAD_FEM",
|
|
},
|
|
sickFem: {
|
|
...data?.sickFem,
|
|
pose: "SICK_FEM",
|
|
isAvailable: Boolean(data?.sickFem),
|
|
isSelected: selectedPose === "SICK_FEM",
|
|
},
|
|
unconverted: {
|
|
...data?.unconverted,
|
|
pose: "UNCONVERTED",
|
|
isAvailable: Boolean(data?.unconverted),
|
|
isSelected: selectedPose === "UNCONVERTED",
|
|
},
|
|
unknown: {
|
|
...data?.unknown,
|
|
pose: "UNKNOWN",
|
|
isAvailable: Boolean(data?.unknown),
|
|
isSelected: selectedPose === "UNKNOWN",
|
|
},
|
|
};
|
|
|
|
return { loading, error, poseInfos };
|
|
}
|
|
|
|
function getIcon(pose) {
|
|
if (["HAPPY_MASC", "HAPPY_FEM"].includes(pose)) {
|
|
return twemojiSmile;
|
|
} else if (["SAD_MASC", "SAD_FEM"].includes(pose)) {
|
|
return twemojiCry;
|
|
} else if (["SICK_MASC", "SICK_FEM"].includes(pose)) {
|
|
return twemojiSick;
|
|
} else if (pose === "UNCONVERTED") {
|
|
return twemojiHourglass;
|
|
} else {
|
|
return twemojiSmile;
|
|
}
|
|
}
|
|
|
|
function getLabel(pose) {
|
|
if (pose === "HAPPY_MASC" || pose === "HAPPY_FEM") {
|
|
return "Happy";
|
|
} else if (pose === "SAD_MASC" || pose === "SAD_FEM") {
|
|
return "Sad";
|
|
} else if (pose === "SICK_MASC" || pose === "SICK_FEM") {
|
|
return "Sick";
|
|
} else if (pose === "UNCONVERTED") {
|
|
return "Retired UC";
|
|
} else {
|
|
return "Default";
|
|
}
|
|
}
|
|
|
|
function getTransform(poseInfo) {
|
|
const { pose, bodyId } = poseInfo;
|
|
if (pose === "UNCONVERTED") {
|
|
return transformsByBodyId.default;
|
|
}
|
|
if (bodyId in transformsByBodyId) {
|
|
return transformsByBodyId[bodyId];
|
|
}
|
|
return transformsByBodyId.default;
|
|
}
|
|
|
|
export const petAppearanceForPosePickerFragment = gql`
|
|
fragment PetAppearanceForPosePicker on PetAppearance {
|
|
id
|
|
bodyId
|
|
pose
|
|
...PetAppearanceForOutfitPreview
|
|
}
|
|
${petAppearanceFragment}
|
|
`;
|
|
|
|
const transformsByBodyId = {
|
|
93: "translate(-5px, 10px) scale(2.8)",
|
|
106: "translate(-8px, 8px) scale(2.9)",
|
|
47: "translate(-1px, 17px) scale(3)",
|
|
84: "translate(-21px, 22px) scale(3.2)",
|
|
146: "translate(2px, 15px) scale(3.3)",
|
|
250: "translate(-14px, 28px) scale(3.4)",
|
|
212: "translate(-4px, 8px) scale(2.9)",
|
|
74: "translate(-26px, 30px) scale(3.0)",
|
|
94: "translate(-4px, 8px) scale(3.1)",
|
|
132: "translate(-14px, 18px) scale(3.0)",
|
|
56: "translate(-7px, 24px) scale(2.9)",
|
|
90: "translate(-16px, 20px) scale(3.5)",
|
|
136: "translate(-11px, 18px) scale(3.0)",
|
|
138: "translate(-14px, 26px) scale(3.5)",
|
|
166: "translate(-13px, 24px) scale(3.1)",
|
|
119: "translate(-6px, 29px) scale(3.1)",
|
|
126: "translate(3px, 13px) scale(3.1)",
|
|
67: "translate(2px, 27px) scale(3.4)",
|
|
163: "translate(-7px, 16px) scale(3.1)",
|
|
147: "translate(-2px, 15px) scale(3.0)",
|
|
80: "translate(-2px, -17px) scale(3.0)",
|
|
117: "translate(-14px, 16px) scale(3.6)",
|
|
201: "translate(-16px, 16px) scale(3.2)",
|
|
51: "translate(-2px, 6px) scale(3.2)",
|
|
208: "translate(-3px, 6px) scale(3.7)",
|
|
196: "translate(-7px, 19px) scale(5.2)",
|
|
143: "translate(-16px, 20px) scale(3.5)",
|
|
150: "translate(-3px, 24px) scale(3.2)",
|
|
175: "translate(-9px, 15px) scale(3.4)",
|
|
173: "translate(3px, 57px) scale(4.4)",
|
|
199: "translate(-28px, 35px) scale(3.8)",
|
|
52: "translate(-8px, 33px) scale(3.5)",
|
|
109: "translate(-8px, -6px) scale(3.2)",
|
|
134: "translate(-14px, 14px) scale(3.1)",
|
|
95: "translate(-12px, 0px) scale(3.4)",
|
|
96: "translate(6px, 23px) scale(3.3)",
|
|
154: "translate(-20px, 25px) scale(3.6)",
|
|
55: "translate(-16px, 28px) scale(4.0)",
|
|
76: "translate(-8px, 11px) scale(3.0)",
|
|
156: "translate(2px, 12px) scale(3.5)",
|
|
78: "translate(-3px, 18px) scale(3.0)",
|
|
191: "translate(-18px, 46px) scale(4.4)",
|
|
187: "translate(-6px, 22px) scale(3.2)",
|
|
46: "translate(-2px, 19px) scale(3.4)",
|
|
178: "translate(-11px, 32px) scale(3.3)",
|
|
100: "translate(-13px, 23px) scale(3.3)",
|
|
130: "translate(-14px, 4px) scale(3.1)",
|
|
188: "translate(-9px, 24px) scale(3.5)",
|
|
257: "translate(-14px, 25px) scale(3.4)",
|
|
206: "translate(-7px, 4px) scale(3.6)",
|
|
101: "translate(-13px, 16px) scale(3.2)",
|
|
68: "translate(-2px, 13px) scale(3.2)",
|
|
182: "translate(-6px, 4px) scale(3.1)",
|
|
180: "translate(-15px, 22px) scale(3.6)",
|
|
306: "translate(1px, 14px) scale(3.1)",
|
|
default: "scale(2.5)",
|
|
};
|
|
|
|
export default React.memo(PosePicker);
|