Bundle wardrobe-2020 into the app

We add jsbuilding-rails to get esbuild running in the app, and then we copy-paste the files we need from impress-2020 into here!

I stopped at the point where it was building successfully, but it's not running correctly: it's not sure about `process.env` in `next`, and I think the right next step is to delete the NextJS deps altogether and use React Router instead.
This commit is contained in:
Emi Matchu 2023-08-10 15:56:36 -07:00
parent 0e7bbd526f
commit 81b2a2b4a2
63 changed files with 18135 additions and 1 deletions

13
.gitignore vendored
View file

@ -4,3 +4,16 @@ log/*.log
tmp/**/*
.env
.vagrant
/app/assets/builds/*
!/app/assets/builds/.keep
/node_modules
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View file

@ -78,3 +78,5 @@ group :test do
gem 'factory_girl_rails', '~> 4.9'
gem 'rspec-rails', '~> 2.0.0.beta.22'
end
gem "jsbundling-rails", "~> 1.1"

View file

@ -150,6 +150,8 @@ GEM
http_accept_language (2.1.1)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
jsbundling-rails (1.1.2)
railties (>= 6.0.0)
launchy (2.5.2)
addressable (~> 2.8)
letter_opener (1.8.1)
@ -329,6 +331,7 @@ DEPENDENCIES
globalize (~> 6.2, >= 6.2.1)
haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1)
jsbundling-rails (~> 1.1)
letter_opener (~> 1.8, >= 1.8.1)
memcache-client (~> 1.8.5)
mysql2 (~> 0.5.5)
@ -354,4 +357,4 @@ RUBY VERSION
ruby 3.1.4p223
BUNDLED WITH
2.4.18
2.3.26

2
Procfile.dev Normal file
View file

@ -0,0 +1,2 @@
web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server
js: yarn build --watch

0
app/assets/builds/.keep Normal file
View file

View file

@ -0,0 +1,3 @@
import { WardrobePage } from "./wardrobe-2020";
console.log("Hello, wardrobe page!", WardrobePage);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,902 @@
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, @next/next/no-img-element */}
<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, @next/next/no-img-element */}
<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, @next/next/no-img-element */}
<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;

View file

@ -0,0 +1,30 @@
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;

View file

@ -0,0 +1,411 @@
import React from "react";
import {
Badge,
Box,
Flex,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
Portal,
Select,
Skeleton,
Spinner,
Tooltip,
useToast,
VStack,
} from "@chakra-ui/react";
import { ExternalLinkIcon, ChevronRightIcon } from "@chakra-ui/icons";
import { gql, useMutation } from "@apollo/client";
import {
ItemBadgeList,
ItemKindBadge,
ItemThumbnail,
} from "./components/ItemCard";
import { Heading1 } from "./util";
import useSupport from "./WardrobePage/support/useSupport";
function ItemPageLayout({ children, item, isEmbedded }) {
return (
<Box>
<ItemPageHeader item={item} isEmbedded={isEmbedded} />
<Box>{children}</Box>
</Box>
);
}
function ItemPageHeader({ item, isEmbedded }) {
return (
<Box
display="flex"
alignItems="center"
justifyContent="flex-start"
width="100%"
>
<SubtleSkeleton isLoaded={item?.thumbnailUrl} marginRight="4">
<ItemThumbnail item={item} size="lg" isActive flex="0 0 auto" />
</SubtleSkeleton>
<Box>
<SubtleSkeleton isLoaded={item?.name}>
<Heading1
lineHeight="1.1"
// Nudge down the size a bit in the embed case, to better fit the
// tighter layout!
size={isEmbedded ? "xl" : "2xl"}
>
{item?.name || "Item name here"}
</Heading1>
</SubtleSkeleton>
<ItemPageBadges item={item} isEmbedded={isEmbedded} />
</Box>
</Box>
);
}
/**
* SubtleSkeleton hides the skeleton animation until a second has passed, and
* doesn't fade in the content if it loads near-instantly. This helps avoid
* flash-of-content stuff!
*
* For plain Skeletons, we often use <Delay><Skeleton /></Delay> instead. But
* that pattern doesn't work as well for wrapper skeletons where we're using
* placeholder content for layout: we don't want the delay if the content
* really _is_ present!
*/
export function SubtleSkeleton({ isLoaded, ...props }) {
const [shouldFadeIn, setShouldFadeIn] = React.useState(false);
const [shouldShowSkeleton, setShouldShowSkeleton] = React.useState(false);
React.useEffect(() => {
const t = setTimeout(() => {
if (!isLoaded) {
setShouldFadeIn(true);
}
}, 150);
return () => clearTimeout(t);
});
React.useEffect(() => {
const t = setTimeout(() => setShouldShowSkeleton(true), 500);
return () => clearTimeout(t);
});
return (
<Skeleton
fadeDuration={shouldFadeIn ? undefined : 0}
startColor={shouldShowSkeleton ? undefined : "transparent"}
endColor={shouldShowSkeleton ? undefined : "transparent"}
isLoaded={isLoaded}
{...props}
/>
);
}
function ItemPageBadges({ item, isEmbedded }) {
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
return (
<ItemBadgeList marginTop="1">
<SubtleSkeleton isLoaded={item?.isNc != null}>
<ItemKindBadgeWithSupportTools item={item} />
</SubtleSkeleton>
{
// If the createdAt date is null (loaded and empty), hide the badge.
item?.createdAt !== null && (
<SubtleSkeleton
// Distinguish between undefined (still loading) and null (loaded and
// empty).
isLoaded={item?.createdAt !== undefined}
>
<Badge
display="block"
minWidth="5.25em"
boxSizing="content-box"
textAlign="center"
>
{item?.createdAt && <ShortTimestamp when={item?.createdAt} />}
</Badge>
</SubtleSkeleton>
)
}
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
<LinkBadge
href={`https://impress.openneo.net/items/${item?.id}`}
isEmbedded={isEmbedded}
>
Classic DTI
</LinkBadge>
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
<LinkBadge
href={
"https://items.jellyneo.net/search/?name=" +
encodeURIComponent(item?.name) +
"&name_type=3"
}
isEmbedded={isEmbedded}
>
Jellyneo
</LinkBadge>
</SubtleSkeleton>
{item?.isNc && (
<SubtleSkeleton
isLoaded={
// Distinguish between undefined (still loading) and null (loaded
// and empty).
item?.ncTradeValueText !== undefined
}
>
{item?.ncTradeValueText && (
<LinkBadge href="http://www.neopets.com/~owls">
OWLS: {item?.ncTradeValueText}
</LinkBadge>
)}
</SubtleSkeleton>
)}
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/shops/wizard.phtml?string=" +
encodeURIComponent(item?.name)
}
isEmbedded={isEmbedded}
>
Shop Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/portal/supershopwiz.phtml?string=" +
encodeURIComponent(item?.name)
}
isEmbedded={isEmbedded}
>
Super Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&search_string=" +
encodeURIComponent(item?.name)
}
isEmbedded={isEmbedded}
>
Trade Post
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"http://www.neopets.com/genie.phtml?type=process_genie&criteria=exact&auctiongenie=" +
encodeURIComponent(item?.name)
}
isEmbedded={isEmbedded}
>
Auctions
</LinkBadge>
)}
</SubtleSkeleton>
</ItemBadgeList>
);
}
function ItemKindBadgeWithSupportTools({ item }) {
const { isSupportUser, supportSecret } = useSupport();
const toast = useToast();
const ncRef = React.useRef(null);
const isNcAutoDetectedFromRarity =
item?.rarityIndex === 500 || item?.rarityIndex === 0;
const [mutate, { loading }] = useMutation(gql`
mutation ItemPageSupportSetIsManuallyNc(
$itemId: ID!
$isManuallyNc: Boolean!
$supportSecret: String!
) {
setItemIsManuallyNc(
itemId: $itemId
isManuallyNc: $isManuallyNc
supportSecret: $supportSecret
) {
id
isNc
isManuallyNc
}
}
`);
if (
isSupportUser &&
item?.rarityIndex != null &&
item?.isManuallyNc != null
) {
// TODO: Could code-split this into a SupportOnly file...
return (
<Popover placement="bottom-start" initialFocusRef={ncRef} showArrow>
<PopoverTrigger>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} isEditButton />
</PopoverTrigger>
<Portal>
<PopoverContent padding="4">
<PopoverArrow />
<VStack spacing="2" align="flex-start">
<Flex align="center">
<Box as="span" fontWeight="600" marginRight="2">
NC:
</Box>
<Select
ref={ncRef}
size="xs"
value={item.isManuallyNc ? "true" : "false"}
onChange={(e) => {
const isManuallyNc = e.target.value === "true";
mutate({
variables: {
itemId: item.id,
isManuallyNc,
supportSecret,
},
optimisticResponse: {
setItemIsManuallyNc: {
__typename: "Item",
id: item.id,
isNc: isManuallyNc || isNcAutoDetectedFromRarity,
isManuallyNc,
},
},
}).catch((e) => {
console.error(e);
toast({
status: "error",
title:
"Could not set NC status for this item. Try again?",
});
});
}}
>
<option value="false">
Auto-detect: {isNcAutoDetectedFromRarity ? "Yes" : "No"}.{" "}
(Rarity {item.rarityIndex})
</option>
<option value="true">Manually set: Yes.</option>
</Select>
{loading && <Spinner size="sm" marginLeft="2" />}
</Flex>
<Flex align="center">
<Box as="span" fontWeight="600" marginRight="1">
PB:
</Box>
<Select size="xs" isReadOnly value="auto-detect">
<option value="auto-detect">
Auto-detect: {item.isPb ? "Yes" : "No"}. (from description)
</option>
<option style={{ fontStyle: "italic" }}>
(This cannot be manually set.)
</option>
</Select>
</Flex>
<Badge
colorScheme="pink"
alignSelf="flex-end"
marginBottom="-2"
marginRight="-2"
>
Support <span aria-hidden="true">💖</span>
</Badge>
</VStack>
</PopoverContent>
</Portal>
</Popover>
);
}
return <ItemKindBadge isNc={item?.isNc} isPb={item?.isPb} />;
}
const LinkBadge = React.forwardRef(
({ children, href, isEmbedded, ...props }, ref) => {
return (
<Badge
ref={ref}
as="a"
href={href}
display="flex"
alignItems="center"
// Normally we want to act like a normal webpage, and treat links as
// normal. But when we're on the wardrobe page, we want to avoid
// disrupting the outfit, and open in a new window instead.
target={isEmbedded ? "_blank" : undefined}
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
{children}
{
// We also change the icon to signal whether this will launch in a new
// window or not!
isEmbedded ? (
<ExternalLinkIcon marginLeft="1" />
) : (
<ChevronRightIcon />
)
}
</Badge>
);
}
);
const fullDateFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
});
const monthYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
});
const monthDayYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
function ShortTimestamp({ when }) {
const date = new Date(when);
// To find the start of last month, take today, then set its date to the 1st
// and its time to midnight (the start of this month), and subtract one
// month. (JS handles negative months and rolls them over correctly.)
const startOfLastMonth = new Date();
startOfLastMonth.setDate(1);
startOfLastMonth.setHours(0);
startOfLastMonth.setMinutes(0);
startOfLastMonth.setSeconds(0);
startOfLastMonth.setMilliseconds(0);
startOfLastMonth.setMonth(startOfLastMonth.getMonth() - 1);
const dateIsOlderThanLastMonth = date < startOfLastMonth;
return (
<Tooltip
label={`First seen on ${fullDateFormatter.format(date)}`}
placement="top"
openDelay={400}
>
{dateIsOlderThanLastMonth
? monthYearFormatter.format(date)
: monthDayYearFormatter.format(date)}
</Tooltip>
);
}
export default ItemPageLayout;

View file

@ -0,0 +1,336 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
Box,
Flex,
IconButton,
Skeleton,
Tooltip,
useColorModeValue,
useTheme,
} from "@chakra-ui/react";
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
import Link from "next/link";
import { loadable } from "../util";
import {
ItemCardContent,
ItemBadgeList,
ItemKindBadge,
MaybeAnimatedBadge,
YouOwnThisBadge,
YouWantThisBadge,
getZoneBadges,
} from "../components/ItemCard";
import SupportOnly from "./support/SupportOnly";
import useSupport from "./support/useSupport";
const LoadableItemPageDrawer = loadable(() => import("../ItemPageDrawer"));
const LoadableItemSupportDrawer = loadable(() =>
import("./support/ItemSupportDrawer")
);
/**
* Item show a basic summary of an item, in the context of the current outfit!
*
* It also responds to the focus state of an `input` as its previous sibling.
* This will be an invisible radio/checkbox that controls the actual wear
* state.
*
* In fact, this component can't trigger wear or unwear events! When you click
* it in the app, you're actually clicking a <label> that wraps the radio or
* checkbox. Similarly, the parent provides the `onRemove` callback for the
* Remove button.
*
* NOTE: This component is memoized with React.memo. It's surpisingly expensive
* to re-render, because Chakra components are a lil bit expensive from
* their internal complexity, and we have a lot of them here. And it can
* add up when there's a lot of Items in the list. This contributes to
* wearing/unwearing items being noticeably slower on lower-power
* devices.
*/
function Item({
item,
itemNameId,
isWorn,
isInOutfit,
onRemove,
isDisabled = false,
}) {
const [infoDrawerIsOpen, setInfoDrawerIsOpen] = React.useState(false);
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
return (
<>
<ItemContainer isDisabled={isDisabled}>
<Box flex="1 1 0" minWidth="0">
<ItemCardContent
item={item}
badges={<ItemBadges item={item} />}
itemNameId={itemNameId}
isWorn={isWorn}
isDiabled={isDisabled}
focusSelector={containerHasFocus}
/>
</Box>
<Box flex="0 0 auto" marginTop="5px">
{isInOutfit && (
<ItemActionButton
icon={<DeleteIcon />}
label="Remove"
onClick={(e) => {
onRemove(item.id);
e.preventDefault();
}}
/>
)}
<SupportOnly>
<ItemActionButton
icon={<EditIcon />}
label="Support"
onClick={(e) => {
setSupportDrawerIsOpen(true);
e.preventDefault();
}}
/>
</SupportOnly>
<ItemActionButton
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();
}}
/>
</Box>
</ItemContainer>
<LoadableItemPageDrawer
item={item}
isOpen={infoDrawerIsOpen}
onClose={() => setInfoDrawerIsOpen(false)}
/>
<SupportOnly>
<LoadableItemSupportDrawer
item={item}
isOpen={supportDrawerIsOpen}
onClose={() => setSupportDrawerIsOpen(false)}
/>
</SupportOnly>
</>
);
}
/**
* ItemSkeleton is a placeholder for when an Item is loading.
*/
function ItemSkeleton() {
return (
<ItemContainer isDisabled>
<Skeleton width="50px" height="50px" />
<Box width="3" />
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
</ItemContainer>
);
}
/**
* ItemContainer is the outermost element of an `Item`.
*
* It provides spacing, but also is responsible for a number of hover/focus/etc
* styles - including for its children, who sometimes reference it as an
* .item-container parent!
*/
function ItemContainer({ children, isDisabled = false }) {
const theme = useTheme();
const focusBackgroundColor = useColorModeValue(
theme.colors.gray["100"],
theme.colors.gray["700"]
);
const activeBorderColor = useColorModeValue(
theme.colors.green["400"],
theme.colors.green["500"]
);
const focusCheckedBorderColor = useColorModeValue(
theme.colors.green["800"],
theme.colors.green["300"]
);
return (
<ClassNames>
{({ css, cx }) => (
<Box
p="1"
my="1"
borderRadius="lg"
d="flex"
cursor={isDisabled ? undefined : "pointer"}
border="1px"
borderColor="transparent"
className={cx([
"item-container",
!isDisabled &&
css`
&:hover,
input:focus + & {
background-color: ${focusBackgroundColor};
}
input:active + & {
border-color: ${activeBorderColor};
}
input:checked:focus + & {
border-color: ${focusCheckedBorderColor};
}
`,
])}
>
{children}
</Box>
)}
</ClassNames>
);
}
function ItemBadges({ item }) {
const { isSupportUser } = useSupport();
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
const restrictedZones = item.appearanceOn.restrictedZones.filter(
(z) => z.isCommonlyUsedByItems
);
const isMaybeAnimated = item.appearanceOn.layers.some(
(l) => l.canvasMovieLibraryUrl
);
return (
<ItemBadgeList>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
{
// This badge is unreliable, but it's helpful for looking for animated
// items to test, so we show it only to support. We use this form
// instead of <SupportOnly />, to avoid adding extra badge list spacing
// on the additional empty child.
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
}
{getZoneBadges(occupiedZones, { variant: "occupies" })}
{getZoneBadges(restrictedZones, { variant: "restricts" })}
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
</ItemBadgeList>
);
}
/**
* ItemActionButton is one of a list of actions a user can take for this item.
*/
function ItemActionButton({ icon, label, to, onClick }) {
const theme = useTheme();
const focusBackgroundColor = useColorModeValue(
theme.colors.gray["300"],
theme.colors.gray["800"]
);
const focusColor = useColorModeValue(
theme.colors.gray["700"],
theme.colors.gray["200"]
);
return (
<ClassNames>
{({ css }) => (
<Tooltip label={label} placement="top">
<LinkOrButton
component={IconButton}
href={to}
icon={icon}
aria-label={label}
variant="ghost"
color="gray.400"
onClick={onClick}
className={css`
opacity: 0;
transition: all 0.2s;
${containerHasFocus} {
opacity: 1;
}
&:focus,
&:hover {
opacity: 1;
background-color: ${focusBackgroundColor};
color: ${focusColor};
}
/* On touch devices, always show the buttons! This avoids having to
* tap to reveal them (which toggles the item), or worse,
* accidentally tapping a hidden button without realizing! */
@media (hover: none) {
opacity: 1;
}
`}
/>
</Tooltip>
)}
</ClassNames>
);
}
function LinkOrButton({ href, component = Button, ...props }) {
const ButtonComponent = component;
if (href != null) {
return (
<Link href={href} passHref>
<ButtonComponent as="a" {...props} />
</Link>
);
} else {
return <ButtonComponent {...props} />;
}
}
/**
* ItemListContainer is a container for Item components! Wrap your Item
* components in this to ensure a consistent list layout.
*/
export function ItemListContainer({ children, ...props }) {
return (
<Flex direction="column" {...props}>
{children}
</Flex>
);
}
/**
* ItemListSkeleton is a placeholder for when an ItemListContainer and its
* Items are loading.
*/
export function ItemListSkeleton({ count, ...props }) {
return (
<ItemListContainer {...props}>
{Array.from({ length: count }).map((_, i) => (
<ItemSkeleton key={i} />
))}
</ItemListContainer>
);
}
/**
* containerHasFocus is a common CSS selector, for the case where our parent
* .item-container is hovered or the adjacent hidden radio/checkbox is
* focused.
*/
const containerHasFocus =
".item-container:hover &, input:focus + .item-container &";
export default React.memo(Item);

