Extract SpeciesFacesPicker to a new file
I wanna refactor how we do styles in it for perf reasons, and it's enough added complexity that I want it in its own file!
This commit is contained in:
parent
ab2dbeb02a
commit
caf0a8b815
2 changed files with 904 additions and 888 deletions
|
@ -14,9 +14,6 @@ import {
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
useTheme,
|
useTheme,
|
||||||
useToast,
|
useToast,
|
||||||
useToken,
|
|
||||||
Wrap,
|
|
||||||
WrapItem,
|
|
||||||
Flex,
|
Flex,
|
||||||
usePrefersReducedMotion,
|
usePrefersReducedMotion,
|
||||||
Grid,
|
Grid,
|
||||||
|
@ -27,7 +24,6 @@ import {
|
||||||
EditIcon,
|
EditIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
WarningIcon,
|
WarningIcon,
|
||||||
WarningTwoIcon,
|
|
||||||
} from "@chakra-ui/icons";
|
} from "@chakra-ui/icons";
|
||||||
import { MdPause, MdPlayArrow } from "react-icons/md";
|
import { MdPause, MdPlayArrow } from "react-icons/md";
|
||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
|
@ -49,6 +45,9 @@ import SpeciesColorPicker, {
|
||||||
} from "./components/SpeciesColorPicker";
|
} from "./components/SpeciesColorPicker";
|
||||||
import useCurrentUser from "./components/useCurrentUser";
|
import useCurrentUser from "./components/useCurrentUser";
|
||||||
import { useLocalStorage } from "./util";
|
import { useLocalStorage } from "./util";
|
||||||
|
import SpeciesFacesPicker, {
|
||||||
|
colorIsBasic,
|
||||||
|
} from "./ItemPage/SpeciesFacesPicker";
|
||||||
|
|
||||||
function ItemPage() {
|
function ItemPage() {
|
||||||
const { itemId } = useParams();
|
const { itemId } = useParams();
|
||||||
|
@ -983,318 +982,10 @@ function PlayPauseButton({ isPaused, onClick }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpeciesFacesPicker({
|
export function ItemZonesInfo({
|
||||||
selectedSpeciesId,
|
compatibleBodiesAndTheirZones,
|
||||||
selectedColorId,
|
restrictedZones,
|
||||||
compatibleBodies,
|
|
||||||
couldProbablyModelMoreData,
|
|
||||||
onChange,
|
|
||||||
isLoading,
|
|
||||||
}) {
|
}) {
|
||||||
// For basic colors (Blue, Green, Red, Yellow), we just use the hardcoded
|
|
||||||
// data, which is part of the bundle and loads super-fast. For other colors,
|
|
||||||
// we load in all the faces of that color, falling back to basic colors when
|
|
||||||
// absent!
|
|
||||||
//
|
|
||||||
// TODO: Could we move this into our `build-cached-data` script, and just do
|
|
||||||
// the query all the time, and have Apollo happen to satisfy it fast?
|
|
||||||
// The semantics of returning our colorful random set could be weird…
|
|
||||||
const selectedColorIsBasic = colorIsBasic(selectedColorId);
|
|
||||||
const { loading: loadingGQL, error, data } = useQuery(
|
|
||||||
gql`
|
|
||||||
query SpeciesFacesPicker($selectedColorId: ID!) {
|
|
||||||
color(id: $selectedColorId) {
|
|
||||||
id
|
|
||||||
appliedToAllCompatibleSpecies {
|
|
||||||
id
|
|
||||||
neopetsImageHash
|
|
||||||
species {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
variables: { selectedColorId },
|
|
||||||
skip: selectedColorId == null || selectedColorIsBasic,
|
|
||||||
onError: (e) => console.error(e),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const allBodiesAreCompatible = compatibleBodies.some(
|
|
||||||
(body) => body.representsAllBodies
|
|
||||||
);
|
|
||||||
const compatibleBodyIds = compatibleBodies.map((body) => body.id);
|
|
||||||
|
|
||||||
const speciesFacesFromData = data?.color?.appliedToAllCompatibleSpecies || [];
|
|
||||||
|
|
||||||
const allSpeciesFaces = DEFAULT_SPECIES_FACES.map((defaultSpeciesFace) => {
|
|
||||||
const providedSpeciesFace = speciesFacesFromData.find(
|
|
||||||
(f) => f.species.id === defaultSpeciesFace.speciesId
|
|
||||||
);
|
|
||||||
if (providedSpeciesFace) {
|
|
||||||
return {
|
|
||||||
...defaultSpeciesFace,
|
|
||||||
colorId: selectedColorId,
|
|
||||||
bodyId: providedSpeciesFace.body.id,
|
|
||||||
// If this species/color pair exists, but without an image hash, then
|
|
||||||
// we want to provide a face so that it's enabled, but use the fallback
|
|
||||||
// image even though it's wrong, so that it looks like _something_.
|
|
||||||
neopetsImageHash:
|
|
||||||
providedSpeciesFace.neopetsImageHash ||
|
|
||||||
defaultSpeciesFace.neopetsImageHash,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return defaultSpeciesFace;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Wrap spacing="0" justify="center">
|
|
||||||
{allSpeciesFaces.map((speciesFace) => (
|
|
||||||
<WrapItem key={speciesFace.speciesId}>
|
|
||||||
<SpeciesFaceOption
|
|
||||||
speciesId={speciesFace.speciesId}
|
|
||||||
speciesName={speciesFace.speciesName}
|
|
||||||
colorId={speciesFace.colorId}
|
|
||||||
neopetsImageHash={speciesFace.neopetsImageHash}
|
|
||||||
isSelected={speciesFace.speciesId === selectedSpeciesId}
|
|
||||||
// If the face color doesn't match the current color, this is a
|
|
||||||
// fallback face for an invalid species/color pair.
|
|
||||||
isValid={
|
|
||||||
speciesFace.colorId === selectedColorId || selectedColorIsBasic
|
|
||||||
}
|
|
||||||
bodyIsCompatible={
|
|
||||||
allBodiesAreCompatible ||
|
|
||||||
compatibleBodyIds.includes(speciesFace.bodyId)
|
|
||||||
}
|
|
||||||
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
|
||||||
onChange={onChange}
|
|
||||||
isLoading={isLoading || loadingGQL}
|
|
||||||
/>
|
|
||||||
</WrapItem>
|
|
||||||
))}
|
|
||||||
</Wrap>
|
|
||||||
{error && (
|
|
||||||
<Flex
|
|
||||||
color="yellow.500"
|
|
||||||
fontSize="xs"
|
|
||||||
marginTop="1"
|
|
||||||
textAlign="center"
|
|
||||||
width="100%"
|
|
||||||
align="flex-start"
|
|
||||||
justify="center"
|
|
||||||
>
|
|
||||||
<WarningTwoIcon marginTop="0.4em" marginRight="1" />
|
|
||||||
<Box>
|
|
||||||
Error loading this color's pet photos.
|
|
||||||
<br />
|
|
||||||
Check your connection and try again.
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SpeciesFaceOption = React.memo(
|
|
||||||
({
|
|
||||||
speciesId,
|
|
||||||
speciesName,
|
|
||||||
colorId,
|
|
||||||
neopetsImageHash,
|
|
||||||
isSelected,
|
|
||||||
bodyIsCompatible,
|
|
||||||
isValid,
|
|
||||||
couldProbablyModelMoreData,
|
|
||||||
onChange,
|
|
||||||
isLoading,
|
|
||||||
}) => {
|
|
||||||
const selectedBorderColor = useColorModeValue("green.600", "green.400");
|
|
||||||
const selectedBackgroundColor = useColorModeValue("green.200", "green.600");
|
|
||||||
const focusBorderColor = "blue.400";
|
|
||||||
const focusBackgroundColor = "blue.100";
|
|
||||||
const [
|
|
||||||
selectedBorderColorValue,
|
|
||||||
selectedBackgroundColorValue,
|
|
||||||
focusBorderColorValue,
|
|
||||||
focusBackgroundColorValue,
|
|
||||||
] = useToken("colors", [
|
|
||||||
selectedBorderColor,
|
|
||||||
selectedBackgroundColor,
|
|
||||||
focusBorderColor,
|
|
||||||
focusBackgroundColor,
|
|
||||||
]);
|
|
||||||
const xlShadow = useToken("shadows", "xl");
|
|
||||||
|
|
||||||
const [labelIsHovered, setLabelIsHovered] = React.useState(false);
|
|
||||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
|
||||||
|
|
||||||
const isDisabled = isLoading || !isValid || !bodyIsCompatible;
|
|
||||||
const isHappy = isLoading || (isValid && bodyIsCompatible);
|
|
||||||
const emotionId = isHappy ? "1" : "2";
|
|
||||||
const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer";
|
|
||||||
|
|
||||||
let disabledExplanation = null;
|
|
||||||
if (isLoading) {
|
|
||||||
// If we're still loading, don't try to explain anything yet!
|
|
||||||
} else if (!isValid) {
|
|
||||||
disabledExplanation = "(Can't be this color)";
|
|
||||||
} else if (!bodyIsCompatible) {
|
|
||||||
disabledExplanation = couldProbablyModelMoreData
|
|
||||||
? "(Item needs models)"
|
|
||||||
: "(Not compatible)";
|
|
||||||
}
|
|
||||||
|
|
||||||
const tooltipLabel = (
|
|
||||||
<div style={{ textAlign: "center" }}>
|
|
||||||
{speciesName}
|
|
||||||
{disabledExplanation && (
|
|
||||||
<div style={{ fontStyle: "italic", fontSize: "0.75em" }}>
|
|
||||||
{disabledExplanation}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// NOTE: Because we render quite a few of these, avoiding using Chakra
|
|
||||||
// elements like Box helps with render performance!
|
|
||||||
return (
|
|
||||||
<ClassNames>
|
|
||||||
{({ css }) => (
|
|
||||||
<DeferredTooltip
|
|
||||||
label={tooltipLabel}
|
|
||||||
placement="top"
|
|
||||||
gutter={-10}
|
|
||||||
// We track hover and focus state manually for the tooltip, so that
|
|
||||||
// keyboard nav to switch between options causes the tooltip to
|
|
||||||
// follow. (By default, the tooltip appears on the first tab focus,
|
|
||||||
// but not when you _change_ options!)
|
|
||||||
isOpen={labelIsHovered || inputIsFocused}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
style={{ cursor }}
|
|
||||||
onMouseEnter={() => setLabelIsHovered(true)}
|
|
||||||
onMouseLeave={() => setLabelIsHovered(false)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
aria-label={speciesName}
|
|
||||||
name="species-faces-picker"
|
|
||||||
value={speciesId}
|
|
||||||
checked={isSelected}
|
|
||||||
// It's possible to get this selected via the SpeciesColorPicker,
|
|
||||||
// even if this would normally be disabled. If so, make this
|
|
||||||
// option enabled, so keyboard users can focus and change it.
|
|
||||||
disabled={isDisabled && !isSelected}
|
|
||||||
onChange={() => onChange({ speciesId, colorId })}
|
|
||||||
onFocus={() => setInputIsFocused(true)}
|
|
||||||
onBlur={() => setInputIsFocused(false)}
|
|
||||||
className={css`
|
|
||||||
/* Copied from Chakra's <VisuallyHidden /> */
|
|
||||||
border: 0px;
|
|
||||||
clip: rect(0px, 0px, 0px, 0px);
|
|
||||||
height: 1px;
|
|
||||||
width: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0px;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
position: absolute;
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={css`
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
input:checked + & {
|
|
||||||
background: ${selectedBackgroundColorValue};
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: ${xlShadow},
|
|
||||||
${selectedBorderColorValue} 0 0 2px 2px;
|
|
||||||
transform: scale(1.2);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus + & {
|
|
||||||
background: ${focusBackgroundColorValue};
|
|
||||||
box-shadow: ${xlShadow}, ${focusBorderColorValue} 0 0 0 3px;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<CrossFadeImage
|
|
||||||
src={`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png`}
|
|
||||||
srcSet={
|
|
||||||
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
|
|
||||||
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/6.png 2x`
|
|
||||||
}
|
|
||||||
alt={speciesName}
|
|
||||||
width={55}
|
|
||||||
height={55}
|
|
||||||
data-is-loading={isLoading}
|
|
||||||
data-is-disabled={isDisabled}
|
|
||||||
className={css`
|
|
||||||
filter: saturate(90%);
|
|
||||||
opacity: 0.9;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&[data-is-disabled="true"] {
|
|
||||||
filter: saturate(0%);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-is-loading="true"] {
|
|
||||||
animation: 0.8s linear 0s infinite alternate none running
|
|
||||||
pulse;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + * &[data-body-is-disabled="false"] {
|
|
||||||
opacity: 1;
|
|
||||||
filter: saturate(110%);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + * &[data-body-is-disabled="true"] {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
from {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alt text for when the image fails to load! We hide it
|
|
||||||
* while still loading though! */
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-align: center;
|
|
||||||
&:-moz-loading {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
&:-moz-broken {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</DeferredTooltip>
|
|
||||||
)}
|
|
||||||
</ClassNames>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function ItemZonesInfo({ compatibleBodiesAndTheirZones, restrictedZones }) {
|
|
||||||
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
|
// 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
|
// merging zones with the same label, because that's how user-facing zone UI
|
||||||
// generally works!
|
// generally works!
|
||||||
|
@ -1466,577 +1157,4 @@ function buildSortKeyForZoneLabelsAndTheirBodies({ zoneLabel, bodies }) {
|
||||||
return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
|
return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* CrossFadeImage is like <img>, but listens for successful load events, and
|
|
||||||
* fades from the previous image to the new image once it loads.
|
|
||||||
*
|
|
||||||
* We treat `src` as a unique key representing the image's identity, but we
|
|
||||||
* also carry along the rest of the props during the fade, like `srcSet` and
|
|
||||||
* `className`.
|
|
||||||
*/
|
|
||||||
function CrossFadeImage(incomingImageProps) {
|
|
||||||
const [prevImageProps, setPrevImageProps] = React.useState(null);
|
|
||||||
const [currentImageProps, setCurrentImageProps] = React.useState(null);
|
|
||||||
|
|
||||||
const incomingImageIsCurrentImage =
|
|
||||||
incomingImageProps.src === currentImageProps?.src;
|
|
||||||
|
|
||||||
const onLoadNextImage = () => {
|
|
||||||
setPrevImageProps(currentImageProps);
|
|
||||||
setCurrentImageProps(incomingImageProps);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The main trick to this component is using React's `key` feature! When
|
|
||||||
// diffing the rendered tree, if React sees two nodes with the same `key`, it
|
|
||||||
// treats them as the same node and makes the prop changes to match.
|
|
||||||
//
|
|
||||||
// We usually use this in `.map`, to make sure that adds/removes in a list
|
|
||||||
// don't cause our children to shift around and swap their React state or DOM
|
|
||||||
// nodes with each other.
|
|
||||||
//
|
|
||||||
// But here, we use `key` to get React to transition the same <img> DOM node
|
|
||||||
// between 3 different states!
|
|
||||||
//
|
|
||||||
// The image starts its life as the last in the list, from
|
|
||||||
// `incomingImageProps`: it's invisible, and still loading. We use its `src`
|
|
||||||
// as the `key`.
|
|
||||||
//
|
|
||||||
// When it loads, we update the state so that this `key` now belongs to the
|
|
||||||
// _second_ node, from `currentImageProps`. React will see this and make the
|
|
||||||
// correct transition for us: it sets opacity to 0, sets z-index to 2,
|
|
||||||
// removes aria-hidden, and removes the `onLoad` handler.
|
|
||||||
//
|
|
||||||
// Then, when another image is ready to show, we update the state so that
|
|
||||||
// this key now belongs to the _first_ node, from `prevImageProps` (and the
|
|
||||||
// second node is showing something new). React sees this, and makes the
|
|
||||||
// transition back to invisibility, but without the `onLoad` handler this
|
|
||||||
// time! (And transitions the current image into view, like it did for this
|
|
||||||
// one.)
|
|
||||||
//
|
|
||||||
// Finally, when yet _another_ image is ready to show, we stop rendering any
|
|
||||||
// images with this key anymore, and so React unmounts the image entirely.
|
|
||||||
//
|
|
||||||
// Thanks, React, for handling our multiple overlapping transitions through
|
|
||||||
// this little state machine! This could have been a LOT harder to write,
|
|
||||||
// whew!
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClassNames>
|
|
||||||
{({ css }) => (
|
|
||||||
<div
|
|
||||||
className={css`
|
|
||||||
display: grid;
|
|
||||||
grid-template-areas: "shared-overlapping-area";
|
|
||||||
isolation: isolate; /* Avoid z-index conflicts with parent! */
|
|
||||||
|
|
||||||
> div {
|
|
||||||
grid-area: shared-overlapping-area;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{prevImageProps && (
|
|
||||||
<div
|
|
||||||
key={prevImageProps.src}
|
|
||||||
className={css`
|
|
||||||
z-index: 3;
|
|
||||||
opacity: 0;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
|
||||||
<img {...prevImageProps} aria-hidden />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentImageProps && (
|
|
||||||
<div
|
|
||||||
key={currentImageProps.src}
|
|
||||||
className={css`
|
|
||||||
z-index: 2;
|
|
||||||
opacity: 1;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
|
||||||
<img
|
|
||||||
{...currentImageProps}
|
|
||||||
// If the current image _is_ the incoming image, we'll allow
|
|
||||||
// new props to come in and affect it. But if it's a new image
|
|
||||||
// incoming, we want to stick to the last props the current
|
|
||||||
// image had! (This matters for e.g. `bodyIsCompatible`
|
|
||||||
// becoming true in `SpeciesFaceOption` and restoring color,
|
|
||||||
// before the new color's image loads in.)
|
|
||||||
{...(incomingImageIsCurrentImage ? incomingImageProps : {})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!incomingImageIsCurrentImage && (
|
|
||||||
<div
|
|
||||||
key={incomingImageProps.src}
|
|
||||||
className={css`
|
|
||||||
z-index: 1;
|
|
||||||
opacity: 0;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
|
||||||
<img
|
|
||||||
{...incomingImageProps}
|
|
||||||
aria-hidden
|
|
||||||
onLoad={onLoadNextImage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ClassNames>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is
|
|
||||||
* true before mounting it, and unmounts it after closing.
|
|
||||||
*
|
|
||||||
* This can drastically improve render performance when there are lots of
|
|
||||||
* tooltip targets to re-render… but it comes with some limitations, like the
|
|
||||||
* extra requirement to control `isOpen`, and some additional DOM structure!
|
|
||||||
*/
|
|
||||||
function DeferredTooltip({ children, isOpen, ...props }) {
|
|
||||||
const [shouldShowTooltip, setShouldShowToolip] = React.useState(isOpen);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setShouldShowToolip(true);
|
|
||||||
} else {
|
|
||||||
const timeoutId = setTimeout(() => setShouldShowToolip(false), 500);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClassNames>
|
|
||||||
{({ css }) => (
|
|
||||||
<div
|
|
||||||
className={css`
|
|
||||||
position: relative;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{shouldShowTooltip && (
|
|
||||||
<Tooltip isOpen={isOpen} {...props}>
|
|
||||||
<div
|
|
||||||
className={css`
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ClassNames>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorIsBasic(colorId) {
|
|
||||||
return ["8", "34", "61", "84"].includes(colorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HACK: I'm just hardcoding all this, rather than connecting up to the
|
|
||||||
// database and adding a loading state. Tbh I'm not sure it's a good idea
|
|
||||||
// to load this dynamically until we have SSR to make it come in fast!
|
|
||||||
// And it's not so bad if this gets out of sync with the database,
|
|
||||||
// because the SpeciesColorPicker will still be usable!
|
|
||||||
const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" };
|
|
||||||
const DEFAULT_SPECIES_FACES = [
|
|
||||||
{
|
|
||||||
speciesName: "Acara",
|
|
||||||
speciesId: "1",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "93",
|
|
||||||
neopetsImageHash: "obxdjm88",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Aisha",
|
|
||||||
speciesId: "2",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "106",
|
|
||||||
neopetsImageHash: "n9ozx4z5",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Blumaroo",
|
|
||||||
speciesId: "3",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "47",
|
|
||||||
neopetsImageHash: "kfonqhdc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Bori",
|
|
||||||
speciesId: "4",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "84",
|
|
||||||
neopetsImageHash: "sc2hhvhn",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Bruce",
|
|
||||||
speciesId: "5",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "146",
|
|
||||||
neopetsImageHash: "wqz8xn4t",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Buzz",
|
|
||||||
speciesId: "6",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "250",
|
|
||||||
neopetsImageHash: "jc9klfxm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Chia",
|
|
||||||
speciesId: "7",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "212",
|
|
||||||
neopetsImageHash: "4lrb4n3f",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Chomby",
|
|
||||||
speciesId: "8",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "74",
|
|
||||||
neopetsImageHash: "bdml26md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Cybunny",
|
|
||||||
speciesId: "9",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "94",
|
|
||||||
neopetsImageHash: "xl6msllv",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Draik",
|
|
||||||
speciesId: "10",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "132",
|
|
||||||
neopetsImageHash: "bob39shq",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Elephante",
|
|
||||||
speciesId: "11",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "56",
|
|
||||||
neopetsImageHash: "jhhhbrww",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Eyrie",
|
|
||||||
speciesId: "12",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "90",
|
|
||||||
neopetsImageHash: "6kngmhvs",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Flotsam",
|
|
||||||
speciesId: "13",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "136",
|
|
||||||
neopetsImageHash: "47vt32x2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Gelert",
|
|
||||||
speciesId: "14",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "138",
|
|
||||||
neopetsImageHash: "5nrd2lvd",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Gnorbu",
|
|
||||||
speciesId: "15",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "166",
|
|
||||||
neopetsImageHash: "6c275jcg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Grarrl",
|
|
||||||
speciesId: "16",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "119",
|
|
||||||
neopetsImageHash: "j7q65fv4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Grundo",
|
|
||||||
speciesId: "17",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "126",
|
|
||||||
neopetsImageHash: "5xn4kjf8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Hissi",
|
|
||||||
speciesId: "18",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "67",
|
|
||||||
neopetsImageHash: "jsfvcqwt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Ixi",
|
|
||||||
speciesId: "19",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "163",
|
|
||||||
neopetsImageHash: "w32r74vo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Jetsam",
|
|
||||||
speciesId: "20",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "147",
|
|
||||||
neopetsImageHash: "kz43rnld",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Jubjub",
|
|
||||||
speciesId: "21",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "80",
|
|
||||||
neopetsImageHash: "m267j935",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Kacheek",
|
|
||||||
speciesId: "22",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "117",
|
|
||||||
neopetsImageHash: "4gsrb59g",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Kau",
|
|
||||||
speciesId: "23",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "201",
|
|
||||||
neopetsImageHash: "ktlxmrtr",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Kiko",
|
|
||||||
speciesId: "24",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "51",
|
|
||||||
neopetsImageHash: "42j5q3zx",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Koi",
|
|
||||||
speciesId: "25",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "208",
|
|
||||||
neopetsImageHash: "ncfn87wk",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Korbat",
|
|
||||||
speciesId: "26",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "196",
|
|
||||||
neopetsImageHash: "omx9c876",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Kougra",
|
|
||||||
speciesId: "27",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "143",
|
|
||||||
neopetsImageHash: "rfsbh59t",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Krawk",
|
|
||||||
speciesId: "28",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "150",
|
|
||||||
neopetsImageHash: "hxgsm5d4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Kyrii",
|
|
||||||
speciesId: "29",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "175",
|
|
||||||
neopetsImageHash: "blxmjgbk",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Lenny",
|
|
||||||
speciesId: "30",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "173",
|
|
||||||
neopetsImageHash: "8r94jhfq",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Lupe",
|
|
||||||
speciesId: "31",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "199",
|
|
||||||
neopetsImageHash: "z42535zh",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Lutari",
|
|
||||||
speciesId: "32",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "52",
|
|
||||||
neopetsImageHash: "qgg6z8s7",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Meerca",
|
|
||||||
speciesId: "33",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "109",
|
|
||||||
neopetsImageHash: "kk2nn2jr",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Moehog",
|
|
||||||
speciesId: "34",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "134",
|
|
||||||
neopetsImageHash: "jgkoro5z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Mynci",
|
|
||||||
speciesId: "35",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "95",
|
|
||||||
neopetsImageHash: "xwlo9657",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Nimmo",
|
|
||||||
speciesId: "36",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "96",
|
|
||||||
neopetsImageHash: "bx7fho8x",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Ogrin",
|
|
||||||
speciesId: "37",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "154",
|
|
||||||
neopetsImageHash: "rjzmx24v",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Peophin",
|
|
||||||
speciesId: "38",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "55",
|
|
||||||
neopetsImageHash: "kokc52kh",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Poogle",
|
|
||||||
speciesId: "39",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "76",
|
|
||||||
neopetsImageHash: "fw6lvf3c",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Pteri",
|
|
||||||
speciesId: "40",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "156",
|
|
||||||
neopetsImageHash: "tjhwbro3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Quiggle",
|
|
||||||
speciesId: "41",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "78",
|
|
||||||
neopetsImageHash: "jdto7mj4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Ruki",
|
|
||||||
speciesId: "42",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "191",
|
|
||||||
neopetsImageHash: "qsgbm5f6",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Scorchio",
|
|
||||||
speciesId: "43",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "187",
|
|
||||||
neopetsImageHash: "hkjoncsx",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Shoyru",
|
|
||||||
speciesId: "44",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "46",
|
|
||||||
neopetsImageHash: "mmvn4tkg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Skeith",
|
|
||||||
speciesId: "45",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "178",
|
|
||||||
neopetsImageHash: "fc4cxk3t",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Techo",
|
|
||||||
speciesId: "46",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "100",
|
|
||||||
neopetsImageHash: "84gvowmj",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Tonu",
|
|
||||||
speciesId: "47",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "130",
|
|
||||||
neopetsImageHash: "jd433863",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Tuskaninny",
|
|
||||||
speciesId: "48",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "188",
|
|
||||||
neopetsImageHash: "q39wn6vq",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Uni",
|
|
||||||
speciesId: "49",
|
|
||||||
colorId: colors.GREEN,
|
|
||||||
bodyId: "257",
|
|
||||||
neopetsImageHash: "njzvoflw",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Usul",
|
|
||||||
speciesId: "50",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "206",
|
|
||||||
neopetsImageHash: "rox4mgh5",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Vandagyre",
|
|
||||||
speciesId: "55",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "306",
|
|
||||||
neopetsImageHash: "xkntzsww",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Wocky",
|
|
||||||
speciesId: "51",
|
|
||||||
colorId: colors.YELLOW,
|
|
||||||
bodyId: "101",
|
|
||||||
neopetsImageHash: "dnr2kj4b",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Xweetok",
|
|
||||||
speciesId: "52",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "68",
|
|
||||||
neopetsImageHash: "tdkqr2b6",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Yurble",
|
|
||||||
speciesId: "53",
|
|
||||||
colorId: colors.RED,
|
|
||||||
bodyId: "182",
|
|
||||||
neopetsImageHash: "h95cs547",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
speciesName: "Zafara",
|
|
||||||
speciesId: "54",
|
|
||||||
colorId: colors.BLUE,
|
|
||||||
bodyId: "180",
|
|
||||||
neopetsImageHash: "x8c57g2l",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default ItemPage;
|
export default ItemPage;
|
||||||
|
|
898
src/app/ItemPage/SpeciesFacesPicker.js
Normal file
898
src/app/ItemPage/SpeciesFacesPicker.js
Normal file
|
@ -0,0 +1,898 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ClassNames } from "@emotion/react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Tooltip,
|
||||||
|
useColorModeValue,
|
||||||
|
useToken,
|
||||||
|
Wrap,
|
||||||
|
WrapItem,
|
||||||
|
Flex,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { WarningTwoIcon } from "@chakra-ui/icons";
|
||||||
|
import gql from "graphql-tag";
|
||||||
|
import { useQuery } from "@apollo/client";
|
||||||
|
|
||||||
|
function SpeciesFacesPicker({
|
||||||
|
selectedSpeciesId,
|
||||||
|
selectedColorId,
|
||||||
|
compatibleBodies,
|
||||||
|
couldProbablyModelMoreData,
|
||||||
|
onChange,
|
||||||
|
isLoading,
|
||||||
|
}) {
|
||||||
|
// For basic colors (Blue, Green, Red, Yellow), we just use the hardcoded
|
||||||
|
// data, which is part of the bundle and loads super-fast. For other colors,
|
||||||
|
// we load in all the faces of that color, falling back to basic colors when
|
||||||
|
// absent!
|
||||||
|
//
|
||||||
|
// TODO: Could we move this into our `build-cached-data` script, and just do
|
||||||
|
// the query all the time, and have Apollo happen to satisfy it fast?
|
||||||
|
// The semantics of returning our colorful random set could be weird…
|
||||||
|
const selectedColorIsBasic = colorIsBasic(selectedColorId);
|
||||||
|
const { loading: loadingGQL, error, data } = useQuery(
|
||||||
|
gql`
|
||||||
|
query SpeciesFacesPicker($selectedColorId: ID!) {
|
||||||
|
color(id: $selectedColorId) {
|
||||||
|
id
|
||||||
|
appliedToAllCompatibleSpecies {
|
||||||
|
id
|
||||||
|
neopetsImageHash
|
||||||
|
species {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
variables: { selectedColorId },
|
||||||
|
skip: selectedColorId == null || selectedColorIsBasic,
|
||||||
|
onError: (e) => console.error(e),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const allBodiesAreCompatible = compatibleBodies.some(
|
||||||
|
(body) => body.representsAllBodies
|
||||||
|
);
|
||||||
|
const compatibleBodyIds = compatibleBodies.map((body) => body.id);
|
||||||
|
|
||||||
|
const speciesFacesFromData = data?.color?.appliedToAllCompatibleSpecies || [];
|
||||||
|
|
||||||
|
const allSpeciesFaces = DEFAULT_SPECIES_FACES.map((defaultSpeciesFace) => {
|
||||||
|
const providedSpeciesFace = speciesFacesFromData.find(
|
||||||
|
(f) => f.species.id === defaultSpeciesFace.speciesId
|
||||||
|
);
|
||||||
|
if (providedSpeciesFace) {
|
||||||
|
return {
|
||||||
|
...defaultSpeciesFace,
|
||||||
|
colorId: selectedColorId,
|
||||||
|
bodyId: providedSpeciesFace.body.id,
|
||||||
|
// If this species/color pair exists, but without an image hash, then
|
||||||
|
// we want to provide a face so that it's enabled, but use the fallback
|
||||||
|
// image even though it's wrong, so that it looks like _something_.
|
||||||
|
neopetsImageHash:
|
||||||
|
providedSpeciesFace.neopetsImageHash ||
|
||||||
|
defaultSpeciesFace.neopetsImageHash,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return defaultSpeciesFace;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Wrap spacing="0" justify="center">
|
||||||
|
{allSpeciesFaces.map((speciesFace) => (
|
||||||
|
<WrapItem key={speciesFace.speciesId}>
|
||||||
|
<SpeciesFaceOption
|
||||||
|
speciesId={speciesFace.speciesId}
|
||||||
|
speciesName={speciesFace.speciesName}
|
||||||
|
colorId={speciesFace.colorId}
|
||||||
|
neopetsImageHash={speciesFace.neopetsImageHash}
|
||||||
|
isSelected={speciesFace.speciesId === selectedSpeciesId}
|
||||||
|
// If the face color doesn't match the current color, this is a
|
||||||
|
// fallback face for an invalid species/color pair.
|
||||||
|
isValid={
|
||||||
|
speciesFace.colorId === selectedColorId || selectedColorIsBasic
|
||||||
|
}
|
||||||
|
bodyIsCompatible={
|
||||||
|
allBodiesAreCompatible ||
|
||||||
|
compatibleBodyIds.includes(speciesFace.bodyId)
|
||||||
|
}
|
||||||
|
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
||||||
|
onChange={onChange}
|
||||||
|
isLoading={isLoading || loadingGQL}
|
||||||
|
/>
|
||||||
|
</WrapItem>
|
||||||
|
))}
|
||||||
|
</Wrap>
|
||||||
|
{error && (
|
||||||
|
<Flex
|
||||||
|
color="yellow.500"
|
||||||
|
fontSize="xs"
|
||||||
|
marginTop="1"
|
||||||
|
textAlign="center"
|
||||||
|
width="100%"
|
||||||
|
align="flex-start"
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
|
<WarningTwoIcon marginTop="0.4em" marginRight="1" />
|
||||||
|
<Box>
|
||||||
|
Error loading this color's pet photos.
|
||||||
|
<br />
|
||||||
|
Check your connection and try again.
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const SpeciesFaceOption = React.memo(
|
||||||
|
({
|
||||||
|
speciesId,
|
||||||
|
speciesName,
|
||||||
|
colorId,
|
||||||
|
neopetsImageHash,
|
||||||
|
isSelected,
|
||||||
|
bodyIsCompatible,
|
||||||
|
isValid,
|
||||||
|
couldProbablyModelMoreData,
|
||||||
|
onChange,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const selectedBorderColor = useColorModeValue("green.600", "green.400");
|
||||||
|
const selectedBackgroundColor = useColorModeValue("green.200", "green.600");
|
||||||
|
const focusBorderColor = "blue.400";
|
||||||
|
const focusBackgroundColor = "blue.100";
|
||||||
|
const [
|
||||||
|
selectedBorderColorValue,
|
||||||
|
selectedBackgroundColorValue,
|
||||||
|
focusBorderColorValue,
|
||||||
|
focusBackgroundColorValue,
|
||||||
|
] = useToken("colors", [
|
||||||
|
selectedBorderColor,
|
||||||
|
selectedBackgroundColor,
|
||||||
|
focusBorderColor,
|
||||||
|
focusBackgroundColor,
|
||||||
|
]);
|
||||||
|
const xlShadow = useToken("shadows", "xl");
|
||||||
|
|
||||||
|
const [labelIsHovered, setLabelIsHovered] = React.useState(false);
|
||||||
|
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||||
|
|
||||||
|
const isDisabled = isLoading || !isValid || !bodyIsCompatible;
|
||||||
|
const isHappy = isLoading || (isValid && bodyIsCompatible);
|
||||||
|
const emotionId = isHappy ? "1" : "2";
|
||||||
|
const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer";
|
||||||
|
|
||||||
|
let disabledExplanation = null;
|
||||||
|
if (isLoading) {
|
||||||
|
// If we're still loading, don't try to explain anything yet!
|
||||||
|
} else if (!isValid) {
|
||||||
|
disabledExplanation = "(Can't be this color)";
|
||||||
|
} else if (!bodyIsCompatible) {
|
||||||
|
disabledExplanation = couldProbablyModelMoreData
|
||||||
|
? "(Item needs models)"
|
||||||
|
: "(Not compatible)";
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipLabel = (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
{speciesName}
|
||||||
|
{disabledExplanation && (
|
||||||
|
<div style={{ fontStyle: "italic", fontSize: "0.75em" }}>
|
||||||
|
{disabledExplanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOTE: Because we render quite a few of these, avoiding using Chakra
|
||||||
|
// elements like Box helps with render performance!
|
||||||
|
return (
|
||||||
|
<ClassNames>
|
||||||
|
{({ css }) => (
|
||||||
|
<DeferredTooltip
|
||||||
|
label={tooltipLabel}
|
||||||
|
placement="top"
|
||||||
|
gutter={-10}
|
||||||
|
// We track hover and focus state manually for the tooltip, so that
|
||||||
|
// keyboard nav to switch between options causes the tooltip to
|
||||||
|
// follow. (By default, the tooltip appears on the first tab focus,
|
||||||
|
// but not when you _change_ options!)
|
||||||
|
isOpen={labelIsHovered || inputIsFocused}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{ cursor }}
|
||||||
|
onMouseEnter={() => setLabelIsHovered(true)}
|
||||||
|
onMouseLeave={() => setLabelIsHovered(false)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
aria-label={speciesName}
|
||||||
|
name="species-faces-picker"
|
||||||
|
value={speciesId}
|
||||||
|
checked={isSelected}
|
||||||
|
// It's possible to get this selected via the SpeciesColorPicker,
|
||||||
|
// even if this would normally be disabled. If so, make this
|
||||||
|
// option enabled, so keyboard users can focus and change it.
|
||||||
|
disabled={isDisabled && !isSelected}
|
||||||
|
onChange={() => onChange({ speciesId, colorId })}
|
||||||
|
onFocus={() => setInputIsFocused(true)}
|
||||||
|
onBlur={() => setInputIsFocused(false)}
|
||||||
|
className={css`
|
||||||
|
/* Copied from Chakra's <VisuallyHidden /> */
|
||||||
|
border: 0px;
|
||||||
|
clip: rect(0px, 0px, 0px, 0px);
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: absolute;
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input:checked + & {
|
||||||
|
background: ${selectedBackgroundColorValue};
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: ${xlShadow},
|
||||||
|
${selectedBorderColorValue} 0 0 2px 2px;
|
||||||
|
transform: scale(1.2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus + & {
|
||||||
|
background: ${focusBackgroundColorValue};
|
||||||
|
box-shadow: ${xlShadow}, ${focusBorderColorValue} 0 0 0 3px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<CrossFadeImage
|
||||||
|
src={`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png`}
|
||||||
|
srcSet={
|
||||||
|
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
|
||||||
|
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/6.png 2x`
|
||||||
|
}
|
||||||
|
alt={speciesName}
|
||||||
|
width={55}
|
||||||
|
height={55}
|
||||||
|
data-is-loading={isLoading}
|
||||||
|
data-is-disabled={isDisabled}
|
||||||
|
className={css`
|
||||||
|
filter: saturate(90%);
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&[data-is-disabled="true"] {
|
||||||
|
filter: saturate(0%);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-is-loading="true"] {
|
||||||
|
animation: 0.8s linear 0s infinite alternate none running
|
||||||
|
pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + * &[data-body-is-disabled="false"] {
|
||||||
|
opacity: 1;
|
||||||
|
filter: saturate(110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + * &[data-body-is-disabled="true"] {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
from {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alt text for when the image fails to load! We hide it
|
||||||
|
* while still loading though! */
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
&:-moz-loading {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
&:-moz-broken {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</DeferredTooltip>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CrossFadeImage is like <img>, but listens for successful load events, and
|
||||||
|
* fades from the previous image to the new image once it loads.
|
||||||
|
*
|
||||||
|
* We treat `src` as a unique key representing the image's identity, but we
|
||||||
|
* also carry along the rest of the props during the fade, like `srcSet` and
|
||||||
|
* `className`.
|
||||||
|
*/
|
||||||
|
function CrossFadeImage(incomingImageProps) {
|
||||||
|
const [prevImageProps, setPrevImageProps] = React.useState(null);
|
||||||
|
const [currentImageProps, setCurrentImageProps] = React.useState(null);
|
||||||
|
|
||||||
|
const incomingImageIsCurrentImage =
|
||||||
|
incomingImageProps.src === currentImageProps?.src;
|
||||||
|
|
||||||
|
const onLoadNextImage = () => {
|
||||||
|
setPrevImageProps(currentImageProps);
|
||||||
|
setCurrentImageProps(incomingImageProps);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The main trick to this component is using React's `key` feature! When
|
||||||
|
// diffing the rendered tree, if React sees two nodes with the same `key`, it
|
||||||
|
// treats them as the same node and makes the prop changes to match.
|
||||||
|
//
|
||||||
|
// We usually use this in `.map`, to make sure that adds/removes in a list
|
||||||
|
// don't cause our children to shift around and swap their React state or DOM
|
||||||
|
// nodes with each other.
|
||||||
|
//
|
||||||
|
// But here, we use `key` to get React to transition the same <img> DOM node
|
||||||
|
// between 3 different states!
|
||||||
|
//
|
||||||
|
// The image starts its life as the last in the list, from
|
||||||
|
// `incomingImageProps`: it's invisible, and still loading. We use its `src`
|
||||||
|
// as the `key`.
|
||||||
|
//
|
||||||
|
// When it loads, we update the state so that this `key` now belongs to the
|
||||||
|
// _second_ node, from `currentImageProps`. React will see this and make the
|
||||||
|
// correct transition for us: it sets opacity to 0, sets z-index to 2,
|
||||||
|
// removes aria-hidden, and removes the `onLoad` handler.
|
||||||
|
//
|
||||||
|
// Then, when another image is ready to show, we update the state so that
|
||||||
|
// this key now belongs to the _first_ node, from `prevImageProps` (and the
|
||||||
|
// second node is showing something new). React sees this, and makes the
|
||||||
|
// transition back to invisibility, but without the `onLoad` handler this
|
||||||
|
// time! (And transitions the current image into view, like it did for this
|
||||||
|
// one.)
|
||||||
|
//
|
||||||
|
// Finally, when yet _another_ image is ready to show, we stop rendering any
|
||||||
|
// images with this key anymore, and so React unmounts the image entirely.
|
||||||
|
//
|
||||||
|
// Thanks, React, for handling our multiple overlapping transitions through
|
||||||
|
// this little state machine! This could have been a LOT harder to write,
|
||||||
|
// whew!
|
||||||
|
return (
|
||||||
|
<ClassNames>
|
||||||
|
{({ css }) => (
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "shared-overlapping-area";
|
||||||
|
isolation: isolate; /* Avoid z-index conflicts with parent! */
|
||||||
|
|
||||||
|
> div {
|
||||||
|
grid-area: shared-overlapping-area;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{prevImageProps && (
|
||||||
|
<div
|
||||||
|
key={prevImageProps.src}
|
||||||
|
className={css`
|
||||||
|
z-index: 3;
|
||||||
|
opacity: 0;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
|
<img {...prevImageProps} aria-hidden />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentImageProps && (
|
||||||
|
<div
|
||||||
|
key={currentImageProps.src}
|
||||||
|
className={css`
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
|
<img
|
||||||
|
{...currentImageProps}
|
||||||
|
// If the current image _is_ the incoming image, we'll allow
|
||||||
|
// new props to come in and affect it. But if it's a new image
|
||||||
|
// incoming, we want to stick to the last props the current
|
||||||
|
// image had! (This matters for e.g. `bodyIsCompatible`
|
||||||
|
// becoming true in `SpeciesFaceOption` and restoring color,
|
||||||
|
// before the new color's image loads in.)
|
||||||
|
{...(incomingImageIsCurrentImage ? incomingImageProps : {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!incomingImageIsCurrentImage && (
|
||||||
|
<div
|
||||||
|
key={incomingImageProps.src}
|
||||||
|
className={css`
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
|
<img
|
||||||
|
{...incomingImageProps}
|
||||||
|
aria-hidden
|
||||||
|
onLoad={onLoadNextImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is
|
||||||
|
* true before mounting it, and unmounts it after closing.
|
||||||
|
*
|
||||||
|
* This can drastically improve render performance when there are lots of
|
||||||
|
* tooltip targets to re-render… but it comes with some limitations, like the
|
||||||
|
* extra requirement to control `isOpen`, and some additional DOM structure!
|
||||||
|
*/
|
||||||
|
function DeferredTooltip({ children, isOpen, ...props }) {
|
||||||
|
const [shouldShowTooltip, setShouldShowToolip] = React.useState(isOpen);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setShouldShowToolip(true);
|
||||||
|
} else {
|
||||||
|
const timeoutId = setTimeout(() => setShouldShowToolip(false), 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClassNames>
|
||||||
|
{({ css }) => (
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
position: relative;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{shouldShowTooltip && (
|
||||||
|
<Tooltip isOpen={isOpen} {...props}>
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK: I'm just hardcoding all this, rather than connecting up to the
|
||||||
|
// database and adding a loading state. Tbh I'm not sure it's a good idea
|
||||||
|
// to load this dynamically until we have SSR to make it come in fast!
|
||||||
|
// And it's not so bad if this gets out of sync with the database,
|
||||||
|
// because the SpeciesColorPicker will still be usable!
|
||||||
|
const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" };
|
||||||
|
|
||||||
|
export function colorIsBasic(colorId) {
|
||||||
|
return ["8", "34", "61", "84"].includes(colorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SPECIES_FACES = [
|
||||||
|
{
|
||||||
|
speciesName: "Acara",
|
||||||
|
speciesId: "1",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "93",
|
||||||
|
neopetsImageHash: "obxdjm88",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Aisha",
|
||||||
|
speciesId: "2",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "106",
|
||||||
|
neopetsImageHash: "n9ozx4z5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Blumaroo",
|
||||||
|
speciesId: "3",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "47",
|
||||||
|
neopetsImageHash: "kfonqhdc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Bori",
|
||||||
|
speciesId: "4",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "84",
|
||||||
|
neopetsImageHash: "sc2hhvhn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Bruce",
|
||||||
|
speciesId: "5",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "146",
|
||||||
|
neopetsImageHash: "wqz8xn4t",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Buzz",
|
||||||
|
speciesId: "6",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "250",
|
||||||
|
neopetsImageHash: "jc9klfxm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Chia",
|
||||||
|
speciesId: "7",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "212",
|
||||||
|
neopetsImageHash: "4lrb4n3f",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Chomby",
|
||||||
|
speciesId: "8",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "74",
|
||||||
|
neopetsImageHash: "bdml26md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Cybunny",
|
||||||
|
speciesId: "9",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "94",
|
||||||
|
neopetsImageHash: "xl6msllv",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Draik",
|
||||||
|
speciesId: "10",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "132",
|
||||||
|
neopetsImageHash: "bob39shq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Elephante",
|
||||||
|
speciesId: "11",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "56",
|
||||||
|
neopetsImageHash: "jhhhbrww",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Eyrie",
|
||||||
|
speciesId: "12",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "90",
|
||||||
|
neopetsImageHash: "6kngmhvs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Flotsam",
|
||||||
|
speciesId: "13",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "136",
|
||||||
|
neopetsImageHash: "47vt32x2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Gelert",
|
||||||
|
speciesId: "14",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "138",
|
||||||
|
neopetsImageHash: "5nrd2lvd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Gnorbu",
|
||||||
|
speciesId: "15",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "166",
|
||||||
|
neopetsImageHash: "6c275jcg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Grarrl",
|
||||||
|
speciesId: "16",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "119",
|
||||||
|
neopetsImageHash: "j7q65fv4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Grundo",
|
||||||
|
speciesId: "17",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "126",
|
||||||
|
neopetsImageHash: "5xn4kjf8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Hissi",
|
||||||
|
speciesId: "18",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "67",
|
||||||
|
neopetsImageHash: "jsfvcqwt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Ixi",
|
||||||
|
speciesId: "19",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "163",
|
||||||
|
neopetsImageHash: "w32r74vo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Jetsam",
|
||||||
|
speciesId: "20",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "147",
|
||||||
|
neopetsImageHash: "kz43rnld",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Jubjub",
|
||||||
|
speciesId: "21",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "80",
|
||||||
|
neopetsImageHash: "m267j935",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kacheek",
|
||||||
|
speciesId: "22",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "117",
|
||||||
|
neopetsImageHash: "4gsrb59g",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kau",
|
||||||
|
speciesId: "23",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "201",
|
||||||
|
neopetsImageHash: "ktlxmrtr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kiko",
|
||||||
|
speciesId: "24",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "51",
|
||||||
|
neopetsImageHash: "42j5q3zx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Koi",
|
||||||
|
speciesId: "25",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "208",
|
||||||
|
neopetsImageHash: "ncfn87wk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Korbat",
|
||||||
|
speciesId: "26",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "196",
|
||||||
|
neopetsImageHash: "omx9c876",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kougra",
|
||||||
|
speciesId: "27",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "143",
|
||||||
|
neopetsImageHash: "rfsbh59t",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Krawk",
|
||||||
|
speciesId: "28",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "150",
|
||||||
|
neopetsImageHash: "hxgsm5d4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kyrii",
|
||||||
|
speciesId: "29",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "175",
|
||||||
|
neopetsImageHash: "blxmjgbk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Lenny",
|
||||||
|
speciesId: "30",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "173",
|
||||||
|
neopetsImageHash: "8r94jhfq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Lupe",
|
||||||
|
speciesId: "31",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "199",
|
||||||
|
neopetsImageHash: "z42535zh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Lutari",
|
||||||
|
speciesId: "32",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "52",
|
||||||
|
neopetsImageHash: "qgg6z8s7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Meerca",
|
||||||
|
speciesId: "33",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "109",
|
||||||
|
neopetsImageHash: "kk2nn2jr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Moehog",
|
||||||
|
speciesId: "34",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "134",
|
||||||
|
neopetsImageHash: "jgkoro5z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Mynci",
|
||||||
|
speciesId: "35",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "95",
|
||||||
|
neopetsImageHash: "xwlo9657",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Nimmo",
|
||||||
|
speciesId: "36",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "96",
|
||||||
|
neopetsImageHash: "bx7fho8x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Ogrin",
|
||||||
|
speciesId: "37",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "154",
|
||||||
|
neopetsImageHash: "rjzmx24v",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Peophin",
|
||||||
|
speciesId: "38",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "55",
|
||||||
|
neopetsImageHash: "kokc52kh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Poogle",
|
||||||
|
speciesId: "39",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "76",
|
||||||
|
neopetsImageHash: "fw6lvf3c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Pteri",
|
||||||
|
speciesId: "40",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "156",
|
||||||
|
neopetsImageHash: "tjhwbro3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Quiggle",
|
||||||
|
speciesId: "41",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "78",
|
||||||
|
neopetsImageHash: "jdto7mj4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Ruki",
|
||||||
|
speciesId: "42",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "191",
|
||||||
|
neopetsImageHash: "qsgbm5f6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Scorchio",
|
||||||
|
speciesId: "43",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "187",
|
||||||
|
neopetsImageHash: "hkjoncsx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Shoyru",
|
||||||
|
speciesId: "44",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "46",
|
||||||
|
neopetsImageHash: "mmvn4tkg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Skeith",
|
||||||
|
speciesId: "45",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "178",
|
||||||
|
neopetsImageHash: "fc4cxk3t",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Techo",
|
||||||
|
speciesId: "46",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "100",
|
||||||
|
neopetsImageHash: "84gvowmj",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Tonu",
|
||||||
|
speciesId: "47",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "130",
|
||||||
|
neopetsImageHash: "jd433863",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Tuskaninny",
|
||||||
|
speciesId: "48",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "188",
|
||||||
|
neopetsImageHash: "q39wn6vq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Uni",
|
||||||
|
speciesId: "49",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "257",
|
||||||
|
neopetsImageHash: "njzvoflw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Usul",
|
||||||
|
speciesId: "50",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "206",
|
||||||
|
neopetsImageHash: "rox4mgh5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Vandagyre",
|
||||||
|
speciesId: "55",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "306",
|
||||||
|
neopetsImageHash: "xkntzsww",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Wocky",
|
||||||
|
speciesId: "51",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "101",
|
||||||
|
neopetsImageHash: "dnr2kj4b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Xweetok",
|
||||||
|
speciesId: "52",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "68",
|
||||||
|
neopetsImageHash: "tdkqr2b6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Yurble",
|
||||||
|
speciesId: "53",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "182",
|
||||||
|
neopetsImageHash: "h95cs547",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Zafara",
|
||||||
|
speciesId: "54",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "180",
|
||||||
|
neopetsImageHash: "x8c57g2l",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default SpeciesFacesPicker;
|
Loading…
Reference in a new issue