View file

@ -0,0 +1,92 @@
import React from "react";
import { Box, Flex, useBreakpointValue } from "@chakra-ui/react";
import * as Sentry from "@sentry/react";
import ItemsPanel from "./ItemsPanel";
import SearchToolbar, { searchQueryIsEmpty } from "./SearchToolbar";
import SearchPanel from "./SearchPanel";
import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
/**
* ItemsAndSearchPanels manages the shared layout and state for:
* - ItemsPanel, which shows the items in the outfit now, and
* - SearchPanel, which helps you find new items to add.
*
* These panels don't share a _lot_ of concerns; they're mainly intertwined by
* the fact that they share the SearchToolbar at the top!
*
* We try to keep the search concerns in the search components, by avoiding
* letting any actual _logic_ live at the root here; and instead just
* performing some wiring to help them interact with each other via simple
* state and refs.
*/
function ItemsAndSearchPanels({
loading,
searchQuery,
onChangeSearchQuery,
outfitState,
outfitSaving,
dispatchToOutfit,
}) {
const scrollContainerRef = React.useRef();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
const [canUseSearchFooter] = useLocalStorage(
"DTIFeatureFlagCanUseSearchFooter",
false
);
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Flex direction="column" height="100%">
{isShowingSearchFooter && <Box height="2" />}
{!isShowingSearchFooter && (
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
<SearchToolbar
query={searchQuery}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
onChange={onChangeSearchQuery}
/>
</Box>
)}
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
<Box
key="search-panel"
flex="1 0 0"
position="relative"
overflowY="scroll"
ref={scrollContainerRef}
data-test-id="search-panel-scroll-container"
>
<SearchPanel
query={searchQuery}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
/>
</Box>
) : (
<Box position="relative" overflow="auto" key="items-panel">
<Box px="4" py="2">
<ItemsPanel
loading={loading}
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
)}
</Flex>
</Sentry.ErrorBoundary>
);
}
export default ItemsAndSearchPanels;

View file

@ -0,0 +1,557 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
Box,
Editable,
EditablePreview,
EditableInput,
Flex,
IconButton,
Skeleton,
Tooltip,
VisuallyHidden,
Menu,
MenuButton,
MenuList,
MenuItem,
Portal,
Button,
Spinner,
useColorModeValue,
Modal,
ModalContent,
ModalOverlay,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
ModalCloseButton,
} from "@chakra-ui/react";
import {
CheckIcon,
DeleteIcon,
EditIcon,
QuestionIcon,
WarningTwoIcon,
} from "@chakra-ui/icons";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import {
Delay,
ErrorMessage,
getGraphQLErrorMessage,
Heading1,
Heading2,
} from "../util";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import { BiRename } from "react-icons/bi";
import { IoCloudUploadOutline } from "react-icons/io5";
import { MdMoreVert } from "react-icons/md";
import { buildOutfitUrl } from "./useOutfitState";
import { gql, useMutation } from "@apollo/client";
import { useRouter } from "next/router";
/**
* ItemsPanel shows the items in the current outfit, and lets the user toggle
* between them! It also shows an editable outfit name, to help set context.
*
* Note that this component provides an effective 1 unit of padding around
* itself, which is uncommon in this app: we usually prefer to let parents
* control the spacing!
*
* This is because Item has padding, but it's generally not visible; so, to
* *look* aligned with the other elements like the headings, the headings need
* to have extra padding. Essentially: while the Items _do_ stretch out the
* full width of the container, it doesn't look like it!
*/
function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
const { zonesAndItems, incompatibleItems } = outfitState;
return (
<ClassNames>
{({ css }) => (
<Box>
<Box px="1">
<OutfitHeading
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Flex direction="column">
{loading ? (
<ItemZoneGroupsSkeleton
itemCount={outfitState.allItemIds.length}
/>
) : (
<TransitionGroup component={null}>
{zonesAndItems.map(({ zoneLabel, items }) => (
<CSSTransition
key={zoneLabel}
{...fadeOutAndRollUpTransition(css)}
>
<ItemZoneGroup
zoneLabel={zoneLabel}
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</CSSTransition>
))}
{incompatibleItems.length > 0 && (
<ItemZoneGroup
zoneLabel="Incompatible"
afterHeader={
<Tooltip
label="These items don't fit this pet"
placement="top"
openDelay={100}
>
<QuestionIcon fontSize="sm" />
</Tooltip>
}
items={incompatibleItems}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
isDisabled
/>
)}
</TransitionGroup>
)}
</Flex>
</Box>
)}
</ClassNames>
);
}
/**
* ItemZoneGroup shows the items for a particular zone, and lets the user
* toggle between them.
*
* For each item, it renders a <label> with a visually-hidden radio button and
* the Item component (which will visually reflect the radio's state). This
* makes the list screen-reader- and keyboard-accessible!
*/
function ItemZoneGroup({
zoneLabel,
items,
outfitState,
dispatchToOutfit,
isDisabled = false,
afterHeader = null,
}) {
// onChange is fired when the radio button becomes checked, not unchecked!
const onChange = (e) => {
const itemId = e.target.value;
dispatchToOutfit({ type: "wearItem", itemId });
};
// Clicking the radio button when already selected deselects it - this is how
// you can select none!
const onClick = (e) => {
const itemId = e.target.value;
if (outfitState.wornItemIds.includes(itemId)) {
// We need the event handler to finish before this, so that simulated
// events don't just come back around and undo it - but we can't just
// solve that with `preventDefault`, because it breaks the radio's
// intended visual updates when we unwear. So, we `setTimeout` to do it
// after all event handlers resolve!
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
}
};
const onRemove = React.useCallback(
(itemId) => {
dispatchToOutfit({ type: "removeItem", itemId });
},
[dispatchToOutfit]
);
return (
<ClassNames>
{({ css }) => (
<Box mb="10">
<Heading2 display="flex" alignItems="center" mx="1">
{zoneLabel}
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
</Heading2>
<ItemListContainer>
<TransitionGroup component={null}>
{items.map((item) => {
const itemNameId =
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
const itemNode = (
<Item
item={item}
itemNameId={itemNameId}
isWorn={
!isDisabled && outfitState.wornItemIds.includes(item.id)
}
isInOutfit={outfitState.allItemIds.includes(item.id)}
onRemove={onRemove}
isDisabled={isDisabled}
/>
);
return (
<CSSTransition
key={item.id}
{...fadeOutAndRollUpTransition(css)}
>
{isDisabled ? (
itemNode
) : (
<label>
<VisuallyHidden
as="input"
type="radio"
aria-labelledby={itemNameId}
name={zoneLabel}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange}
onClick={onClick}
onKeyUp={(e) => {
if (e.key === " ") {
onClick(e);
}
}}
/>
{itemNode}
</label>
)}
</CSSTransition>
);
})}
</TransitionGroup>
</ItemListContainer>
</Box>
)}
</ClassNames>
);
}
/**
* ItemZoneGroupSkeletons is a placeholder for when the items are loading.
*
* We try to match the approximate size of the incoming data! This is
* especially nice for when you start with a fresh pet from the homepage, so
* we don't show skeleton items that just clear away!
*/
function ItemZoneGroupsSkeleton({ itemCount }) {
const groups = [];
for (let i = 0; i < itemCount; i++) {
// NOTE: I initially wrote this to return groups of 3, which looks good for
// outfit shares I think, but looks bad for pet loading... once shares
// become a more common use case, it might be useful to figure out how
// to differentiate these cases and show 1-per-group for pets, but
// maybe more for built outfits?
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
}
return groups;
}
/**
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
*/
function ItemZoneGroupSkeleton({ itemCount }) {
return (
<Box mb="10">
<Delay>
<Skeleton
mx="1"
// 2.25rem font size, 1.25rem line height
height={`${2.25 * 1.25}rem`}
width="12rem"
/>
<ItemListSkeleton count={itemCount} />
</Delay>
</Box>
);
}
/**
* OutfitSavingIndicator shows a Save button, or the "Saved" or "Saving" state,
* if the user can save this outfit. If not, this is empty!
*/
function OutfitSavingIndicator({ outfitSaving }) {
const {
canSaveOutfit,
isNewOutfit,
isSaving,
latestVersionIsSaved,
saveError,
saveOutfit,
} = outfitSaving;
const errorTextColor = useColorModeValue("red.600", "red.400");
if (!canSaveOutfit) {
return null;
}
if (isNewOutfit) {
return (
<Button
variant="outline"
size="sm"
isLoading={isSaving}
loadingText="Saving…"
leftIcon={
<Box
// Adjust the visual balance toward the cloud
marginBottom="-2px"
>
<IoCloudUploadOutline />
</Box>
}
onClick={saveOutfit}
data-test-id="wardrobe-save-outfit-button"
>
Save
</Button>
);
}
if (isSaving) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saving-indicator"
>
<Spinner
size="xs"
marginRight="1.5"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saving
</Flex>
);
}
if (latestVersionIsSaved) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saved-indicator"
>
<CheckIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saved
</Flex>
);
}
if (saveError) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-save-error-indicator"
color={errorTextColor}
>
<WarningTwoIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Error saving
</Flex>
);
}
// The most common way we'll hit this null is when the outfit is changing,
// but the debouncing isn't done yet, so it's not saving yet.
return null;
}
/**
* OutfitHeading is an editable outfit name, as a big pretty page heading!
* It also contains the outfit menu, for saving etc.
*/
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
const { canDeleteOutfit } = outfitSaving;
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
return (
// The Editable wraps everything, including the menu, because the menu has
// a Rename option.
<Editable
// Make sure not to ever pass `undefined` into here, or else the
// component enters uncontrolled mode, and changing the value
// later won't fix it!
value={outfitState.name || ""}
placeholder="Untitled outfit"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ onEdit }) => (
<Flex align="center" marginBottom="6">
<Box>
<Box role="group" d="inline-block" position="relative" width="100%">
<Heading1>
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
<EditableInput lineHeight="48px" />
</Heading1>
</Box>
</Box>
<Box width="4" flex="1 0 auto" />
<Box flex="0 0 auto">
<OutfitSavingIndicator outfitSaving={outfitSaving} />
</Box>
<Box width="2" />
<Menu placement="bottom-end">
<MenuButton
as={IconButton}
variant="ghost"
icon={<MdMoreVert />}
aria-label="Outfit menu"
borderRadius="full"
fontSize="24px"
opacity="0.8"
/>
<Portal>
<MenuList>
{outfitState.id && (
<MenuItem
icon={<EditIcon />}
as="a"
href={outfitCopyUrl}
target="_blank"
>
Edit a copy
</MenuItem>
)}
<MenuItem
icon={<BiRename />}
onClick={() => {
// Start the rename after a tick, so finishing up the click
// won't just immediately remove focus from the Editable.
setTimeout(onEdit, 0);
}}
>
Rename
</MenuItem>
{canDeleteOutfit && (
<DeleteOutfitMenuItem outfitState={outfitState} />
)}
</MenuList>
</Portal>
</Menu>
</Flex>
)}
</Editable>
);
}
function DeleteOutfitMenuItem({ outfitState }) {
const { id, name } = outfitState;
const { isOpen, onOpen, onClose } = useDisclosure();
const { push: pushHistory } = useRouter();
const [sendDeleteOutfitMutation, { loading, error }] = useMutation(
gql`
mutation DeleteOutfitMenuItem($id: ID!) {
deleteOutfit(id: $id)
}
`,
{
context: { sendAuth: true },
update(cache) {
// Once this is deleted, evict it from the local cache, and "garbage
// collect" to force all queries referencing this outfit to reload the
// next time we see them. (This is especially important since we're
// about to redirect to the user outfits page, which shouldn't show
// the outfit anymore!)
cache.evict(`Outfit:${id}`);
cache.gc();
},
}
);
return (
<>
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
Delete
</MenuItem>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
<ModalCloseButton />
<ModalBody>
We'll delete this data and remove it from your list of outfits.
Links and image embeds pointing to this outfit will break. Is that
okay?
{error && (
<ErrorMessage marginTop="1em">
Error deleting outfit: "{getGraphQLErrorMessage(error)}". Try
again?
</ErrorMessage>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>No, keep this outfit</Button>
<Box flex="1 0 auto" width="2" />
<Button
colorScheme="red"
onClick={() =>
sendDeleteOutfitMutation({ variables: { id } })
.then(() => {
pushHistory(`/your-outfits`);
})
.catch((e) => {
/* handled in error UI */
})
}
isLoading={loading}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
/**
* fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the
* fade-out and height decrease when an Item or ItemZoneGroup is removed.
*
* Note that this _cannot_ be implemented as a wrapper component that returns a
* CSSTransition. This is because the CSSTransition must be the direct child of
* the TransitionGroup, and a wrapper breaks the parent-child relationship.
*
* See react-transition-group docs for more info!
*/
const fadeOutAndRollUpTransition = (css) => ({
classNames: css`
&-exit {
opacity: 1;
height: auto;
}
&-exit-active {
opacity: 0;
height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
transition: all 0.5s;
}
`,
timeout: 500,
onExit: (e) => {
e.style.height = e.offsetHeight + "px";
},
});
export default ItemsPanel;

View file

@ -0,0 +1,683 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
Box,
Button,
DarkMode,
Flex,
FormControl,
FormHelperText,
FormLabel,
HStack,
IconButton,
ListItem,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Portal,
Stack,
Switch,
Tooltip,
UnorderedList,
useClipboard,
useToast,
} from "@chakra-ui/react";
import {
ArrowBackIcon,
CheckIcon,
ChevronDownIcon,
DownloadIcon,
LinkIcon,
SettingsIcon,
} from "@chakra-ui/icons";
import { MdPause, MdPlayArrow } from "react-icons/md";
import Link from "next/link";
import { getBestImageUrlForLayer } from "../components/OutfitPreview";
import HTML5Badge, { layerUsesHTML5 } from "../components/HTML5Badge";
import PosePicker from "./PosePicker";
import SpeciesColorPicker from "../components/SpeciesColorPicker";
import { loadImage, useLocalStorage } from "../util";
import useCurrentUser from "../components/useCurrentUser";
import useOutfitAppearance from "../components/useOutfitAppearance";
import OutfitKnownGlitchesBadge from "./OutfitKnownGlitchesBadge";
import usePreferArchive from "../components/usePreferArchive";
/**
* OutfitControls is the set of controls layered over the outfit preview, to
* control things like species/color and sharing links!
*/
function OutfitControls({
outfitState,
dispatchToOutfit,
showAnimationControls,
appearance,
}) {
const [focusIsLocked, setFocusIsLocked] = React.useState(false);
const onLockFocus = React.useCallback(
() => setFocusIsLocked(true),
[setFocusIsLocked]
);
const onUnlockFocus = React.useCallback(
() => setFocusIsLocked(false),
[setFocusIsLocked]
);
// HACK: As of 1.0.0-rc.0, Chakra's `toast` function rebuilds unnecessarily,
// which triggers unnecessary rebuilds of the `onSpeciesColorChange`
// callback, which causes the `React.memo` on `SpeciesColorPicker` to
// fail, which harms performance. But it seems to work just fine if we
// hold onto the first copy of the function we get! :/
const _toast = useToast();
// eslint-disable-next-line react-hooks/exhaustive-deps
const toast = React.useMemo(() => _toast, []);
const onSpeciesColorChange = React.useCallback(
(species, color, isValid, closestPose) => {
if (isValid) {
dispatchToOutfit({
type: "setSpeciesAndColor",
speciesId: species.id,
colorId: color.id,
pose: closestPose,
});
} else {
// NOTE: This shouldn't be possible to trigger, because the
// `stateMustAlwaysBeValid` prop should prevent it. But we have
// it as a fallback, just in case!
toast({
title: `We haven't seen a ${color.name} ${species.name} before! 😓`,
status: "warning",
});
}
},
[dispatchToOutfit, toast]
);
const maybeUnlockFocus = (e) => {
// We lock focus when a touch-device user taps the area. When they tap
// empty space, we treat that as a toggle and release the focus lock.
if (e.target === e.currentTarget) {
onUnlockFocus();
}
};
return (
<ClassNames>
{({ css, cx }) => (
<Box
role="group"
pos="absolute"
left="0"
right="0"
top="0"
bottom="0"
height="100%" // Required for Safari to size the grid correctly
padding={{ base: 2, lg: 6 }}
display="grid"
overflow="auto"
gridTemplateAreas={`"back play-pause sharing"
"space space space"
"picker picker picker"`}
gridTemplateRows="auto minmax(1rem, 1fr) auto"
className={cx(
css`
opacity: 0;
transition: opacity 0.2s;
&:focus-within,
&.focus-is-locked {
opacity: 1;
}
/* Ignore simulated hovers, only reveal for _real_ hovers. This helps
* us avoid state conflicts with the focus-lock from clicks. */
@media (hover: hover) {
&:hover {
opacity: 1;
}
}
`,
focusIsLocked && "focus-is-locked"
)}
onClickCapture={(e) => {
const opacity = parseFloat(
getComputedStyle(e.currentTarget).opacity
);
if (opacity < 0.5) {
// If the controls aren't visible right now, then clicks on them are
// probably accidental. Ignore them! (We prevent default to block
// built-in behaviors like link nav, and we stop propagation to block
// our own custom click handlers. I don't know if I can prevent the
// select clicks though?)
e.preventDefault();
e.stopPropagation();
// We also show the controls, by locking focus. We'll undo this when
// the user taps elsewhere (because it will trigger a blur event from
// our child components), in `maybeUnlockFocus`.
setFocusIsLocked(true);
}
}}
onContextMenuCapture={() => {
if (!toast.isActive("outfit-controls-context-menu-hint")) {
toast({
id: "outfit-controls-context-menu-hint",
title:
"By the way, to save this image, use the Download button!",
description: "It's in the top right of the preview area.",
duration: 10000,
isClosable: true,
});
}
}}
data-test-id="wardrobe-outfit-controls"
>
<Box gridArea="back" onClick={maybeUnlockFocus}>
<BackButton outfitState={outfitState} />
</Box>
<Flex
gridArea="play-pause"
// HACK: Better visual centering with other controls
paddingTop="0.3rem"
direction="column"
align="center"
>
{showAnimationControls && <PlayPauseButton />}
<Box height="2" />
<HStack spacing="2" align="center" justify="center">
<OutfitHTML5Badge appearance={appearance} />
<OutfitKnownGlitchesBadge appearance={appearance} />
<SettingsButton
onLockFocus={onLockFocus}
onUnlockFocus={onUnlockFocus}
/>
</HStack>
</Flex>
<Stack
gridArea="sharing"
alignSelf="flex-end"
spacing={{ base: "2", lg: "4" }}
align="flex-end"
onClick={maybeUnlockFocus}
>
<Box>
<DownloadButton outfitState={outfitState} />
</Box>
<Box>
<CopyLinkButton outfitState={outfitState} />
</Box>
</Stack>
<Box gridArea="space" onClick={maybeUnlockFocus} />
{outfitState.speciesId && outfitState.colorId && (
<Flex gridArea="picker" justify="center" onClick={maybeUnlockFocus}>
{/**
* We try to center the species/color picker, but the left spacer will
* shrink more than the pose picker container if we run out of space!
*/}
<Flex
flex="1 1 0"
paddingRight="3"
align="center"
justify="flex-end"
/>
<Box flex="0 0 auto">
<DarkMode>
<SpeciesColorPicker
speciesId={outfitState.speciesId}
colorId={outfitState.colorId}
idealPose={outfitState.pose}
onChange={onSpeciesColorChange}
stateMustAlwaysBeValid
speciesTestId="wardrobe-species-picker"
colorTestId="wardrobe-color-picker"
/>
</DarkMode>
</Box>
<Flex flex="1 1 0" align="center" pl="2">
<PosePicker
speciesId={outfitState.speciesId}
colorId={outfitState.colorId}
pose={outfitState.pose}
appearanceId={outfitState.appearanceId}
dispatchToOutfit={dispatchToOutfit}
onLockFocus={onLockFocus}
onUnlockFocus={onUnlockFocus}
data-test-id="wardrobe-pose-picker"
/>
</Flex>
</Flex>
)}
</Box>
)}
</ClassNames>
);
}
function OutfitHTML5Badge({ appearance }) {
const petIsUsingHTML5 =
appearance.petAppearance?.layers.every(layerUsesHTML5);
const itemsNotUsingHTML5 = appearance.items.filter((item) =>
item.appearance.layers.some((l) => !layerUsesHTML5(l))
);
itemsNotUsingHTML5.sort((a, b) => a.name.localeCompare(b.name));
const usesHTML5 = petIsUsingHTML5 && itemsNotUsingHTML5.length === 0;
let tooltipLabel;
if (usesHTML5) {
tooltipLabel = (
<>This outfit is converted to HTML5, and ready to use on Neopets.com!</>
);
} else {
tooltipLabel = (
<Box>
<Box as="p">
This outfit isn't converted to HTML5 yet, so it might not appear in
Neopets.com customization yet. Once it's ready, it could look a bit
different than our temporary preview here. It might even be animated!
</Box>
{!petIsUsingHTML5 && (
<Box as="p" marginTop="1em" fontWeight="bold">
This pet is not yet converted.
</Box>
)}
{itemsNotUsingHTML5.length > 0 && (
<>
<Box as="header" marginTop="1em" fontWeight="bold">
The following items aren't yet converted:
</Box>
<UnorderedList>
{itemsNotUsingHTML5.map((item) => (
<ListItem key={item.id}>{item.name}</ListItem>
))}
</UnorderedList>
</>
)}
</Box>
);
}
return (
<HTML5Badge
usesHTML5={usesHTML5}
isLoading={appearance.loading}
tooltipLabel={tooltipLabel}
/>
);
}
/**
* BackButton takes you back home, or to Your Outfits if this outfit is yours.
*/
function BackButton({ outfitState }) {
const currentUser = useCurrentUser();
const outfitBelongsToCurrentUser =
outfitState.creator && outfitState.creator.id === currentUser.id;
return (
<Link href={outfitBelongsToCurrentUser ? "/your-outfits" : "/"} passHref>
<ControlButton
as="a"
icon={<ArrowBackIcon />}
aria-label="Leave this outfit"
d="inline-flex" // Not sure why <a> requires this to style right! ^^`
data-test-id="wardrobe-nav-back-button"
/>
</Link>
);
}
/**
* DownloadButton downloads the outfit as an image!
*/
function DownloadButton({ outfitState }) {
const { visibleLayers } = useOutfitAppearance(outfitState);
const [downloadImageUrl, prepareDownload] =
useDownloadableImage(visibleLayers);
return (
<Tooltip label="Download" placement="left">
<Box>
<ControlButton
icon={<DownloadIcon />}
aria-label="Download"
as="a"
// eslint-disable-next-line no-script-url
href={downloadImageUrl || "#"}
onClick={(e) => {
if (!downloadImageUrl) {
e.preventDefault();
}
}}
download={(outfitState.name || "Outfit") + ".png"}
onMouseEnter={prepareDownload}
onFocus={prepareDownload}
cursor={!downloadImageUrl && "wait"}
/>
</Box>
</Tooltip>
);
}
/**
* CopyLinkButton copies the outfit URL to the clipboard!
*/
function CopyLinkButton({ outfitState }) {
const { onCopy, hasCopied } = useClipboard(outfitState.url);
return (
<Tooltip label={hasCopied ? "Copied!" : "Copy link"} placement="left">
<Box>
<ControlButton
icon={hasCopied ? <CheckIcon /> : <LinkIcon />}
aria-label="Copy link"
onClick={onCopy}
/>
</Box>
</Tooltip>
);
}
function PlayPauseButton() {
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
// We show an intro animation if this mounts while paused. Whereas if we're
// not paused, we initialize as if we had already finished.
const [blinkInState, setBlinkInState] = React.useState(
isPaused ? { type: "ready" } : { type: "done" }
);
const buttonRef = React.useRef(null);
React.useLayoutEffect(() => {
if (blinkInState.type === "ready" && buttonRef.current) {
setBlinkInState({
type: "started",
position: {
left: buttonRef.current.offsetLeft,
top: buttonRef.current.offsetTop,
},
});
}
}, [blinkInState, setBlinkInState]);
return (
<ClassNames>
{({ css }) => (
<>
<PlayPauseButtonContent
isPaused={isPaused}
setIsPaused={setIsPaused}
ref={buttonRef}
/>
{blinkInState.type === "started" && (
<Portal>
<PlayPauseButtonContent
isPaused={isPaused}
setIsPaused={setIsPaused}
position="absolute"
left={blinkInState.position.left}
top={blinkInState.position.top}
backgroundColor="gray.600"
borderColor="gray.50"
color="gray.50"
onAnimationEnd={() => setBlinkInState({ type: "done" })}
// Don't disrupt the hover state of the controls! (And the button
// doesn't seem to click correctly, not sure why, but instead of
// debugging I'm adding this :p)
pointerEvents="none"
className={css`
@keyframes fade-in-out {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
opacity: 0;
}
}
opacity: 0;
animation: fade-in-out 2s;
`}
/>
</Portal>
)}
</>
)}
</ClassNames>
);
}
const PlayPauseButtonContent = React.forwardRef(
({ isPaused, setIsPaused, ...props }, ref) => {
return (
<TranslucentButton
ref={ref}
leftIcon={isPaused ? <MdPause /> : <MdPlayArrow />}
onClick={() => setIsPaused(!isPaused)}
{...props}
>
{isPaused ? <>Paused</> : <>Playing</>}
</TranslucentButton>
);
}
);
function SettingsButton({ onLockFocus, onUnlockFocus }) {
return (
<Popover onOpen={onLockFocus} onClose={onUnlockFocus}>
<PopoverTrigger>
<TranslucentButton size="xs" aria-label="Settings">
<SettingsIcon />
<Box width="1" />
<ChevronDownIcon />
</TranslucentButton>
</PopoverTrigger>
<Portal>
<PopoverContent width="25ch">
<PopoverArrow />
<PopoverBody>
<HiResModeSetting />
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
}
function HiResModeSetting() {
const [hiResMode, setHiResMode] = useLocalStorage("DTIHiResMode", false);
const [preferArchive, setPreferArchive] = usePreferArchive();
return (
<Box>
<FormControl>
<Flex>
<Box>
<FormLabel htmlFor="hi-res-mode-setting" fontSize="sm" margin="0">
Hi-res mode (SVG)
</FormLabel>
<FormHelperText marginTop="0" fontSize="xs">
Crisper at higher resolutions, but not always accurate
</FormHelperText>
</Box>
<Box width="2" />
<Switch
id="hi-res-mode-setting"
size="sm"
marginTop="0.1rem"
isChecked={hiResMode}
onChange={(e) => setHiResMode(e.target.checked)}
/>
</Flex>
</FormControl>
<Box height="2" />
<FormControl>
<Flex>
<Box>
<FormLabel
htmlFor="prefer-archive-setting"
fontSize="sm"
margin="0"
>
Use DTI's image archive
</FormLabel>
<FormHelperText marginTop="0" fontSize="xs">
Turn this on when images.neopets.com is slow!
</FormHelperText>
</Box>
<Box width="2" />
<Switch
id="prefer-archive-setting"
size="sm"
marginTop="0.1rem"
isChecked={preferArchive ?? false}
onChange={(e) => setPreferArchive(e.target.checked)}
/>
</Flex>
</FormControl>
</Box>
);
}
const TranslucentButton = React.forwardRef(({ children, ...props }, ref) => {
return (
<Button
ref={ref}
size="sm"
color="gray.100"
variant="outline"
borderColor="gray.200"
borderRadius="full"
backgroundColor="blackAlpha.600"
boxShadow="md"
_hover={{
backgroundColor: "gray.600",
borderColor: "gray.50",
color: "gray.50",
}}
_focus={{
backgroundColor: "gray.600",
borderColor: "gray.50",
color: "gray.50",
}}
{...props}
>
{children}
</Button>
);
});
/**
* ControlButton is a UI helper to render the cute round buttons we use in
* OutfitControls!
*/
function ControlButton({ icon, "aria-label": ariaLabel, ...props }) {
return (
<IconButton
icon={icon}
aria-label={ariaLabel}
isRound
variant="unstyled"
backgroundColor="gray.600"
color="gray.50"
boxShadow="md"
d="flex"
alignItems="center"
justifyContent="center"
transition="backgroundColor 0.2s"
_focus={{ backgroundColor: "gray.500" }}
_hover={{ backgroundColor: "gray.500" }}
outline="initial"
{...props}
/>
);
}
/**
* useDownloadableImage loads the image data and generates the downloadable
* image URL.
*/
function useDownloadableImage(visibleLayers) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const [preferArchive] = usePreferArchive();
const [downloadImageUrl, setDownloadImageUrl] = React.useState(null);
const [preparedForLayerIds, setPreparedForLayerIds] = React.useState([]);
const toast = useToast();
const prepareDownload = React.useCallback(async () => {
// Skip if the current image URL is already correct for these layers.
const layerIds = visibleLayers.map((l) => l.id);
if (layerIds.join(",") === preparedForLayerIds.join(",")) {
return;
}
// Skip if there are no layers. (This probably means we're still loading!)
if (layerIds.length === 0) {
return;
}
setDownloadImageUrl(null);
// NOTE: You could argue that we may as well just always use PNGs here,
// regardless of hi-res mode… but using the same src will help both
// performance (can use cached SVG), and predictability (image will
// look like what you see here).
const imagePromises = visibleLayers.map((layer) =>
loadImage(getBestImageUrlForLayer(layer, { hiResMode }), {
crossOrigin: "anonymous",
preferArchive,
})
);
let images;
try {
images = await Promise.all(imagePromises);
} catch (e) {
console.error("Error building downloadable image", e);
toast({
status: "error",
title: "Oops, sorry, we couldn't download the image!",
description:
"Check your connection, then reload the page and try again.",
});
return;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = 600;
canvas.height = 600;
for (const image of images) {
context.drawImage(image, 0, 0);
}
console.debug(
"Generated image for download",
layerIds,
canvas.toDataURL("image/png")
);
setDownloadImageUrl(canvas.toDataURL("image/png"));
setPreparedForLayerIds(layerIds);
}, [preparedForLayerIds, visibleLayers, toast, hiResMode, preferArchive]);
return [downloadImageUrl, prepareDownload];
}
export default OutfitControls;

View file

@ -0,0 +1,299 @@
import React from "react";
import { Box, VStack } from "@chakra-ui/react";
import { WarningTwoIcon } from "@chakra-ui/icons";
import { FaBug } from "react-icons/fa";
import { GlitchBadgeLayout, layerUsesHTML5 } from "../components/HTML5Badge";
import getVisibleLayers from "../components/getVisibleLayers";
import { useLocalStorage } from "../util";
function OutfitKnownGlitchesBadge({ appearance }) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const { petAppearance, items } = appearance;
const glitchMessages = [];
// Look for UC/Invisible/etc incompatibilities that we hid, that we should
// just mark Incompatible someday instead; or with correctly partially-hidden
// art.
//
// NOTE: This particular glitch is checking for the *absence* of layers, so
// we skip it if we're still loading!
if (!appearance.loading) {
for (const item of items) {
// HACK: We use `getVisibleLayers` with just this pet appearance and item
// appearance, to run the logic for which layers are compatible with
// this pet. But `getVisibleLayers` does other things too, so it's
// plausible that this could do not quite what we want in some cases!
const allItemLayers = item.appearance.layers;
const compatibleItemLayers = getVisibleLayers(petAppearance, [
item.appearance,
]).filter((l) => l.source === "item");
if (compatibleItemLayers.length === 0) {
glitchMessages.push(
<Box key={`total-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i> isn't actually compatible with this special pet.
We're hiding the item art, which is outdated behavior, and we should
instead be treating it as entirely incompatible. Fixing this is in
our todo list, sorry for the confusing UI!
</Box>
);
} else if (compatibleItemLayers.length < allItemLayers.length) {
glitchMessages.push(
<Box key={`partial-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i>'s compatibility with this pet is complicated, but
we believe this is how it looks: some zones are visible, and some
zones are hidden. If this isn't quite right, please email me at
matchu@openneo.net and let me know!
</Box>
);
}
}
}
// Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
for (const item of items) {
const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT")
);
const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
(l) =>
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
!layerUsesHTML5(l)
);
if (itemHasBrokenOnNeopetsDotCom) {
glitchMessages.push(
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
{itemHasBrokenUnconvertedLayers ? (
<>
We're aware of a glitch affecting the art for <i>{item.name}</i>.
Last time we checked, this glitch affected its appearance on
Neopets.com, too. Hopefully this will be fixed once it's converted
to HTML5!
</>
) : (
<>
We're aware of a previous glitch affecting the art for{" "}
<i>{item.name}</i>, but it might have been resolved during HTML5
conversion. Please use the feedback form on the homepage to let us
know if it looks right, or still looks wrong! Thank you!
</>
)}
</Box>
);
}
}
// Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
for (const item of items) {
const itemHasGlitch = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT")
);
if (itemHasGlitch) {
glitchMessages.push(
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i>, and we believe it
looks this way on-site, too. But our version might be out of date! If
you've seen it look better on-site, please email me at
matchu@openneo.net so we can fix it!
</Box>
);
}
}
// Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this
// if hi-res mode is on, because otherwise it doesn't affect the user anyway!
if (hiResMode) {
for (const item of items) {
const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT")
);
if (itemHasOfficialSvgIsIncorrect) {
glitchMessages.push(
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> that prevents us
from showing the SVG image for Hi-Res Mode. Instead, we're showing a
PNG, which might look a bit blurry on larger screens.
</Box>
);
}
}
}
// Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
for (const item of items) {
const itemHasGlitch = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN")
);
if (itemHasGlitch) {
glitchMessages.push(
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> that causes it to
display incorrectlybut we're not sure if it's on our end, or TNT's.
If you own this item, please email me at matchu@openneo.net to let us
know how it looks in the on-site customizer!
</Box>
);
}
}
// Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
for (const item of items) {
const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT")
);
if (itemHasOfficialBodyIdIsIncorrect) {
glitchMessages.push(
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
Last we checked, <i>{item.name}</i> actually is compatible with this
pet, even though it seems like it shouldn't be. But TNT might change
this at any time, so be careful!
</Box>
);
}
}
// Look for Dyeworks items that aren't converted yet.
for (const item of items) {
const itemIsDyeworks = item.name.includes("Dyeworks");
const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
if (itemIsDyeworks && !itemIsConverted) {
glitchMessages.push(
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
<i>{item.name}</i> isn't converted to HTML5 yet, and our Classic DTI
code often shows old Dyeworks items in the wrong color. Once it's
converted, we'll display it correctly!
</Box>
);
}
}
// Check whether the pet is Invisible. If so, we'll show a blanket warning.
if (petAppearance?.color?.id === "38") {
glitchMessages.push(
<Box key={`invisible-pet-warning`}>
Invisible pets are affected by a number of glitches, including faces
sometimes being visible on-site, and errors in the HTML5 conversion. If
this pose looks incorrect, you can try another by clicking the emoji
face next to the species/color picker. But be aware that Neopets.com
might look different!
</Box>
);
}
// Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
if (
petAppearance?.color?.id === "26" &&
petAppearance?.species?.id === "49"
) {
glitchMessages.push(
<Box key={`faerie-uni-dithering-horn-warning`}>
The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and
sometimes yellow. To help you design for both cases, we show the blue
horn with the feminine design, and the yellow horn with the masculine
designbut the pet's gender does not actually affect which horn you'll
get, and it will often change over time!
</Box>
);
}
// Check whether the pet appearance is marked as Glitched.
if (petAppearance?.isGlitched) {
glitchMessages.push(
// NOTE: This message assumes that the current pet appearance is the
// best canonical one, but it's _possible_ to view Glitched
// appearances even if we _do_ have a better one saved... but
// only the Support UI ever takes you there.
<Box key={`pet-appearance-is-glitched`}>
We know that the art for this pet is incorrect, but we still haven't
seen a <em>correct</em> model for this pose yet. Once someone models the
correct data, we'll use that instead. For now, you could also try
switching to another pose, by clicking the emoji face next to the
species/color picker!
</Box>
);
}
const petLayers = petAppearance?.layers || [];
// Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
for (const layer of petLayers) {
const layerHasGlitch = (layer.knownGlitches || []).includes(
"OFFICIAL_SWF_IS_INCORRECT"
);
if (layerHasGlitch) {
glitchMessages.push(
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
We're aware of a glitch affecting the art for this pet's{" "}
<i>{layer.zone.label}</i> zone. Last time we checked, this glitch
affected its appearance on Neopets.com, too. But our version might be
out of date! If you've seen it look better on-site, please email me at
matchu@openneo.net so we can fix it!
</Box>
);
}
}
// Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
if (hiResMode) {
for (const layer of petLayers) {
const layerHasOfficialSvgIsIncorrect = (
layer.knownGlitches || []
).includes("OFFICIAL_SVG_IS_INCORRECT");
if (layerHasOfficialSvgIsIncorrect) {
glitchMessages.push(
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
zone that prevents us from showing the SVG image for Hi-Res Mode.
Instead, we're showing a PNG, which might look a bit blurry on
larger screens.
</Box>
);
}
}
}
// Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
for (const layer of petLayers) {
const layerHasGlitch = (layer.knownGlitches || []).includes(
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN"
);
if (layerHasGlitch) {
glitchMessages.push(
<Box
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`}
>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
zone that causes it to display incorrectlybut we're not sure if it's
on our end, or TNT's. If you have this pet, please email me at
matchu@openneo.net to let us know how it looks in the on-site
customizer!
</Box>
);
}
}
if (glitchMessages.length === 0) {
return null;
}
return (
<GlitchBadgeLayout
aria-label="Has known glitches"
tooltipLabel={
<Box>
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
Known glitches
</Box>
<VStack spacing="1em">{glitchMessages}</VStack>
</Box>
}
>
<WarningTwoIcon fontSize="xs" marginRight="1" />
<FaBug />
</GlitchBadgeLayout>
);
}
export default OutfitKnownGlitchesBadge;

View file

@ -0,0 +1,743 @@
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,
VisuallyHidden,
useColorModeValue,
useTheme,
useToast,
} from "@chakra-ui/react";
import { loadable } from "../util";
import Image from "next/image";
import { petAppearanceFragment } from "../components/useOutfitAppearance";
import getVisibleLayers from "../components/getVisibleLayers";
import { OutfitLayers } from "../components/OutfitPreview";
import SupportOnly from "./support/SupportOnly";
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";
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,
appearanceId,
dispatchToOutfit,
onLockFocus,
onUnlockFocus,
...props
}) {
const theme = useTheme();
const initialFocusRef = React.useRef();
const { loading, error, poseInfos } = usePoses(speciesId, colorId, pose);
const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
"DTIPosePickerIsInSupportMode",
false
);
const { isSupportUser } = useSupport();
const toast = useToast();
// 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,
]);
if (loading) {
return null;
}
// 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;
}
// If there's only one pose anyway, don't bother showing a picker!
// (Unless we're Support, in which case we want the ability to pop it open to
// inspect and label the Unknown poses!)
const numAvailablePoses = Object.values(poseInfos).filter(
(p) => p.isAvailable
).length;
if (numAvailablePoses <= 1 && !isSupportUser) {
return null;
}
const onChange = (e) => {
dispatchToOutfit({ type: "setPose", pose: e.target.value });
};
return (
<Popover
placement="bottom-end"
returnFocusOnClose
onOpen={onLockFocus}
onClose={onUnlockFocus}
initialFocusRef={initialFocusRef}
isLazy
lazyBehavior="keepMounted"
>
{({ isOpen }) => (
<ClassNames>
{({ css, cx }) => (
<>
<PopoverTrigger>
<Button
variant="unstyled"
boxShadow="md"
d="flex"
alignItems="center"
justifyContent="center"
_focus={{ borderColor: "gray.50" }}
_hover={{ borderColor: "gray.50" }}
outline="initial"
className={cx(
css`
border: 1px solid transparent !important;
transition: border-color 0.2s !important;
&:focus,
&:hover,
&.is-open {
border-color: ${theme.colors.gray["50"]} !important;
}
&.is-open {
border-width: 2px !important;
}
`,
isOpen && "is-open"
)}
{...props}
>
<EmojiImage src={getIcon(pose)} alt="Choose a pose" />
</Button>
</PopoverTrigger>
<Portal>
<PopoverContent>
<Box p="4" position="relative">
{isInSupportMode ? (
<PosePickerSupport
speciesId={speciesId}
colorId={colorId}
pose={pose}
appearanceId={appearanceId}
initialFocusRef={initialFocusRef}
dispatchToOutfit={dispatchToOutfit}
/>
) : (
<>
<PosePickerTable
poseInfos={poseInfos}
onChange={onChange}
initialFocusRef={initialFocusRef}
/>
{numAvailablePoses <= 1 && (
<SupportOnly>
<Box
fontSize="xs"
fontStyle="italic"
textAlign="center"
opacity="0.7"
marginTop="2"
>
The empty picker is hidden for most users!
<br />
You can see it because you're a Support user.
</Box>
</SupportOnly>
)}
</>
)}
<SupportOnly>
<Box position="absolute" top="5" left="3">
<PosePickerSupportSwitch
isChecked={isInSupportMode}
onChange={(e) => setIsInSupportMode(e.target.checked)}
/>
</Box>
</SupportOnly>
</Box>
<PopoverArrow />
</PopoverContent>
</Portal>
</>
)}
</ClassNames>
)}
</Popover>
);
}
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 && (
<PoseOption
poseInfo={poseInfos.unconverted}
onChange={onChange}
inputRef={poseInfos.unconverted.isSelected && initialFocusRef}
size="sm"
label="Unconverted"
marginTop="2"
/>
)}
</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",
};
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 EmojiImage({ src, alt, boxSize = 16 }) {
return (
<Image
src={src}
alt={alt}
width={boxSize}
height={boxSize}
layout="fixed"
/>
);
}
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 twemojiSunglasses;
} else {
return twemojiQuestion;
}
}
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);

View file

@ -0,0 +1,80 @@
import React from "react";
import * as Sentry from "@sentry/react";
import { Box, Flex } from "@chakra-ui/react";
import SearchToolbar from "./SearchToolbar";
import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
import PaginationToolbar from "../components/PaginationToolbar";
import { useSearchResults } from "./useSearchResults";
/**
* SearchFooter appears on large screens only, to let you search for new items
* while still keeping the rest of the item screen open!
*/
function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage(
"DTIFeatureFlagCanUseSearchFooter",
false
);
const { items, numTotalPages } = useSearchResults(
searchQuery,
outfitState,
1
);
React.useEffect(() => {
if (window.location.search.includes("feature-flag-can-use-search-footer")) {
setCanUseSearchFooter(true);
}
}, [setCanUseSearchFooter]);
// TODO: Show the new footer to other users, too!
if (!canUseSearchFooter) {
return null;
}
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Box>
<Box paddingX="4" paddingY="4">
<Flex as="label" align="center">
<Box fontWeight="600" flex="0 0 auto">
Add new items:
</Box>
<Box width="8" />
<SearchToolbar
query={searchQuery}
onChange={onChangeSearchQuery}
flex="0 1 100%"
suggestionsPlacement="top"
/>
<Box width="8" />
{numTotalPages != null && (
<Box flex="0 0 auto">
<PaginationToolbar
numTotalPages={numTotalPages}
currentPageNumber={1}
goToPageNumber={() => alert("TODO")}
buildPageUrl={() => null}
size="sm"
/>
</Box>
)}
</Flex>
</Box>
<Box maxHeight="32" overflow="auto">
<Box as="ul" listStyleType="disc" paddingLeft="8">
{items.map((item) => (
<Box key={item.id} as="li">
{item.name}
</Box>
))}
</Box>
</Box>
</Box>
</Sentry.ErrorBoundary>
);
}
export default SearchFooter;

View file

@ -0,0 +1,284 @@
import React from "react";
import { Box, Text, useColorModeValue, VisuallyHidden } from "@chakra-ui/react";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import PaginationToolbar from "../components/PaginationToolbar";
import { useSearchResults } from "./useSearchResults";
export const SEARCH_PER_PAGE = 30;
/**
* SearchPanel shows item search results to the user, so they can preview them
* and add them to their outfit!
*
* It's tightly coordinated with SearchToolbar, using refs to control special
* keyboard and focus interactions.
*/
function SearchPanel({
query,
outfitState,
dispatchToOutfit,
scrollContainerRef,
searchQueryRef,
firstSearchResultRef,
}) {
const scrollToTop = React.useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}, [scrollContainerRef]);
// Sometimes we want to give focus back to the search field!
const onMoveFocusUpToQuery = (e) => {
if (searchQueryRef.current) {
searchQueryRef.current.focus();
e.preventDefault();
}
};
return (
<Box
onKeyDown={(e) => {
// This will catch any Escape presses when the user's focus is inside
// the SearchPanel.
if (e.key === "Escape") {
onMoveFocusUpToQuery(e);
}
}}
>
<SearchResults
// When the query changes, replace the SearchResults component with a
// new instance. This resets both `currentPageNumber`, to take us back
// to page 1; and also `itemIdsToReconsider`. That way, if you find an
// item you like in one search, then immediately do a second search and
// try a conflicting item, we'll restore the item you liked from your
// first search!
key={serializeQuery(query)}
query={query}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
firstSearchResultRef={firstSearchResultRef}
scrollToTop={scrollToTop}
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/>
</Box>
);
}
/**
* SearchResults loads the search results from the user's query, renders them,
* and tracks the scroll container for infinite scrolling.
*
* For each item, we render a <label> with a visually-hidden checkbox and the
* Item component (which will visually reflect the radio's state). This makes
* the list screen-reader- and keyboard-accessible!
*/
function SearchResults({
query,
outfitState,
dispatchToOutfit,
firstSearchResultRef,
scrollToTop,
onMoveFocusUpToQuery,
}) {
const [currentPageNumber, setCurrentPageNumber] = React.useState(1);
const { loading, error, items, numTotalPages } = useSearchResults(
query,
outfitState,
currentPageNumber
);
// Preload the previous and next page of search results, with this quick
// ~hacky trick: just `useSearchResults` two more times, with some extra
// attention to skip the query when we don't know if it will exist!
useSearchResults(query, outfitState, currentPageNumber - 1, {
skip: currentPageNumber <= 1,
});
useSearchResults(query, outfitState, currentPageNumber + 1, {
skip: numTotalPages == null || currentPageNumber >= numTotalPages,
});
// This will save the `wornItemIds` when the SearchResults first mounts, and
// keep it saved even after the outfit changes. We use this to try to restore
// these items after the user makes changes, e.g., after they try on another
// Background we want to restore the previous one!
const [itemIdsToReconsider] = React.useState(outfitState.wornItemIds);
// Whenever the page number changes, scroll back to the top!
React.useEffect(() => scrollToTop(), [currentPageNumber, scrollToTop]);
// You can use UpArrow/DownArrow to navigate between items, and even back up
// to the search field!
const goToPrevItem = React.useCallback(
(e) => {
const prevLabel = e.target.closest("label").previousSibling;
if (prevLabel) {
prevLabel.querySelector("input[type=checkbox]").focus();
prevLabel.scrollIntoView({ block: "center" });
e.preventDefault();
} else {
// If we're at the top of the list, move back up to the search box!
onMoveFocusUpToQuery(e);
}
},
[onMoveFocusUpToQuery]
);
const goToNextItem = React.useCallback((e) => {
const nextLabel = e.target.closest("label").nextSibling;
if (nextLabel) {
nextLabel.querySelector("input[type=checkbox]").focus();
nextLabel.scrollIntoView({ block: "center" });
e.preventDefault();
}
}, []);
const searchPanelBackground = useColorModeValue("white", "gray.900");
// If the results aren't ready, we have some special case UI!
if (error) {
return (
<Text>
We hit an error trying to load your search results{" "}
<span role="img" aria-label="(sweat emoji)">
😓
</span>{" "}
Try again?
</Text>
);
}
// Finally, render the item list, with checkboxes and Item components!
// We also render some extra skeleton items at the bottom during infinite
// scroll loading.
return (
<Box>
<Box
position="sticky"
top="0"
background={searchPanelBackground}
zIndex="2"
paddingX="5"
paddingBottom="2"
paddingTop="1"
>
<PaginationToolbar
numTotalPages={numTotalPages}
currentPageNumber={currentPageNumber}
goToPageNumber={setCurrentPageNumber}
buildPageUrl={() => null}
size="sm"
/>
</Box>
<ItemListContainer paddingX="4" paddingBottom="2">
{items.map((item, index) => (
<SearchResultItem
key={item.id}
item={item}
itemIdsToReconsider={itemIdsToReconsider}
isWorn={outfitState.wornItemIds.includes(item.id)}
isInOutfit={outfitState.allItemIds.includes(item.id)}
dispatchToOutfit={dispatchToOutfit}
checkboxRef={index === 0 ? firstSearchResultRef : null}
goToPrevItem={goToPrevItem}
goToNextItem={goToNextItem}
/>
))}
</ItemListContainer>
{loading && (
<ItemListSkeleton
count={SEARCH_PER_PAGE}
paddingX="4"
paddingBottom="2"
/>
)}
{!loading && items.length === 0 && (
<Text paddingX="4">
We couldn't find any matching items{" "}
<span role="img" aria-label="(thinking emoji)">
🤔
</span>{" "}
Try again?
</Text>
)}
</Box>
);
}
function SearchResultItem({
item,
itemIdsToReconsider,
isWorn,
isInOutfit,
dispatchToOutfit,
checkboxRef,
goToPrevItem,
goToNextItem,
}) {
// It's important to use `useCallback` for `onRemove`, to avoid re-rendering
// the whole list of <Item>s!
const onRemove = React.useCallback(
() =>
dispatchToOutfit({
type: "removeItem",
itemId: item.id,
itemIdsToReconsider,
}),
[item.id, itemIdsToReconsider, dispatchToOutfit]
);
return (
<label>
<VisuallyHidden
as="input"
type="checkbox"
aria-label={`Wear "${item.name}"`}
value={item.id}
checked={isWorn}
ref={checkboxRef}
onChange={(e) => {
const itemId = e.target.value;
const willBeWorn = e.target.checked;
if (willBeWorn) {
dispatchToOutfit({ type: "wearItem", itemId, itemIdsToReconsider });
} else {
dispatchToOutfit({
type: "unwearItem",
itemId,
itemIdsToReconsider,
});
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.target.click();
} else if (e.key === "ArrowUp") {
goToPrevItem(e);
} else if (e.key === "ArrowDown") {
goToNextItem(e);
}
}}
/>
<Item
item={item}
isWorn={isWorn}
isInOutfit={isInOutfit}
onRemove={onRemove}
/>
</label>
);
}
/**
* serializeQuery stably converts a search query object to a string, for easier
* JS comparison.
*/
function serializeQuery(query) {
return `${JSON.stringify([
query.value,
query.filterToItemKind,
query.filterToZoneLabel,
query.filterToCurrentUserOwnsOrWants,
])}`;
}
export default SearchPanel;

View file

@ -0,0 +1,479 @@
import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import {
Box,
IconButton,
Input,
InputGroup,
InputLeftAddon,
InputLeftElement,
InputRightElement,
Tooltip,
useColorModeValue,
} from "@chakra-ui/react";
import {
ChevronDownIcon,
ChevronUpIcon,
CloseIcon,
SearchIcon,
} from "@chakra-ui/icons";
import { ClassNames } from "@emotion/react";
import Autosuggest from "react-autosuggest";
import useCurrentUser from "../components/useCurrentUser";
import { logAndCapture } from "../util";
export const emptySearchQuery = {
value: "",
filterToZoneLabel: null,
filterToItemKind: null,
filterToCurrentUserOwnsOrWants: null,
};
export function searchQueryIsEmpty(query) {
return Object.values(query).every((value) => !value);
}
const SUGGESTIONS_PLACEMENT_PROPS = {
inline: {
borderBottomRadius: "md",
},
top: {
position: "absolute",
bottom: "100%",
borderTopRadius: "md",
},
};
/**
* SearchToolbar is rendered above both the ItemsPanel and the SearchPanel,
* and contains the search field where the user types their query.
*
* It has some subtle keyboard interaction support, like DownArrow to go to the
* first search result, and Escape to clear the search and go back to the
* ItemsPanel. (The SearchPanel can also send focus back to here, with Escape
* from anywhere, or UpArrow from the first result!)
*/
function SearchToolbar({
query,
searchQueryRef,
firstSearchResultRef = null,
onChange,
autoFocus,
showItemsLabel = false,
background = null,
boxShadow = null,
suggestionsPlacement = "inline",
...props
}) {
const [suggestions, setSuggestions] = React.useState([]);
const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
const { isLoggedIn } = useCurrentUser();
// NOTE: This query should always load ~instantly, from the client cache.
const { data } = useQuery(gql`
query SearchToolbarZones {
allZones {
id
label
depth
isCommonlyUsedByItems
}
}
`);
const zones = data?.allZones || [];
const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);
let zoneLabels = itemZones.map((z) => z.label);
zoneLabels = [...new Set(zoneLabels)];
zoneLabels.sort();
const onMoveFocusDownToResults = (e) => {
if (firstSearchResultRef && firstSearchResultRef.current) {
firstSearchResultRef.current.focus();
e.preventDefault();
}
};
const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");
const renderSuggestion = React.useCallback(
({ text }, { isHighlighted }) => (
<Box
fontWeight={isHighlighted ? "bold" : "normal"}
background={isHighlighted ? highlightedBgColor : suggestionBgColor}
padding="2"
paddingLeft="2.5rem"
fontSize="sm"
>
{text}
</Box>
),
[suggestionBgColor, highlightedBgColor]
);
const renderSuggestionsContainer = React.useCallback(
({ containerProps, children }) => {
const { className, ...otherContainerProps } = containerProps;
return (
<ClassNames>
{({ css, cx }) => (
<Box
{...otherContainerProps}
boxShadow="md"
overflow="auto"
transition="all 0.4s"
maxHeight="48"
width="100%"
className={cx(
className,
css`
li {
list-style: none;
}
`
)}
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
>
{children}
{!children && advancedSearchIsOpen && (
<Box
padding="4"
fontSize="sm"
fontStyle="italic"
textAlign="center"
>
No more filters available!
</Box>
)}
</Box>
)}
</ClassNames>
);
},
[advancedSearchIsOpen, suggestionsPlacement]
);
// When we change the query filters, clear out the suggestions.
React.useEffect(() => {
setSuggestions([]);
}, [
query.filterToItemKind,
query.filterToZoneLabel,
query.filterToCurrentUserOwnsOrWants,
]);
let queryFilterText = getQueryFilterText(query);
if (showItemsLabel) {
queryFilterText = queryFilterText ? (
<>
<Box as="span" fontWeight="600">
Items:
</Box>{" "}
{queryFilterText}
</>
) : (
<Box as="span" fontWeight="600">
Items
</Box>
);
}
const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
showAll: true,
});
// Once you remove the final suggestion available, close Advanced Search. We
// have placeholder text available, sure, but this feels more natural!
React.useEffect(() => {
if (allSuggestions.length === 0) {
setAdvancedSearchIsOpen(false);
}
}, [allSuggestions.length]);
const focusBorderColor = useColorModeValue("green.600", "green.400");
return (
<Box position="relative" {...props}>
<Autosuggest
suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
onSuggestionsFetchRequested={({ value }) => {
// HACK: I'm not sure why, but apparently this gets called with value
// set to the _chosen suggestion_ after choosing it? Has that
// always happened? Idk? Let's just, gate around it, I guess?
if (typeof value === "string") {
setSuggestions(
getSuggestions(value, query, zoneLabels, isLoggedIn)
);
}
}}
onSuggestionSelected={(e, { suggestion }) => {
onChange({
...query,
// If the suggestion was from typing, remove the last word of the
// query value. Or, if it was from Advanced Search, leave it alone!
value: advancedSearchIsOpen
? query.value
: removeLastWord(query.value),
filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
filterToItemKind: suggestion.itemKind || query.filterToItemKind,
filterToCurrentUserOwnsOrWants:
suggestion.userOwnsOrWants ||
query.filterToCurrentUserOwnsOrWants,
});
}}
getSuggestionValue={(zl) => zl}
alwaysRenderSuggestions={true}
renderSuggestion={renderSuggestion}
renderSuggestionsContainer={renderSuggestionsContainer}
renderInputComponent={(inputProps) => (
<InputGroup boxShadow={boxShadow} borderRadius="md">
{queryFilterText ? (
<InputLeftAddon>
<SearchIcon color="gray.400" marginRight="3" />
<Box fontSize="sm">{queryFilterText}</Box>
</InputLeftAddon>
) : (
<InputLeftElement>
<SearchIcon color="gray.400" />
</InputLeftElement>
)}
<Input
background={background}
autoFocus={autoFocus}
{...inputProps}
/>
<InputRightElement
width="auto"
justifyContent="flex-end"
paddingRight="2px"
paddingY="2px"
>
{!searchQueryIsEmpty(query) && (
<Tooltip label="Clear">
<IconButton
icon={<CloseIcon fontSize="0.6em" />}
color="gray.400"
variant="ghost"
height="100%"
marginLeft="1"
aria-label="Clear search"
onClick={() => {
setSuggestions([]);
onChange(emptySearchQuery);
}}
/>
</Tooltip>
)}
<Tooltip label="Advanced search">
<IconButton
icon={
advancedSearchIsOpen ? (
<ChevronUpIcon fontSize="1.5em" />
) : (
<ChevronDownIcon fontSize="1.5em" />
)
}
color="gray.400"
variant="ghost"
height="100%"
aria-label="Open advanced search"
onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
/>
</Tooltip>
</InputRightElement>
</InputGroup>
)}
inputProps={{
placeholder: "Search all items…",
focusBorderColor: focusBorderColor,
value: query.value || "",
ref: searchQueryRef,
minWidth: 0,
"data-test-id": "item-search-input",
onChange: (e, { newValue, method }) => {
// The Autosuggest tries to change the _entire_ value of the element
// when navigating suggestions, which isn't actually what we want.
// Only accept value changes that are typed by the user!
if (method === "type") {
onChange({ ...query, value: newValue });
}
},
onKeyDown: (e) => {
if (e.key === "Escape") {
if (suggestions.length > 0) {
setSuggestions([]);
return;
}
onChange(emptySearchQuery);
e.target.blur();
} else if (e.key === "Enter") {
// Pressing Enter doesn't actually submit because it's all on
// debounce, but it can be a declaration that the query is done, so
// filter suggestions should go away!
if (suggestions.length > 0) {
setSuggestions([]);
return;
}
} else if (e.key === "ArrowDown") {
if (suggestions.length > 0) {
return;
}
onMoveFocusDownToResults(e);
} else if (e.key === "Backspace" && e.target.selectionStart === 0) {
onChange({
...query,
filterToItemKind: null,
filterToZoneLabel: null,
filterToCurrentUserOwnsOrWants: null,
});
}
},
}}
/>
</Box>
);
}
function getSuggestions(
value,
query,
zoneLabels,
isLoggedIn,
{ showAll = false } = {}
) {
if (!value && !showAll) {
return [];
}
const words = (value || "").split(/\s+/);
const lastWord = words[words.length - 1];
if (lastWord.length < 2 && !showAll) {
return [];
}
const suggestions = [];
if (query.filterToItemKind == null) {
if (
wordMatches("NC", lastWord) ||
wordMatches("Neocash", lastWord) ||
showAll
) {
suggestions.push({ itemKind: "NC", text: "Neocash items" });
}
if (
wordMatches("NP", lastWord) ||
wordMatches("Neopoints", lastWord) ||
showAll
) {
suggestions.push({ itemKind: "NP", text: "Neopoint items" });
}
if (
wordMatches("PB", lastWord) ||
wordMatches("Paintbrush", lastWord) ||
showAll
) {
suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
}
}
if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
if (wordMatches("Items you own", lastWord) || showAll) {
suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
}
if (wordMatches("Items you want", lastWord) || showAll) {
suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
}
}
if (query.filterToZoneLabel == null) {
for (const zoneLabel of zoneLabels) {
if (wordMatches(zoneLabel, lastWord) || showAll) {
suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
}
}
}
return suggestions;
}
function wordMatches(target, word) {
return target.toLowerCase().includes(word.toLowerCase());
}
function getQueryFilterText(query) {
const textWords = [];
if (query.filterToItemKind) {
textWords.push(query.filterToItemKind);
}
if (query.filterToZoneLabel) {
textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
}
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
if (!query.filterToItemKind && !query.filterToZoneLabel) {
textWords.push("Items");
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
textWords.push("items");
}
textWords.push("you own");
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
if (!query.filterToItemKind && !query.filterToZoneLabel) {
textWords.push("Items");
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
textWords.push("items");
}
textWords.push("you want");
}
return textWords.join(" ");
}
/**
* pluralizeZoneLabel hackily tries to convert a zone name to a plural noun!
*
* HACK: It'd be more reliable and more translatable to do this by just
* manually creating the plural for each zone. But, ehh! ¯\_ ()_/¯
*/
function pluralizeZoneLabel(zoneLabel) {
if (zoneLabel.endsWith("ss")) {
return zoneLabel + "es";
} else if (zoneLabel.endsWith("s")) {
return zoneLabel;
} else {
return zoneLabel + "s";
}
}
/**
* removeLastWord returns a copy of the text, with the last word and any
* preceding space removed.
*/
function removeLastWord(text) {
// This regex matches the full text, and assigns the last word and any
// preceding text to subgroup 2, and all preceding text to subgroup 1. If
// there's no last word, we'll still match, and the full string will be in
// subgroup 1, including any space - no changes made!
const match = text.match(/^(.*?)(\s*\S+)?$/);
if (!match) {
logAndCapture(
new Error(
`Assertion failure: pattern should match any input text, ` +
`but failed to match ${JSON.stringify(text)}`
)
);
return text;
}
return match[1];
}
export default SearchToolbar;

View file

@ -0,0 +1,67 @@
import React from "react";
import { Box, Grid, useColorModeValue, useToken } from "@chakra-ui/react";
import { useCommonStyles } from "../util";
function WardrobePageLayout({
previewAndControls = null,
itemsAndMaybeSearchPanel = null,
searchFooter = null,
}) {
const itemsAndSearchBackground = useColorModeValue("white", "gray.900");
const searchBackground = useCommonStyles().bodyBackground;
const searchShadowColorValue = useToken("colors", "gray.400");
return (
<Box
position="absolute"
top="0"
bottom="0"
left="0"
right="0"
// Create a stacking context, so that our drawers and modals don't fight
// with the z-indexes in here!
zIndex="0"
>
<Grid
templateAreas={{
base: `"previewAndControls"
"itemsAndMaybeSearchPanel"`,
md: `"previewAndControls itemsAndMaybeSearchPanel"
"searchFooter searchFooter"`,
}}
templateRows={{
base: "minmax(100px, 45%) minmax(300px, 55%)",
md: "minmax(300px, 1fr) auto",
}}
templateColumns={{
base: "100%",
md: "50% 50%",
}}
height="100%"
width="100%"
>
<Box
gridArea="previewAndControls"
bg="gray.900"
color="gray.50"
position="relative"
>
{previewAndControls}
</Box>
<Box gridArea="itemsAndMaybeSearchPanel" bg={itemsAndSearchBackground}>
{itemsAndMaybeSearchPanel}
</Box>
<Box
gridArea="searchFooter"
bg={searchBackground}
boxShadow={`0 0 8px ${searchShadowColorValue}`}
display={{ base: "none", md: "block" }}
>
{searchFooter}
</Box>
</Grid>
</Box>
);
}
export default WardrobePageLayout;

View file

@ -0,0 +1,99 @@
import React from "react";
import { Box, Center, DarkMode } from "@chakra-ui/react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import * as Sentry from "@sentry/react";
import OutfitThumbnail from "../components/OutfitThumbnail";
import { useOutfitPreview } from "../components/OutfitPreview";
import { loadable, MajorErrorMessage, TestErrorSender } from "../util";
const OutfitControls = loadable(() => import("./OutfitControls"));
function WardrobePreviewAndControls({
isLoading,
outfitState,
dispatchToOutfit,
}) {
// Whether the current outfit preview has animations. Determines whether we
// show the play/pause button.
const [hasAnimations, setHasAnimations] = React.useState(false);
const { appearance, preview } = useOutfitPreview({
isLoading: isLoading,
speciesId: outfitState.speciesId,
colorId: outfitState.colorId,
pose: outfitState.pose,
appearanceId: outfitState.appearanceId,
wornItemIds: outfitState.wornItemIds,
onChangeHasAnimations: setHasAnimations,
placeholder: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
"data-test-id": "wardrobe-outfit-preview",
});
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Center position="absolute" top="0" bottom="0" left="0" right="0">
<DarkMode>{preview}</DarkMode>
</Center>
<Box position="absolute" top="0" bottom="0" left="0" right="0">
<OutfitControls
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
showAnimationControls={hasAnimations}
appearance={appearance}
/>
</Box>
</Sentry.ErrorBoundary>
);
}
/**
* OutfitThumbnailIfCached will render an OutfitThumbnail as a placeholder for
* the outfit preview... but only if we already have the data to generate the
* thumbnail stored in our local Apollo GraphQL cache.
*
* This means that, when you come from the Your Outfits page, we can show the
* outfit thumbnail instantly while everything else loads. But on direct
* navigation, this does nothing, and we just wait for the preview to load in
* like usual!
*/
function OutfitThumbnailIfCached({ outfitId }) {
const { data } = useQuery(
gql`
query OutfitThumbnailIfCached($outfitId: ID!) {
outfit(id: $outfitId) {
id
updatedAt
}
}
`,
{
variables: {
outfitId,
},
skip: outfitId == null,
fetchPolicy: "cache-only",
onError: (e) => console.error(e),
}
);
if (!data?.outfit) {
return null;
}
return (
<OutfitThumbnail
outfitId={data.outfit.id}
updatedAt={data.outfit.updatedAt}
alt=""
objectFit="contain"
width="100%"
height="100%"
filter="blur(2px)"
/>
);
}
export default WardrobePreviewAndControls;

View file

@ -0,0 +1,198 @@
import React from "react";
import { useToast } from "@chakra-ui/react";
import { useRouter } from "next/router";
import { emptySearchQuery } from "./SearchToolbar";
import ItemsAndSearchPanels from "./ItemsAndSearchPanels";
import SearchFooter from "./SearchFooter";
import SupportOnly from "./support/SupportOnly";
import useOutfitSaving from "./useOutfitSaving";
import useOutfitState, { OutfitStateContext } from "./useOutfitState";
import WardrobePageLayout from "./WardrobePageLayout";
import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
/**
* WardrobePage is the most fun page on the site - it's where you create
* outfits!
*
* This page has two sections: the OutfitPreview, where we show the outfit as a
* big image; and the ItemsAndSearchPanels, which let you manage which items
* are in the outfit and find new ones.
*
* This component manages shared outfit state, and the fullscreen responsive
* page layout.
*/
function WardrobePage() {
const toast = useToast();
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
// We manage outfit saving up here, rather than at the point of the UI where
// "Saving" indicators appear. That way, auto-saving still happens even when
// the indicator isn't on the page, e.g. when searching. We also mount a
// <Prompt /> in this component to prevent navigating away before saving.
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
// TODO: I haven't found a great place for this error UI yet, and this case
// isn't very common, so this lil toast notification seems good enough!
React.useEffect(() => {
if (error) {
console.error(error);
toast({
title: "We couldn't load this outfit 😖",
description: "Please reload the page to try again. Sorry!",
status: "error",
isClosable: true,
duration: 999999999,
});
}
}, [error, toast]);
// For new outfits, we only block navigation while saving. For existing
// outfits, we block navigation while there are any unsaved changes.
const shouldBlockNavigation =
outfitSaving.canSaveOutfit &&
((outfitSaving.isNewOutfit && outfitSaving.isSaving) ||
(!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved));
// In addition to a <Prompt /> for client-side nav, we need to block full nav!
React.useEffect(() => {
if (shouldBlockNavigation) {
const onBeforeUnload = (e) => {
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}
}, [shouldBlockNavigation]);
const title = `${outfitState.name || "Untitled outfit"} | Dress to Impress`;
React.useEffect(() => {
document.title = title;
}, [title]);
// NOTE: Most components pass around outfitState directly, to make the data
// relationships more explicit... but there are some deep components
// that need it, where it's more useful and more performant to access
// via context.
return (
<>
<OutfitStateContext.Provider value={outfitState}>
<SupportOnly>
<WardrobeDevHacks />
</SupportOnly>
{/*
* TODO: This might unnecessarily block navigations that we don't
* necessarily need to, e.g., navigating back to Your Outfits while the
* save request is in flight. We could instead submit the save mutation
* immediately on client-side nav, and have each outfit save mutation
* install a `beforeunload` handler that ensures that you don't close
* the page altogether while it's in flight. But let's start simple and
* see how annoying it actually is in practice lol
*/}
<Prompt
when={shouldBlockNavigation}
message="Are you sure you want to leave? Your changes might not be saved."
/>
<WardrobePageLayout
previewAndControls={
<WardrobePreviewAndControls
isLoading={loading}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
}
itemsAndMaybeSearchPanel={
<ItemsAndSearchPanels
loading={loading}
searchQuery={searchQuery}
onChangeSearchQuery={setSearchQuery}
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
}
searchFooter={
<SearchFooter
searchQuery={searchQuery}
onChangeSearchQuery={setSearchQuery}
outfitState={outfitState}
/>
}
/>
</OutfitStateContext.Provider>
</>
);
}
/**
* SavedOutfitMetaTags renders the meta tags that we use to render pretty
* share cards for social media for saved outfits!
*/
function SavedOutfitMetaTags({ outfitState }) {
const updatedAtTimestamp = Math.floor(
new Date(outfitState.updatedAt).getTime() / 1000
);
const imageUrl =
`https://impress-outfit-images.openneo.net/outfits` +
`/${encodeURIComponent(outfitState.id)}` +
`/v/${encodeURIComponent(updatedAtTimestamp)}` +
`/600.png`;
return (
<>
<meta
property="og:title"
content={outfitState.name || "Untitled outfit"}
/>
<meta property="og:type" content="website" />
<meta property="og:image" content={imageUrl} />
<meta property="og:url" content={outfitState.url} />
<meta property="og:site_name" content="Dress to Impress" />
<meta
property="og:description"
content="A custom Neopets outfit, designed on Dress to Impress!"
/>
</>
);
}
/**
* Prompt blocks client-side navigation via Next.js when the `when` prop is
* true. This is our attempt at a drop-in replacement for the Prompt component
* offered by react-router!
*
* Adapted from https://github.com/vercel/next.js/issues/2694#issuecomment-778225625
*/
function Prompt({ when, message }) {
const router = useRouter();
React.useEffect(() => {
const handleWindowClose = (e) => {
if (!when) return;
e.preventDefault();
return (e.returnValue = message);
};
const handleBrowseAway = () => {
if (!when) return;
if (window.confirm(message)) return;
router.events.emit("routeChangeError");
throw "routeChange aborted by <Prompt>.";
};
window.addEventListener("beforeunload", handleWindowClose);
router.events.on("routeChangeStart", handleBrowseAway);
return () => {
window.removeEventListener("beforeunload", handleWindowClose);
router.events.off("routeChangeStart", handleBrowseAway);
};
}, [when, message, router]);
return null;
}
export default WardrobePage;

View file

@ -0,0 +1,569 @@
import React from "react";
import {
Box,
Button,
Flex,
Heading,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Select,
Tooltip,
Wrap,
WrapItem,
} from "@chakra-ui/react";
import { gql, useMutation, useQuery } from "@apollo/client";
import {
appearanceLayerFragment,
appearanceLayerFragmentForSupport,
itemAppearanceFragment,
petAppearanceFragment,
} from "../../components/useOutfitAppearance";
import HangerSpinner from "../../components/HangerSpinner";
import { ErrorMessage, useCommonStyles } from "../../util";
import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
import { EditIcon } from "@chakra-ui/icons";
import useSupport from "./useSupport";
function AllItemLayersSupportModal({ item, isOpen, onClose }) {
const [bulkAddProposal, setBulkAddProposal] = React.useState(null);
const { bodyBackground } = useCommonStyles();
return (
<Modal size="4xl" isOpen={isOpen} onClose={onClose}>
<ModalOverlay>
<ModalContent background={bodyBackground}>
<ModalHeader as="h1" paddingBottom="2">
<Box as="span" fontWeight="700">
Layers on all pets:
</Box>{" "}
<Box as="span" fontWeight="normal">
{item.name}
</Box>
</ModalHeader>
<ModalCloseButton />
<ModalBody paddingBottom="12">
<BulkAddBodySpecificAssetsForm
bulkAddProposal={bulkAddProposal}
onSubmit={setBulkAddProposal}
/>
<Box height="8" />
<AllItemLayersSupportModalContent
item={item}
bulkAddProposal={bulkAddProposal}
onBulkAddComplete={() => setBulkAddProposal(null)}
/>
</ModalBody>
</ModalContent>
</ModalOverlay>
</Modal>
);
}
function BulkAddBodySpecificAssetsForm({ bulkAddProposal, onSubmit }) {
const [minAssetId, setMinAssetId] = React.useState(
bulkAddProposal?.minAssetId
);
const [assetIdStepValue, setAssetIdStepValue] = React.useState(1);
const [numSpecies, setNumSpecies] = React.useState(55);
const [colorId, setColorId] = React.useState("8");
return (
<Flex
align="center"
as="form"
fontSize="sm"
opacity="0.9"
transition="0.2s all"
onSubmit={(e) => {
e.preventDefault();
onSubmit({ minAssetId, numSpecies, assetIdStepValue, colorId });
}}
>
<Tooltip
label={
<Box textAlign="center" fontSize="xs">
<Box as="p" marginBottom="1em">
When an item accidentally gets assigned to fit all bodies, this
tool can help you recover the original appearances, by assuming
the layer IDs are assigned to each species in alphabetical order.
</Box>
<Box as="p">
This will only find layers that have already been modeled!
</Box>
</Box>
}
>
<Flex align="center" tabIndex="0">
<EditIcon marginRight="1" />
<Box>Bulk-add:</Box>
</Flex>
</Tooltip>
<Box width="2" />
<Input
type="number"
min="1"
step="1"
size="xs"
width="9ch"
placeholder="Min ID"
value={minAssetId || ""}
onChange={(e) => setMinAssetId(e.target.value || null)}
/>
<Box width="1" />
<Box></Box>
<Box width="1" />
<Input
type="number"
min="55"
step="1"
size="xs"
width="9ch"
placeholder="Max ID"
// Because this is an inclusive range, the offset between the numbers
// is one less than the number of entries in the range.
value={
minAssetId != null
? Number(minAssetId) + assetIdStepValue * (numSpecies - 1)
: ""
}
onChange={(e) =>
setMinAssetId(
e.target.value
? Number(e.target.value) - assetIdStepValue * (numSpecies - 1)
: null
)
}
/>
<Box width="1" />
<Select
size="xs"
width="12ch"
value={String(assetIdStepValue)}
onChange={(e) => setAssetIdStepValue(Number(e.target.value))}
>
<option value="1">(All IDs)</option>
<option value="2">(Every other ID)</option>
<option value="3">(Every 3rd ID)</option>
</Select>
<Box width="1" />
for
<Box width="1" />
<Select
size="xs"
width="20ch"
value={String(numSpecies)}
onChange={(e) => setNumSpecies(Number(e.target.value))}
>
<option value="55">All 55 species</option>
<option value="54">54 species, no Vandagyre</option>
</Select>
<Box width="1" />
<Select
size="xs"
width="20ch"
value={colorId}
onChange={(e) => setColorId(e.target.value)}
>
<option value="8">All standard colors</option>
<option value="6">Baby</option>
<option value="46">Mutant</option>
</Select>
<Box width="2" />
<Button type="submit" size="xs" isDisabled={minAssetId == null}>
Preview
</Button>
</Flex>
);
}
const allAppearancesFragment = gql`
fragment AllAppearancesForItem on Item {
allAppearances {
id
body {
id
representsAllBodies
canonicalAppearance {
id
species {
id
name
}
color {
id
name
isStandard
}
pose
...PetAppearanceForOutfitPreview
}
}
...ItemAppearanceForOutfitPreview
}
}
${itemAppearanceFragment}
${petAppearanceFragment}
`;
function AllItemLayersSupportModalContent({
item,
bulkAddProposal,
onBulkAddComplete,
}) {
const { supportSecret } = useSupport();
const { loading, error, data } = useQuery(
gql`
query AllItemLayersSupportModal($itemId: ID!) {
item(id: $itemId) {
id
...AllAppearancesForItem
}
}
${allAppearancesFragment}
`,
{ variables: { itemId: item.id } }
);
const {
loading: loading2,
error: error2,
data: bulkAddProposalData,
} = useQuery(
gql`
query AllItemLayersSupportModal_BulkAddProposal(
$layerRemoteIds: [ID!]!
$colorId: ID!
) {
layersToAdd: itemAppearanceLayersByRemoteId(
remoteIds: $layerRemoteIds
) {
id
remoteId
...AppearanceLayerForOutfitPreview
...AppearanceLayerForSupport
}
color(id: $colorId) {
id
appliedToAllCompatibleSpecies {
id
species {
id
name
}
body {
id
}
canonicalAppearance {
# These are a bit redundant, but it's convenient to just reuse
# what the other query is already doing.
id
species {
id
name
}
color {
id
name
isStandard
}
pose
...PetAppearanceForOutfitPreview
}
}
}
}
${appearanceLayerFragment}
${appearanceLayerFragmentForSupport}
${petAppearanceFragment}
`,
{
variables: {
layerRemoteIds: bulkAddProposal
? Array.from({ length: 54 }, (_, i) =>
String(
Number(bulkAddProposal.minAssetId) +
i * bulkAddProposal.assetIdStepValue
)
)
: [],
colorId: bulkAddProposal?.colorId,
},
skip: bulkAddProposal == null,
}
);
const [
sendBulkAddMutation,
{ loading: mutationLoading, error: mutationError },
] = useMutation(gql`
mutation AllItemLayersSupportModal_BulkAddMutation(
$itemId: ID!
$entries: [BulkAddLayersToItemEntry!]!
$supportSecret: String!
) {
bulkAddLayersToItem(
itemId: $itemId
entries: $entries
supportSecret: $supportSecret
) {
id
...AllAppearancesForItem
}
}
${allAppearancesFragment}
`);
if (loading || loading2) {
return (
<Flex align="center" justify="center" minHeight="64">
<HangerSpinner />
</Flex>
);
}
if (error || error2) {
return <ErrorMessage>{(error || error2).message}</ErrorMessage>;
}
let itemAppearances = data.item?.allAppearances || [];
itemAppearances = mergeBulkAddProposalIntoItemAppearances(
itemAppearances,
bulkAddProposal,
bulkAddProposalData
);
itemAppearances = [...itemAppearances].sort((a, b) => {
const aKey = getSortKeyForBody(a.body);
const bKey = getSortKeyForBody(b.body);
return aKey.localeCompare(bKey);
});
return (
<Box>
{bulkAddProposalData && (
<Flex align="center" marginBottom="6">
<Heading size="md">Previewing bulk-add changes</Heading>
<Box flex="1 0 auto" width="4" />
{mutationError && (
<ErrorMessage fontSize="xs" textAlign="right" marginRight="2">
{mutationError.message}
</ErrorMessage>
)}
<Button flex="0 0 auto" size="sm" onClick={onBulkAddComplete}>
Clear
</Button>
<Box width="2" />
<Button
flex="0 0 auto"
size="sm"
colorScheme="green"
isLoading={mutationLoading}
onClick={() => {
if (
!window.confirm("Are you sure? Bulk operations are dangerous!")
) {
return;
}
// HACK: This could pick up not just new layers, but existing layers
// that aren't changing. Shouldn't be a problem to save,
// though?
// NOTE: This API uses actual layer IDs, instead of the remote IDs
// that we use for body assignment in most of this tool.
const entries = itemAppearances
.map((a) =>
a.layers.map((l) => ({ layerId: l.id, bodyId: a.body.id }))
)
.flat();
sendBulkAddMutation({
variables: { itemId: item.id, entries, supportSecret },
})
.then(onBulkAddComplete)
.catch((e) => {
/* Handled in UI */
});
}}
>
Save {bulkAddProposalData.layersToAdd.length} changes
</Button>
</Flex>
)}
<Wrap justify="center" spacing="4">
{itemAppearances.map((itemAppearance) => (
<WrapItem key={itemAppearance.id}>
<ItemAppearanceCard item={item} itemAppearance={itemAppearance} />
</WrapItem>
))}
</Wrap>
</Box>
);
}
function ItemAppearanceCard({ item, itemAppearance }) {
const petAppearance = itemAppearance.body.canonicalAppearance;
const biologyLayers = petAppearance.layers;
const itemLayers = [...itemAppearance.layers].sort(
(a, b) => a.zone.depth - b.zone.depth
);
const { brightBackground } = useCommonStyles();
return (
<Box
background={brightBackground}
paddingX="4"
paddingY="3"
boxShadow="lg"
borderRadius="lg"
>
<Heading as="h2" size="sm" fontWeight="600">
{getBodyName(itemAppearance.body)}
</Heading>
<Box height="3" />
<Wrap paddingX="3" spacing="5">
{itemLayers.length === 0 && (
<Flex
minWidth="150px"
minHeight="150px"
align="center"
justify="center"
>
<Box fontSize="sm" fontStyle="italic">
(No data)
</Box>
</Flex>
)}
{itemLayers.map((itemLayer) => (
<WrapItem key={itemLayer.id}>
<ItemSupportAppearanceLayer
item={item}
itemLayer={itemLayer}
biologyLayers={biologyLayers}
outfitState={{
speciesId: petAppearance.species.id,
colorId: petAppearance.color.id,
pose: petAppearance.pose,
}}
/>
</WrapItem>
))}
</Wrap>
</Box>
);
}
function getSortKeyForBody(body) {
// "All bodies" sorts first!
if (body.representsAllBodies) {
return "";
}
const { color, species } = body.canonicalAppearance;
// Sort standard colors first, then special colors by name, then by species
// within each color.
return `${color.isStandard ? "A" : "Z"}-${color.name}-${species.name}`;
}
function getBodyName(body) {
if (body.representsAllBodies) {
return "All bodies";
}
const { species, color } = body.canonicalAppearance;
const speciesName = capitalize(species.name);
const colorName = color.isStandard ? "Standard" : capitalize(color.name);
return `${colorName} ${speciesName}`;
}
function capitalize(str) {
return str[0].toUpperCase() + str.slice(1);
}
function mergeBulkAddProposalIntoItemAppearances(
itemAppearances,
bulkAddProposal,
bulkAddProposalData
) {
if (!bulkAddProposalData) {
return itemAppearances;
}
const { color, layersToAdd } = bulkAddProposalData;
// Do a deep copy of the existing item appearances, so we can mutate them as
// we loop through them in this function!
const mergedItemAppearances = JSON.parse(JSON.stringify(itemAppearances));
// To exclude Vandagyre, we take the first N species by ID - which is
// different than the alphabetical sort order we use for assigning layers!
const speciesColorPairsToInclude = [...color.appliedToAllCompatibleSpecies]
.sort((a, b) => Number(a.species.id) - Number(b.species.id))
.slice(0, bulkAddProposal.numSpecies);
// Set up the incoming data in convenient formats.
const sortedSpeciesColorPairs = [...speciesColorPairsToInclude].sort((a, b) =>
a.species.name.localeCompare(b.species.name)
);
const layersToAddByRemoteId = {};
for (const layer of layersToAdd) {
layersToAddByRemoteId[layer.remoteId] = layer;
}
for (const [index, speciesColorPair] of sortedSpeciesColorPairs.entries()) {
const { body, canonicalAppearance } = speciesColorPair;
// Find the existing item appearance to add to, or create a new one if it
// doesn't exist yet.
let itemAppearance = mergedItemAppearances.find(
(a) => a.body.id === body.id && !a.body.representsAllBodies
);
if (!itemAppearance) {
itemAppearance = {
id: `bulk-add-proposal-new-item-appearance-for-body-${body.id}`,
layers: [],
body: {
id: body.id,
canonicalAppearance,
},
};
mergedItemAppearances.push(itemAppearance);
}
const layerToAddRemoteId = String(
Number(bulkAddProposal.minAssetId) +
index * bulkAddProposal.assetIdStepValue
);
const layerToAdd = layersToAddByRemoteId[layerToAddRemoteId];
if (!layerToAdd) {
continue;
}
// Delete this layer from other appearances (because we're going to
// override its body ID), then add it to this new one.
for (const otherItemAppearance of mergedItemAppearances) {
const indexToDelete = otherItemAppearance.layers.findIndex(
(l) => l.remoteId === layerToAddRemoteId
);
if (indexToDelete >= 0) {
otherItemAppearance.layers.splice(indexToDelete, 1);
}
}
itemAppearance.layers.push(layerToAdd);
}
return mergedItemAppearances;
}
export default AllItemLayersSupportModal;

View file

@ -0,0 +1,644 @@
import * as React from "react";
import gql from "graphql-tag";
import { useMutation } from "@apollo/client";
import {
Button,
Box,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
Spinner,
useDisclosure,
useToast,
CheckboxGroup,
VStack,
Checkbox,
} from "@chakra-ui/react";
import { ChevronRightIcon, ExternalLinkIcon } from "@chakra-ui/icons";
import AppearanceLayerSupportUploadModal from "./AppearanceLayerSupportUploadModal";
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
import { OutfitLayers } from "../../components/OutfitPreview";
import SpeciesColorPicker from "../../components/SpeciesColorPicker";
import useOutfitAppearance, {
itemAppearanceFragment,
} from "../../components/useOutfitAppearance";
import useSupport from "./useSupport";
/**
* AppearanceLayerSupportModal offers Support info and tools for a specific item
* appearance layer. Open it by clicking a layer from ItemSupportDrawer.
*/
function AppearanceLayerSupportModal({
item, // Specify this or `petAppearance`
petAppearance, // Specify this or `item`
layer,
outfitState, // speciesId, colorId, pose
isOpen,
onClose,
}) {
const [selectedBodyId, setSelectedBodyId] = React.useState(layer.bodyId);
const [selectedKnownGlitches, setSelectedKnownGlitches] = React.useState(
layer.knownGlitches
);
const [previewBiology, setPreviewBiology] = React.useState({
speciesId: outfitState.speciesId,
colorId: outfitState.colorId,
pose: outfitState.pose,
isValid: true,
});
const [uploadModalIsOpen, setUploadModalIsOpen] = React.useState(false);
const { supportSecret } = useSupport();
const toast = useToast();
const parentName = item
? item.name
: `${petAppearance.color.name} ${petAppearance.species.name} ${petAppearance.id}`;
const [mutate, { loading: mutationLoading, error: mutationError }] =
useMutation(
gql`
mutation ApperanceLayerSupportSetLayerBodyId(
$layerId: ID!
$bodyId: ID!
$knownGlitches: [AppearanceLayerKnownGlitch!]!
$supportSecret: String!
$outfitSpeciesId: ID!
$outfitColorId: ID!
$formPreviewSpeciesId: ID!
$formPreviewColorId: ID!
) {
setLayerBodyId(
layerId: $layerId
bodyId: $bodyId
supportSecret: $supportSecret
) {
# This mutation returns the affected AppearanceLayer. Fetch the
# updated fields, including the appearance on the outfit pet and the
# form preview pet, to automatically update our cached appearance in
# the rest of the app. That means you should be able to see your
# changes immediately!
id
bodyId
item {
id
appearanceOnOutfit: appearanceOn(
speciesId: $outfitSpeciesId
colorId: $outfitColorId
) {
...ItemAppearanceForOutfitPreview
}
appearanceOnFormPreviewPet: appearanceOn(
speciesId: $formPreviewSpeciesId
colorId: $formPreviewColorId
) {
...ItemAppearanceForOutfitPreview
}
}
}
setLayerKnownGlitches(
layerId: $layerId
knownGlitches: $knownGlitches
supportSecret: $supportSecret
) {
id
knownGlitches
svgUrl # Affected by OFFICIAL_SVG_IS_INCORRECT
}
}
${itemAppearanceFragment}
`,
{
variables: {
layerId: layer.id,
bodyId: selectedBodyId,
knownGlitches: selectedKnownGlitches,
supportSecret,
outfitSpeciesId: outfitState.speciesId,
outfitColorId: outfitState.colorId,
formPreviewSpeciesId: previewBiology.speciesId,
formPreviewColorId: previewBiology.colorId,
},
onCompleted: () => {
onClose();
toast({
status: "success",
title: `Saved layer ${layer.id}: ${parentName}`,
});
},
}
);
// TODO: Would be nicer to just learn the correct URL from the server, but we
// don't happen to be saving it, and it would be extra stuff to put on
// the GraphQL request for non-Support users. We could also just try
// loading them, but, ehhh…
const [newManifestUrl, oldManifestUrl] = convertSwfUrlToPossibleManifestUrls(
layer.swfUrl
);
return (
<Modal size="xl" isOpen={isOpen} onClose={onClose}>
<ModalOverlay>
<ModalContent>
<ModalHeader>
Layer {layer.id}: {parentName}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Metadata>
<MetadataLabel>DTI ID:</MetadataLabel>
<MetadataValue>{layer.id}</MetadataValue>
<MetadataLabel>Neopets ID:</MetadataLabel>
<MetadataValue>{layer.remoteId}</MetadataValue>
<MetadataLabel>Zone:</MetadataLabel>
<MetadataValue>
{layer.zone.label} ({layer.zone.id})
</MetadataValue>
<MetadataLabel>Assets:</MetadataLabel>
<MetadataValue>
<HStack spacing="2">
<Button
as="a"
size="xs"
target="_blank"
href={newManifestUrl}
colorScheme="teal"
>
Manifest (new) <ExternalLinkIcon ml="1" />
</Button>
<Button
as="a"
size="xs"
target="_blank"
href={oldManifestUrl}
colorScheme="teal"
>
Manifest (old) <ExternalLinkIcon ml="1" />
</Button>
</HStack>
<HStack spacing="2" marginTop="1">
{layer.canvasMovieLibraryUrl ? (
<Button
as="a"
size="xs"
target="_blank"
href={layer.canvasMovieLibraryUrl}
colorScheme="teal"
>
Movie <ExternalLinkIcon ml="1" />
</Button>
) : (
<Button size="xs" isDisabled>
No Movie
</Button>
)}
{layer.svgUrl ? (
<Button
as="a"
size="xs"
target="_blank"
href={layer.svgUrl}
colorScheme="teal"
>
SVG <ExternalLinkIcon ml="1" />
</Button>
) : (
<Button size="xs" isDisabled>
No SVG
</Button>
)}
{layer.imageUrl ? (
<Button
as="a"
size="xs"
target="_blank"
href={layer.imageUrl}
colorScheme="teal"
>
PNG <ExternalLinkIcon ml="1" />
</Button>
) : (
<Button size="xs" isDisabled>
No PNG
</Button>
)}
<Button
as="a"
size="xs"
target="_blank"
href={layer.swfUrl}
colorScheme="teal"
>
SWF <ExternalLinkIcon ml="1" />
</Button>
<Box flex="1 1 0" />
{item && (
<>
<Button
size="xs"
colorScheme="gray"
onClick={() => setUploadModalIsOpen(true)}
>
Upload PNG <ChevronRightIcon />
</Button>
<AppearanceLayerSupportUploadModal
item={item}
layer={layer}
isOpen={uploadModalIsOpen}
onClose={() => setUploadModalIsOpen(false)}
/>
</>
)}
</HStack>
</MetadataValue>
</Metadata>
<Box height="8" />
{item && (
<>
<AppearanceLayerSupportPetCompatibilityFields
item={item}
layer={layer}
outfitState={outfitState}
selectedBodyId={selectedBodyId}
previewBiology={previewBiology}
onChangeBodyId={setSelectedBodyId}
onChangePreviewBiology={setPreviewBiology}
/>
<Box height="8" />
</>
)}
<AppearanceLayerSupportKnownGlitchesFields
selectedKnownGlitches={selectedKnownGlitches}
onChange={setSelectedKnownGlitches}
/>
</ModalBody>
<ModalFooter>
{item && (
<AppearanceLayerSupportModalRemoveButton
item={item}
layer={layer}
outfitState={outfitState}
onRemoveSuccess={onClose}
/>
)}
<Box flex="1 0 0" />
{mutationError && (
<Box
color="red.400"
fontSize="sm"
marginLeft="8"
marginRight="2"
textAlign="right"
>
{mutationError.message}
</Box>
)}
<Button
isLoading={mutationLoading}
colorScheme="green"
onClick={() =>
mutate().catch((e) => {
/* Discard errors here; we'll show them in the UI! */
})
}
flex="0 0 auto"
>
Save changes
</Button>
</ModalFooter>
</ModalContent>
</ModalOverlay>
</Modal>
);
}
function AppearanceLayerSupportPetCompatibilityFields({
item,
layer,
outfitState,
selectedBodyId,
previewBiology,
onChangeBodyId,
onChangePreviewBiology,
}) {
const [selectedBiology, setSelectedBiology] = React.useState(previewBiology);
const {
loading,
error,
visibleLayers,
bodyId: appearanceBodyId,
} = useOutfitAppearance({
speciesId: previewBiology.speciesId,
colorId: previewBiology.colorId,
pose: previewBiology.pose,
wornItemIds: [item.id],
});
const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
// After we touch a species/color selector and null out `bodyId`, when the
// appearance body ID loads in, select it as the new body ID.
//
// This might move the radio button away from "all pets", but I think that's
// a _less_ surprising experience: if you're touching the pickers, then
// that's probably where you head is.
React.useEffect(() => {
if (selectedBodyId == null && appearanceBodyId != null) {
onChangeBodyId(appearanceBodyId);
}
}, [selectedBodyId, appearanceBodyId, onChangeBodyId]);
return (
<FormControl isInvalid={error || !selectedBiology.isValid ? true : false}>
<FormLabel fontWeight="bold">Pet compatibility</FormLabel>
<RadioGroup
colorScheme="green"
value={selectedBodyId}
onChange={(newBodyId) => onChangeBodyId(newBodyId)}
marginBottom="4"
>
<Radio value="0">
Fits all pets{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Body ID: 0)
</Box>
</Radio>
<Radio as="div" value={appearanceBodyId} marginTop="2">
Fits all pets with the same body as:{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Body ID:{" "}
{appearanceBodyId == null ? (
<Spinner size="sm" />
) : (
appearanceBodyId
)}
)
</Box>
</Radio>
</RadioGroup>
<Box display="flex" flexDirection="column" alignItems="center">
<Box
width="150px"
height="150px"
marginTop="2"
marginBottom="2"
boxShadow="md"
borderRadius="md"
>
<OutfitLayers
loading={loading}
visibleLayers={[...biologyLayers, layer]}
/>
</Box>
<SpeciesColorPicker
speciesId={selectedBiology.speciesId}
colorId={selectedBiology.colorId}
idealPose={outfitState.pose}
size="sm"
showPlaceholders
onChange={(species, color, isValid, pose) => {
const speciesId = species.id;
const colorId = color.id;
setSelectedBiology({ speciesId, colorId, isValid, pose });
if (isValid) {
onChangePreviewBiology({ speciesId, colorId, isValid, pose });
// Also temporarily null out the body ID. We'll switch to the new
// body ID once it's loaded.
onChangeBodyId(null);
}
}}
/>
<Box height="1" />
{!error && (
<FormHelperText>
If it doesn't look right, try some other options until it does!
</FormHelperText>
)}
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
</Box>
</FormControl>
);
}
function AppearanceLayerSupportKnownGlitchesFields({
selectedKnownGlitches,
onChange,
}) {
return (
<FormControl>
<FormLabel fontWeight="bold">Known glitches</FormLabel>
<CheckboxGroup value={selectedKnownGlitches} onChange={onChange}>
<VStack spacing="2" align="flex-start">
<Checkbox value="OFFICIAL_SWF_IS_INCORRECT">
Official SWF is incorrect{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Will display a message)
</Box>
</Checkbox>
<Checkbox value="OFFICIAL_SVG_IS_INCORRECT">
Official SVG is incorrect{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Will use the PNG instead)
</Box>
</Checkbox>
<Checkbox value="OFFICIAL_MOVIE_IS_INCORRECT">
Official Movie is incorrect{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Will display a message)
</Box>
</Checkbox>
<Checkbox value="DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN">
Displays incorrectly, but cause unknown{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Will display a vague message)
</Box>
</Checkbox>
<Checkbox value="OFFICIAL_BODY_ID_IS_INCORRECT">
Fits all pets on-site, but should not{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(TNT's fault. Will show a message, and keep the compatibility
settings above.)
</Box>
</Checkbox>
<Checkbox value="REQUIRES_OTHER_BODY_SPECIFIC_ASSETS">
Only fits pets with other body-specific assets{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(DTI's fault: bodyId=0 is a lie! Will mark incompatible for some
pets.)
</Box>
</Checkbox>
</VStack>
</CheckboxGroup>
</FormControl>
);
}
function AppearanceLayerSupportModalRemoveButton({
item,
layer,
outfitState,
onRemoveSuccess,
}) {
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
const { supportSecret } = useSupport();
const [mutate, { loading, error }] = useMutation(
gql`
mutation AppearanceLayerSupportRemoveButton(
$layerId: ID!
$itemId: ID!
$outfitSpeciesId: ID!
$outfitColorId: ID!
$supportSecret: String!
) {
removeLayerFromItem(
layerId: $layerId
itemId: $itemId
supportSecret: $supportSecret
) {
# This mutation returns the affected layer, and the affected item.
# Fetch the updated appearance for the current outfit, which should
# no longer include this layer. This means you should be able to see
# your changes immediately!
item {
id
appearanceOn(speciesId: $outfitSpeciesId, colorId: $outfitColorId) {
...ItemAppearanceForOutfitPreview
}
}
# The layer's item should be null now, fetch to confirm and update!
layer {
id
item {
id
}
}
}
}
${itemAppearanceFragment}
`,
{
variables: {
layerId: layer.id,
itemId: item.id,
outfitSpeciesId: outfitState.speciesId,
outfitColorId: outfitState.colorId,
supportSecret,
},
onCompleted: () => {
onClose();
onRemoveSuccess();
toast({
status: "success",
title: `Removed layer ${layer.id} from ${item.name}`,
});
},
}
);
return (
<>
<Button colorScheme="red" flex="0 0 auto" onClick={onOpen}>
Remove
</Button>
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay>
<ModalContent>
<ModalCloseButton />
<ModalHeader>
Remove Layer {layer.id} ({layer.zone.label}) from {item.name}?
</ModalHeader>
<ModalBody>
<Box as="p" marginBottom="4">
This will permanently-ish remove Layer {layer.id} (
{layer.zone.label}) from this item.
</Box>
<Box as="p" marginBottom="4">
If you remove a correct layer by mistake, re-modeling should fix
it, or Matchu can restore it if you write down the layer ID
before proceeding!
</Box>
<Box as="p" marginBottom="4">
Are you sure you want to remove Layer {layer.id} from this item?
</Box>
</ModalBody>
<ModalFooter>
<Button flex="0 0 auto" onClick={onClose}>
Close
</Button>
<Box flex="1 0 0" />
{error && (
<Box
color="red.400"
fontSize="sm"
marginLeft="8"
marginRight="2"
textAlign="right"
>
{error.message}
</Box>
)}
<Button
colorScheme="red"
flex="0 0 auto"
onClick={() =>
mutate().catch((e) => {
/* Discard errors here; we'll show them in the UI! */
})
}
isLoading={loading}
>
Yes, remove permanently
</Button>
</ModalFooter>
</ModalContent>
</ModalOverlay>
</Modal>
</>
);
}
const SWF_URL_PATTERN =
/^https?:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/;
function convertSwfUrlToPossibleManifestUrls(swfUrl) {
const match = new URL(swfUrl, "http://images.neopets.com")
.toString()
.match(SWF_URL_PATTERN);
if (!match) {
throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`);
}
const type = match[1];
const folders = match[2];
const hash = match[3];
// TODO: There are a few potential manifest URLs in play! Long-term, we
// should get this from modeling data. But these are some good guesses!
return [
`http://images.neopets.com/cp/${type}/data/${folders}/manifest.json`,
`http://images.neopets.com/cp/${type}/data/${folders}_${hash}/manifest.json`,
];
}
export default AppearanceLayerSupportModal;

View file

@ -0,0 +1,639 @@
import * as React from "react";
import { useApolloClient } from "@apollo/client";
import {
Button,
Box,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
useToast,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import { safeImageUrl } from "../../util";
import useSupport from "./useSupport";
/**
* AppearanceLayerSupportUploadModal helps Support users create and upload PNGs for
* broken appearance layers. Useful when the auto-converters are struggling,
* e.g. the SWF uses a color filter our server-side Flash player can't support!
*/
function AppearanceLayerSupportUploadModal({ item, layer, isOpen, onClose }) {
const [step, setStep] = React.useState(1);
const [imageOnBlackUrl, setImageOnBlackUrl] = React.useState(null);
const [imageOnWhiteUrl, setImageOnWhiteUrl] = React.useState(null);
const [imageWithAlphaUrl, setImageWithAlphaUrl] = React.useState(null);
const [imageWithAlphaBlob, setImageWithAlphaBlob] = React.useState(null);
const [numWarnings, setNumWarnings] = React.useState(null);
const [isUploading, setIsUploading] = React.useState(false);
const [uploadError, setUploadError] = React.useState(null);
const [conflictMode, setConflictMode] = React.useState("onBlack");
const { supportSecret } = useSupport();
const toast = useToast();
const apolloClient = useApolloClient();
// Once both images are ready, merge them!
React.useEffect(() => {
if (!imageOnBlackUrl || !imageOnWhiteUrl) {
return;
}
setImageWithAlphaUrl(null);
setNumWarnings(null);
setIsUploading(false);
mergeIntoImageWithAlpha(
imageOnBlackUrl,
imageOnWhiteUrl,
conflictMode
).then(([url, blob, numWarnings]) => {
setImageWithAlphaUrl(url);
setImageWithAlphaBlob(blob);
setNumWarnings(numWarnings);
});
}, [imageOnBlackUrl, imageOnWhiteUrl, conflictMode]);
const onUpload = React.useCallback(
(e) => {
const file = e.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (re) => {
switch (step) {
case 1:
setImageOnBlackUrl(re.target.result);
setStep(2);
return;
case 2:
setImageOnWhiteUrl(re.target.result);
setStep(3);
return;
default:
throw new Error(`unexpected step ${step}`);
}
};
reader.readAsDataURL(file);
},
[step]
);
const onSubmitFinalImage = React.useCallback(async () => {
setIsUploading(true);
setUploadError(null);
try {
const res = await fetch(`/api/uploadLayerImage?layerId=${layer.id}`, {
method: "POST",
headers: {
"DTI-Support-Secret": supportSecret,
},
body: imageWithAlphaBlob,
});
if (!res.ok) {
setIsUploading(false);
setUploadError(
new Error(`Network error: ${res.status} ${res.statusText}`)
);
return;
}
setIsUploading(false);
onClose();
toast({
status: "success",
title: "Image successfully uploaded",
description: "It might take a few seconds to update in the app!",
});
// NOTE: I tried to do this as a cache update, but I couldn't ever get
// the fragment with size parameters to work :/ (Other fields would
// update, but not these!) Ultimately the eviction is the only
// reliable method I found :/
apolloClient.cache.evict({
id: `AppearanceLayer:${layer.id}`,
fieldName: "imageUrl",
});
apolloClient.cache.evict({
id: `AppearanceLayer:${layer.id}`,
fieldName: "imageUrlV2",
});
} catch (e) {
setIsUploading(false);
setUploadError(e);
}
}, [
imageWithAlphaBlob,
supportSecret,
layer.id,
toast,
onClose,
apolloClient.cache,
]);
return (
<Modal
// HACK: The built-in `full` size also sets 100% height, which I don't
// want; and the docs suggest it will accept px values, but it
// doesn't. But I discovered that invalid size values are treated
// as 100% width and auto height, so, okay! ^_^` Probably a bug,
// but I intend to use it for now!
size="full-hack"
isOpen={isOpen}
onClose={onClose}
>
<ModalOverlay>
<ModalContent>
<ModalHeader textAlign="center">
Upload PNG for {item.name}
</ModalHeader>
<ModalCloseButton />
<ModalBody
paddingBottom="2"
display="flex"
flexDirection="column"
alignItems="center"
textAlign="center"
>
{(step === 1 || step === 2) && (
<AppearanceLayerSupportScreenshotStep
layer={layer}
step={step}
onUpload={onUpload}
/>
)}
{step === 3 && (
<AppearanceLayerSupportReviewStep
imageWithAlphaUrl={imageWithAlphaUrl}
numWarnings={numWarnings}
conflictMode={conflictMode}
onChangeConflictMode={setConflictMode}
/>
)}
</ModalBody>
<ModalFooter>
<Button colorScheme="red" onClick={() => setStep(1)}>
Restart
</Button>
<Box flex="1 1 0" />
{uploadError && (
<Box
color="red.400"
fontSize="sm"
marginRight="2"
textAlign="right"
>
{uploadError.message}
</Box>
)}
<Button onClick={onClose}>Close</Button>
{step === 3 && (
<Button
colorScheme="green"
marginLeft="2"
onClick={onSubmitFinalImage}
isLoading={isUploading}
>
Upload
</Button>
)}
</ModalFooter>
</ModalContent>
</ModalOverlay>
</Modal>
);
}
function AppearanceLayerSupportScreenshotStep({ layer, step, onUpload }) {
return (
<>
<Box>
<b>Step {step}:</b> Take a screenshot of exactly the 600&times;600 Flash
region, then upload it below.
<br />
The border will turn green once the entire region is in view.
</Box>
<Box
display="flex"
alignItems="center"
maxWidth="600px"
width="100%"
marginTop="2"
>
<input key={step} type="file" accept="image/png" onChange={onUpload} />
<Box flex="1 1 0" />
<Button
as="a"
href="https://support.mozilla.org/en-US/kb/firefox-screenshots"
target="_blank"
size="xs"
marginLeft="1"
colorScheme="gray"
>
Firefox help <ExternalLinkIcon marginLeft="1" />
</Button>
<Button
as="a"
href="https://umaar.com/dev-tips/156-element-screenshot/"
target="_blank"
size="xs"
marginLeft="1"
colorScheme="gray"
>
Chrome help <ExternalLinkIcon marginLeft="1" />
</Button>
</Box>
<AppearanceLayerSupportFlashPlayer
swfUrl={layer.swfUrl}
backgroundColor={step === 1 ? "black" : "white"}
/>
</>
);
}
function AppearanceLayerSupportReviewStep({
imageWithAlphaUrl,
numWarnings,
conflictMode,
onChangeConflictMode,
}) {
if (imageWithAlphaUrl == null) {
return <Box>Generating image</Box>;
}
const ratioBad = numWarnings / (600 * 600);
const ratioGood = 1 - ratioBad;
return (
<>
<Box>
<b>Step 3:</b> Does this look correct? If so, let's upload it!
</Box>
<Box fontSize="sm" color="gray.500">
({Math.floor(ratioGood * 10000) / 100}% match,{" "}
{Math.floor(ratioBad * 10000) / 100}% mismatch.)
</Box>
<Box
// Checkerboard pattern: https://stackoverflow.com/a/35362074/107415
backgroundImage="linear-gradient(45deg, #c0c0c0 25%, transparent 25%), linear-gradient(-45deg, #c0c0c0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c0c0c0 75%), linear-gradient(-45deg, transparent 75%, #c0c0c0 75%)"
backgroundSize="20px 20px"
backgroundPosition="0 0, 0 10px, 10px -10px, -10px 0px"
marginTop="2"
>
{imageWithAlphaUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageWithAlphaUrl}
width={600}
height={600}
alt="Generated layer PNG, on a checkered background"
/>
)}
</Box>
<Box>
{numWarnings > 0 && (
<Box
display="flex"
flexDirection="row"
alignItems="center"
justifyContent="center"
width="600px"
marginTop="2"
>
<Box flex="0 1 auto" marginRight="2">
When pixels conflict, we use
</Box>
<Select
flex="0 0 200px"
value={conflictMode}
onChange={(e) => onChangeConflictMode(e.target.value)}
>
<option value="onBlack">the version on black</option>
<option value="onWhite">the version on white</option>
<option value="transparent">transparent pixels</option>
<option value="moreColorful">the more colorful pixels</option>
</Select>
</Box>
)}
</Box>
</>
);
}
function AppearanceLayerSupportFlashPlayer({ swfUrl, backgroundColor }) {
const [isVisible, setIsVisible] = React.useState(null);
const regionRef = React.useRef(null);
// We detect whether the entire SWF region is visible, because Flash only
// bothers to render in visible places. So, screenshotting a SWF container
// that isn't fully visible will fill the not-visible space with black,
// instead of the actual SWF content. We change the border color to hint this
// to the user!
React.useLayoutEffect(() => {
const region = regionRef.current;
if (!region) {
return;
}
const scrollParent = region.closest(".chakra-modal__overlay");
if (!scrollParent) {
throw new Error(`could not find .chakra-modal__overlay scroll parent`);
}
const onMountOrScrollOrResize = () => {
const regionBox = region.getBoundingClientRect();
const scrollParentBox = scrollParent.getBoundingClientRect();
const isVisible =
regionBox.left > scrollParentBox.left &&
regionBox.right < scrollParentBox.right &&
regionBox.top > scrollParentBox.top &&
regionBox.bottom < scrollParentBox.bottom;
setIsVisible(isVisible);
};
onMountOrScrollOrResize();
scrollParent.addEventListener("scroll", onMountOrScrollOrResize);
window.addEventListener("resize", onMountOrScrollOrResize);
return () => {
scrollParent.removeEventListener("scroll", onMountOrScrollOrResize);
window.removeEventListener("resize", onMountOrScrollOrResize);
};
}, []);
let borderColor;
if (isVisible === null) {
borderColor = "gray.400";
} else if (isVisible === false) {
borderColor = "red.400";
} else if (isVisible === true) {
borderColor = "green.400";
}
return (
<Box
data-hint="No: Don't screenshot this node! Use the one below!"
borderWidth="3px"
borderStyle="dashed"
borderColor={borderColor}
marginTop="4"
padding="1px"
backgroundColor={backgroundColor}
>
<Box
// In Chrome on macOS, I observe that I need to shift the SWF
// one pixel to the left in order to capture it correctly.
//
// So, in Chrome, who are using a DevTools procedure, we add a
// hint that this is the node to use.
//
// In Firefox, the GUI to target the SWF seems to work just
// fine. So, the margin hack and these hints don't matter!
data-hint="Yes: Screenshot this node! This is the one!"
backgroundColor={backgroundColor}
>
<Box
data-hint="No: Don't screenshot this node! Use the one above!"
width="600px"
height="600px"
// In Chrome on macOS, I observe that I need to shift the SWF
// one pixel to the left in order to capture it correctly.
//
// But this disrupts the Firefox capture! So here, we do a cheap
// browser detection, to shift left only in Chrome.
marginLeft={navigator.userAgent.includes("Chrome") ? "-1px" : "0"}
ref={regionRef}
>
<object
type="application/x-shockwave-flash"
data={safeImageUrl(swfUrl)}
width="100%"
height="100%"
>
<param name="wmode" value="transparent" />
</object>
</Box>
</Box>
</Box>
);
}
async function mergeIntoImageWithAlpha(
imageOnBlackUrl,
imageOnWhiteUrl,
conflictMode
) {
const [imageOnBlack, imageOnWhite] = await Promise.all([
readImageDataFromUrl(imageOnBlackUrl),
readImageDataFromUrl(imageOnWhiteUrl),
]);
const [imageWithAlphaData, numWarnings] = mergeDataIntoImageWithAlpha(
imageOnBlack,
imageOnWhite,
conflictMode
);
const [
imageWithAlphaUrl,
imageWithAlphaBlob,
] = await writeImageDataToUrlAndBlob(imageWithAlphaData);
return [imageWithAlphaUrl, imageWithAlphaBlob, numWarnings];
}
function mergeDataIntoImageWithAlpha(imageOnBlack, imageOnWhite, conflictMode) {
const imageWithAlpha = new ImageData(600, 600);
let numWarnings = 0;
for (let x = 0; x < 600; x++) {
for (let y = 0; y < 600; y++) {
const pixelIndex = (600 * y + x) << 2;
const rOnBlack = imageOnBlack.data[pixelIndex];
const gOnBlack = imageOnBlack.data[pixelIndex + 1];
const bOnBlack = imageOnBlack.data[pixelIndex + 2];
const rOnWhite = imageOnWhite.data[pixelIndex];
const gOnWhite = imageOnWhite.data[pixelIndex + 1];
const bOnWhite = imageOnWhite.data[pixelIndex + 2];
if (rOnWhite < rOnBlack || gOnWhite < gOnBlack || bOnWhite < bOnBlack) {
if (numWarnings < 100) {
console.warn(
`[${x}x${y}] color on white should be lighter than color on ` +
`black, see pixel ${x}x${y}: ` +
`#${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
`${gOnWhite.toString(16)}` +
` vs ` +
`#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
`${gOnWhite.toString(16)}. ` +
`Using conflict mode ${conflictMode} to fall back.`
);
}
const [r, g, b, a] = resolveConflict(
[rOnBlack, gOnBlack, bOnBlack],
[rOnWhite, gOnWhite, bOnWhite],
conflictMode
);
imageWithAlpha.data[pixelIndex] = r;
imageWithAlpha.data[pixelIndex + 1] = g;
imageWithAlpha.data[pixelIndex + 2] = b;
imageWithAlpha.data[pixelIndex + 3] = a;
numWarnings++;
continue;
}
// The true alpha is how close together the on-white and on-black colors
// are. If they're totally the same, it's 255 opacity. If they're totally
// different, it's 0 opacity. In between, it scales linearly with the
// difference!
const alpha = 255 - (rOnWhite - rOnBlack);
// Check that the alpha derived from other channels makes sense too.
const alphaByB = 255 - (bOnWhite - bOnBlack);
const alphaByG = 255 - (gOnWhite - gOnBlack);
const highestAlpha = Math.max(Math.max(alpha, alphaByB), alphaByG);
const lowestAlpha = Math.min(Math.min(alpha, alphaByB, alphaByG));
if (highestAlpha - lowestAlpha > 2) {
if (numWarnings < 100) {
console.warn(
`[${x}x${y}] derived alpha values don't match: ` +
`${alpha} vs ${alphaByB} vs ${alphaByG}. ` +
`Colors: #${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
`${gOnWhite.toString(16)}` +
` vs ` +
`#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
`${gOnWhite.toString(16)}. ` +
`Using conflict mode ${conflictMode} to fall back.`
);
}
const [r, g, b, a] = resolveConflict(
[rOnBlack, gOnBlack, bOnBlack],
[rOnWhite, gOnWhite, bOnWhite],
conflictMode
);
imageWithAlpha.data[pixelIndex] = r;
imageWithAlpha.data[pixelIndex + 1] = g;
imageWithAlpha.data[pixelIndex + 2] = b;
imageWithAlpha.data[pixelIndex + 3] = a;
numWarnings++;
continue;
}
// And the true color is the color on black, divided by the true alpha.
// We can derive this from the definition of the color on black, which is
// simply the true color times the true alpha. Divide to undo!
const alphaRatio = alpha / 255;
const rOnAlpha = Math.round(rOnBlack / alphaRatio);
const gOnAlpha = Math.round(gOnBlack / alphaRatio);
const bOnAlpha = Math.round(bOnBlack / alphaRatio);
imageWithAlpha.data[pixelIndex] = rOnAlpha;
imageWithAlpha.data[pixelIndex + 1] = gOnAlpha;
imageWithAlpha.data[pixelIndex + 2] = bOnAlpha;
imageWithAlpha.data[pixelIndex + 3] = alpha;
}
}
return [imageWithAlpha, numWarnings];
}
/**
* readImageDataFromUrl reads an image URL to ImageData, by drawing it on a
* canvas and reading ImageData back from it.
*/
async function readImageDataFromUrl(url) {
const image = new Image();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = url;
});
const canvas = document.createElement("canvas");
canvas.width = 600;
canvas.height = 600;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, 600, 600);
return ctx.getImageData(0, 0, 600, 600);
}
/**
* writeImageDataToUrl writes an ImageData to a data URL and Blob, by drawing
* it on a canvas and reading the URL and Blob back from it.
*/
async function writeImageDataToUrlAndBlob(imageData) {
const canvas = document.createElement("canvas");
canvas.width = 600;
canvas.height = 600;
const ctx = canvas.getContext("2d");
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, "image/png")
);
return [dataUrl, blob];
}
function resolveConflict(
[rOnBlack, gOnBlack, bOnBlack],
[rOnWhite, gOnWhite, bOnWhite],
conflictMode
) {
if (conflictMode === "onBlack") {
return [rOnBlack, gOnBlack, bOnBlack, 255];
} else if (conflictMode === "onWhite") {
return [rOnWhite, gOnWhite, bOnWhite, 255];
} else if (conflictMode === "transparent") {
return [0, 0, 0, 0];
} else if (conflictMode === "moreColorful") {
const sOnBlack = computeSaturation(rOnBlack, gOnBlack, bOnBlack);
const sOnWhite = computeSaturation(rOnWhite, gOnWhite, bOnWhite);
if (sOnBlack > sOnWhite) {
return [rOnBlack, gOnBlack, bOnBlack, 255];
} else {
return [rOnWhite, gOnWhite, bOnWhite, 255];
}
} else {
throw new Error(`unexpected conflict mode ${conflictMode}`);
}
}
/**
* Returns the given color's saturation, as a ratio from 0 to 1.
* Adapted from https://css-tricks.com/converting-color-spaces-in-javascript/
*/
function computeSaturation(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const cmin = Math.min(r, g, b);
const cmax = Math.max(r, g, b);
const delta = cmax - cmin;
const l = (cmax + cmin) / 2;
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
return s;
}
export default AppearanceLayerSupportUploadModal;

View file

@ -0,0 +1,99 @@
import * as React from "react";
import { ClassNames } from "@emotion/react";
import { Box, useColorModeValue, useDisclosure } from "@chakra-ui/react";
import { EditIcon } from "@chakra-ui/icons";
import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal";
import { OutfitLayers } from "../../components/OutfitPreview";
function ItemSupportAppearanceLayer({
item,
itemLayer,
biologyLayers,
outfitState,
}) {
const { isOpen, onOpen, onClose } = useDisclosure();
const iconButtonBgColor = useColorModeValue("green.100", "green.300");
const iconButtonColor = useColorModeValue("green.800", "gray.900");
return (
<ClassNames>
{({ css }) => (
<Box
as="button"
width="150px"
textAlign="center"
fontSize="xs"
onClick={onOpen}
>
<Box
width="150px"
height="150px"
marginBottom="1"
boxShadow="md"
borderRadius="md"
position="relative"
>
<OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} />
<Box
className={css`
opacity: 0;
transition: opacity 0.2s;
button:hover &,
button:focus & {
opacity: 1;
}
/* On touch devices, always show the icon, to clarify that this is
* an interactable object! (Whereas I expect other devices to
* discover things by exploratory hover or focus!) */
@media (hover: none) {
opacity: 1;
}
`}
background={iconButtonBgColor}
color={iconButtonColor}
borderRadius="full"
boxShadow="sm"
position="absolute"
bottom="2"
right="2"
padding="2"
alignItems="center"
justifyContent="center"
width="32px"
height="32px"
>
<EditIcon
boxSize="16px"
position="relative"
top="-2px"
right="-1px"
/>
</Box>
</Box>
<Box>
<Box as="span" fontWeight="700">
{itemLayer.zone.label}
</Box>{" "}
<Box as="span" fontWeight="600">
(Zone {itemLayer.zone.id})
</Box>
</Box>
<Box>Neopets ID: {itemLayer.remoteId}</Box>
<Box>DTI ID: {itemLayer.id}</Box>
<AppearanceLayerSupportModal
item={item}
layer={itemLayer}
outfitState={outfitState}
isOpen={isOpen}
onClose={onClose}
/>
</Box>
)}
</ClassNames>
);
}
export default ItemSupportAppearanceLayer;

View file

@ -0,0 +1,463 @@
import * as React from "react";
import gql from "graphql-tag";
import { useQuery, useMutation } from "@apollo/client";
import {
Badge,
Box,
Button,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
Flex,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
HStack,
Link,
Select,
Spinner,
Stack,
Text,
useBreakpointValue,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react";
import {
CheckCircleIcon,
ChevronRightIcon,
ExternalLinkIcon,
} from "@chakra-ui/icons";
import AllItemLayersSupportModal from "./AllItemLayersSupportModal";
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
import useOutfitAppearance from "../../components/useOutfitAppearance";
import { OutfitStateContext } from "../useOutfitState";
import useSupport from "./useSupport";
import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
/**
* ItemSupportDrawer shows Support UI for the item when open.
*
* This component controls the drawer element. The actual content is imported
* from another lazy-loaded component!
*/
function ItemSupportDrawer({ item, isOpen, onClose }) {
const placement = useBreakpointValue({ base: "bottom", lg: "right" });
return (
<Drawer
placement={placement}
size="md"
isOpen={isOpen}
onClose={onClose}
// blockScrollOnMount doesn't matter on our fullscreen UI, but the
// default implementation breaks out layout somehow 🤔 idk, let's not!
blockScrollOnMount={false}
>
<DrawerOverlay>
<DrawerContent
maxHeight={placement === "bottom" ? "90vh" : undefined}
overflow="auto"
>
<DrawerCloseButton />
<DrawerHeader>
{item.name}
<Badge colorScheme="pink" marginLeft="3">
Support <span aria-hidden="true">💖</span>
</Badge>
</DrawerHeader>
<DrawerBody paddingBottom="5">
<Metadata>
<MetadataLabel>Item ID:</MetadataLabel>
<MetadataValue>{item.id}</MetadataValue>
<MetadataLabel>Restricted zones:</MetadataLabel>
<MetadataValue>
<ItemSupportRestrictedZones item={item} />
</MetadataValue>
</Metadata>
<Stack spacing="8" marginTop="6">
<ItemSupportFields item={item} />
<ItemSupportAppearanceLayers item={item} />
</Stack>
</DrawerBody>
</DrawerContent>
</DrawerOverlay>
</Drawer>
);
}
function ItemSupportRestrictedZones({ item }) {
const { speciesId, colorId } = React.useContext(OutfitStateContext);
// NOTE: It would be a better reflection of the data to just query restricted
// zones right off the item... but we already have them in cache from
// the appearance, so query them that way to be instant in practice!
const { loading, error, data } = useQuery(
gql`
query ItemSupportRestrictedZones(
$itemId: ID!
$speciesId: ID!
$colorId: ID!
) {
item(id: $itemId) {
id
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
restrictedZones {
id
label
}
}
}
}
`,
{ variables: { itemId: item.id, speciesId, colorId } }
);
if (loading) {
return <Spinner size="xs" />;
}
if (error) {
return <Text color="red.400">{error.message}</Text>;
}
const restrictedZones = data?.item?.appearanceOn?.restrictedZones || [];
if (restrictedZones.length === 0) {
return "None";
}
return restrictedZones
.map((z) => `${z.label} (${z.id})`)
.sort()
.join(", ");
}
function ItemSupportFields({ item }) {
const { loading, error, data } = useQuery(
gql`
query ItemSupportFields($itemId: ID!) {
item(id: $itemId) {
id
manualSpecialColor {
id
}
explicitlyBodySpecific
}
}
`,
{
variables: { itemId: item.id },
// HACK: I think it's a bug in @apollo/client 3.1.1 that, if the
// optimistic response sets `manualSpecialColor` to null, the query
// doesn't update, even though its cache has updated :/
//
// This cheap trick of changing the display name every re-render
// persuades Apollo that this is a different query, so it re-checks
// its cache and finds the empty `manualSpecialColor`. Weird!
displayName: `ItemSupportFields-${new Date()}`,
}
);
const errorColor = useColorModeValue("red.500", "red.300");
return (
<>
{error && <Box color={errorColor}>{error.message}</Box>}
<ItemSupportSpecialColorFields
loading={loading}
error={error}
item={item}
manualSpecialColor={data?.item?.manualSpecialColor?.id}
/>
<ItemSupportPetCompatibilityRuleFields
loading={loading}
error={error}
item={item}
explicitlyBodySpecific={data?.item?.explicitlyBodySpecific}
/>
</>
);
}
function ItemSupportSpecialColorFields({
loading,
error,
item,
manualSpecialColor,
}) {
const { supportSecret } = useSupport();
const {
loading: colorsLoading,
error: colorsError,
data: colorsData,
} = useQuery(
gql`
query ItemSupportDrawerAllColors {
allColors {
id
name
isStandard
}
}
`
);
const [
mutate,
{ loading: mutationLoading, error: mutationError, data: mutationData },
] = useMutation(gql`
mutation ItemSupportDrawerSetManualSpecialColor(
$itemId: ID!
$colorId: ID
$supportSecret: String!
) {
setManualSpecialColor(
itemId: $itemId
colorId: $colorId
supportSecret: $supportSecret
) {
id
manualSpecialColor {
id
}
}
}
`);
const onChange = React.useCallback(
(e) => {
const colorId = e.target.value || null;
const color =
colorId != null ? { __typename: "Color", id: colorId } : null;
mutate({
variables: {
itemId: item.id,
colorId,
supportSecret,
},
optimisticResponse: {
__typename: "Mutation",
setManualSpecialColor: {
__typename: "Item",
id: item.id,
manualSpecialColor: color,
},
},
}).catch((e) => {
// Ignore errors from the promise, because we'll handle them on render!
});
},
[item.id, mutate, supportSecret]
);
const nonStandardColors =
colorsData?.allColors?.filter((c) => !c.isStandard) || [];
nonStandardColors.sort((a, b) => a.name.localeCompare(b.name));
const linkColor = useColorModeValue("green.500", "green.300");
return (
<FormControl isInvalid={Boolean(error || colorsError || mutationError)}>
<FormLabel>Special color</FormLabel>
<Select
placeholder={
loading || colorsLoading
? "Loading…"
: "Default: Auto-detect from item description"
}
value={manualSpecialColor?.id}
isDisabled={mutationLoading}
icon={
loading || colorsLoading || mutationLoading ? (
<Spinner />
) : mutationData ? (
<CheckCircleIcon />
) : undefined
}
onChange={onChange}
>
{nonStandardColors.map((color) => (
<option key={color.id} value={color.id}>
{color.name}
</option>
))}
</Select>
{colorsError && (
<FormErrorMessage>{colorsError.message}</FormErrorMessage>
)}
{mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
)}
{!colorsError && !mutationError && (
<FormHelperText>
This controls which previews we show on the{" "}
<Link
href={`https://impress.openneo.net/items/${
item.id
}-${item.name.replace(/ /g, "-")}`}
color={linkColor}
isExternal
>
classic item page <ExternalLinkIcon />
</Link>
.
</FormHelperText>
)}
</FormControl>
);
}
function ItemSupportPetCompatibilityRuleFields({
loading,
error,
item,
explicitlyBodySpecific,
}) {
const { supportSecret } = useSupport();
const [
mutate,
{ loading: mutationLoading, error: mutationError, data: mutationData },
] = useMutation(gql`
mutation ItemSupportDrawerSetItemExplicitlyBodySpecific(
$itemId: ID!
$explicitlyBodySpecific: Boolean!
$supportSecret: String!
) {
setItemExplicitlyBodySpecific(
itemId: $itemId
explicitlyBodySpecific: $explicitlyBodySpecific
supportSecret: $supportSecret
) {
id
explicitlyBodySpecific
}
}
`);
const onChange = React.useCallback(
(e) => {
const explicitlyBodySpecific = e.target.value === "true";
mutate({
variables: {
itemId: item.id,
explicitlyBodySpecific,
supportSecret,
},
optimisticResponse: {
__typename: "Mutation",
setItemExplicitlyBodySpecific: {
__typename: "Item",
id: item.id,
explicitlyBodySpecific,
},
},
}).catch((e) => {
// Ignore errors from the promise, because we'll handle them on render!
});
},
[item.id, mutate, supportSecret]
);
return (
<FormControl isInvalid={Boolean(error || mutationError)}>
<FormLabel>Pet compatibility rule</FormLabel>
<Select
value={explicitlyBodySpecific ? "true" : "false"}
isDisabled={mutationLoading}
icon={
loading || mutationLoading ? (
<Spinner />
) : mutationData ? (
<CheckCircleIcon />
) : undefined
}
onChange={onChange}
>
{loading ? (
<option>Loading</option>
) : (
<>
<option value="false">
Default: Auto-detect whether this fits all pets
</option>
<option value="true">
Body specific: Always different for each pet body
</option>
</>
)}
</Select>
{mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
)}
{!mutationError && (
<FormHelperText>
By default, we assume Background-y zones fit all pets the same. When
items don't follow that rule, we can override it.
</FormHelperText>
)}
</FormControl>
);
}
/**
* NOTE: This component takes `outfitState` from context, rather than as a prop
* from its parent, for performance reasons. We want `Item` to memoize
* and generally skip re-rendering on `outfitState` changes, and to make
* sure the context isn't accessed when the drawer is closed. So we use
* it here, only when the drawer is open!
*/
function ItemSupportAppearanceLayers({ item }) {
const outfitState = React.useContext(OutfitStateContext);
const { speciesId, colorId, pose, appearanceId } = outfitState;
const { error, visibleLayers } = useOutfitAppearance({
speciesId,
colorId,
pose,
appearanceId,
wornItemIds: [item.id],
});
const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
const itemLayers = visibleLayers.filter((l) => l.source === "item");
itemLayers.sort((a, b) => a.zone.depth - b.zone.depth);
const modalState = useDisclosure();
return (
<FormControl>
<Flex align="center">
<FormLabel>Appearance layers</FormLabel>
<Box width="4" flex="1 0 auto" />
<Button size="xs" onClick={modalState.onOpen}>
View on all pets <ChevronRightIcon />
</Button>
<AllItemLayersSupportModal
item={item}
isOpen