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={modalState.isOpen}
onClose={modalState.onClose}
/>
</Flex>
<HStack spacing="4" overflow="auto" paddingX="1">
{itemLayers.map((itemLayer) => (
<ItemSupportAppearanceLayer
key={itemLayer.id}
item={item}
itemLayer={itemLayer}
biologyLayers={biologyLayers}
outfitState={outfitState}
/>
))}
</HStack>
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
</FormControl>
);
}
export default ItemSupportDrawer;

View File

@ -0,0 +1,40 @@
import * as React from "react";
import { Box } from "@chakra-ui/react";
/**
* Metadata is a UI component for showing metadata about something, as labels
* and their values.
*/
function Metadata({ children, ...props }) {
return (
<Box
as="dl"
display="grid"
gridTemplateColumns="max-content auto"
gridRowGap="1"
gridColumnGap="2"
{...props}
>
{children}
</Box>
);
}
function MetadataLabel({ children, ...props }) {
return (
<Box as="dt" gridColumn="1" fontWeight="bold" {...props}>
{children}
</Box>
);
}
function MetadataValue({ children, ...props }) {
return (
<Box as="dd" gridColumn="2" {...props}>
{children}
</Box>
);
}
export default Metadata;
export { MetadataLabel, MetadataValue };

View File

@ -0,0 +1,582 @@
import React from "react";
import gql from "graphql-tag";
import { useMutation, useQuery } from "@apollo/client";
import {
Box,
Button,
IconButton,
Select,
Spinner,
Switch,
Wrap,
WrapItem,
useDisclosure,
UnorderedList,
ListItem,
} from "@chakra-ui/react";
import {
ArrowBackIcon,
ArrowForwardIcon,
CheckCircleIcon,
EditIcon,
} from "@chakra-ui/icons";
import HangerSpinner from "../../components/HangerSpinner";
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
import useSupport from "./useSupport";
import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal";
import { petAppearanceForPosePickerFragment } from "../PosePicker";
function PosePickerSupport({
speciesId,
colorId,
pose,
appearanceId,
initialFocusRef,
dispatchToOutfit,
}) {
const { loading, error, data } = useQuery(
gql`
query PosePickerSupport($speciesId: ID!, $colorId: ID!) {
petAppearances(speciesId: $speciesId, colorId: $colorId) {
id
pose
isGlitched
layers {
id
zone {
id
label @client
}
# For AppearanceLayerSupportModal
remoteId
bodyId
swfUrl
svgUrl
imageUrl: imageUrlV2(idealSize: SIZE_600)
canvasMovieLibraryUrl
}
restrictedZones {
id
label @client
}
# For AppearanceLayerSupportModal to know the name
species {
id
name
}
color {
id
name
}
# Also, anything the PosePicker wants that isn't here, so that we
# don't have to refetch anything when we change the canonical poses.
...PetAppearanceForPosePicker
}
...CanonicalPetAppearances
}
${canonicalPetAppearancesFragment}
${petAppearanceForPosePickerFragment}
`,
{ variables: { speciesId, colorId } }
);
// Resize the Popover when we toggle loading state, because it probably will
// affect the content size. appearanceId might also affect content size, if
// it occupies different zones.
//
// NOTE: This also triggers an additional necessary resize when the component
// first mounts, because PosePicker lazy-loads it, so it actually
// mounting affects size too.
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"));
}, [loading, appearanceId]);
const canonicalAppearanceIdsByPose = {
HAPPY_MASC: data?.happyMasc?.id,
SAD_MASC: data?.sadMasc?.id,
SICK_MASC: data?.sickMasc?.id,
HAPPY_FEM: data?.happyFem?.id,
SAD_FEM: data?.sadFem?.id,
SICK_FEM: data?.sickFem?.id,
UNCONVERTED: data?.unconverted?.id,
UNKNOWN: data?.unknown?.id,
};
const canonicalAppearanceIds = Object.values(
canonicalAppearanceIdsByPose
).filter((id) => id);
const providedAppearanceId = appearanceId;
if (!providedAppearanceId) {
appearanceId = canonicalAppearanceIdsByPose[pose];
}
// If you don't already have `appearanceId` in the outfit state, opening up
// PosePickerSupport adds it! That way, if you make changes that affect the
// canonical poses, we'll still stay navigated to this one.
React.useEffect(() => {
if (!providedAppearanceId && appearanceId) {
dispatchToOutfit({
type: "setPose",
pose,
appearanceId,
});
}
}, [providedAppearanceId, appearanceId, pose, dispatchToOutfit]);
if (loading) {
return (
<Box display="flex" justifyContent="center">
<HangerSpinner size="sm" />
</Box>
);
}
if (error) {
return (
<Box color="red.400" marginTop="8">
{error.message}
</Box>
);
}
const currentPetAppearance = data.petAppearances.find(
(pa) => pa.id === appearanceId
);
if (!currentPetAppearance) {
return (
<Box color="red.400" marginTop="8">
Pet appearance with ID {JSON.stringify(appearanceId)} not found
</Box>
);
}
return (
<Box>
<PosePickerSupportNavigator
petAppearances={data.petAppearances}
currentPetAppearance={currentPetAppearance}
canonicalAppearanceIds={canonicalAppearanceIds}
dropdownRef={initialFocusRef}
dispatchToOutfit={dispatchToOutfit}
/>
<Metadata
fontSize="sm"
// Build a new copy of this tree when the appearance changes, to reset
// things like element focus and mutation state!
key={currentPetAppearance.id}
>
<MetadataLabel>DTI ID:</MetadataLabel>
<MetadataValue>{appearanceId}</MetadataValue>
<MetadataLabel>Pose:</MetadataLabel>
<MetadataValue>
<PosePickerSupportPoseFields
petAppearance={currentPetAppearance}
speciesId={speciesId}
colorId={colorId}
/>
</MetadataValue>
<MetadataLabel>Layers:</MetadataLabel>
<MetadataValue>
<Wrap spacing="1">
{currentPetAppearance.layers
.map((layer) => [`${layer.zone.label} (${layer.zone.id})`, layer])
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([text, layer]) => (
<WrapItem key={layer.id}>
<PetLayerSupportLink
outfitState={{ speciesId, colorId, pose }}
petAppearance={currentPetAppearance}
layer={layer}
>
{text}
<EditIcon marginLeft="1" />
</PetLayerSupportLink>
</WrapItem>
))}
</Wrap>
</MetadataValue>
<MetadataLabel>Restricts:</MetadataLabel>
<MetadataValue maxHeight="64" overflowY="auto">
{currentPetAppearance.restrictedZones.length > 0 ? (
<UnorderedList>
{currentPetAppearance.restrictedZones
.map((zone) => `${zone.label} (${zone.id})`)
.sort((a, b) => a[0].localeCompare(b[0]))
.map((zoneText) => (
<ListItem key={zoneText}>{zoneText}</ListItem>
))}
</UnorderedList>
) : (
<Box fontStyle="italic" opacity="0.8">
None
</Box>
)}
</MetadataValue>
</Metadata>
</Box>
);
}
function PetLayerSupportLink({ outfitState, petAppearance, layer, children }) {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<Button size="xs" onClick={onOpen}>
{children}
</Button>
<AppearanceLayerSupportModal
outfitState={outfitState}
petAppearance={petAppearance}
layer={layer}
isOpen={isOpen}
onClose={onClose}
/>
</>
);
}
function PosePickerSupportNavigator({
petAppearances,
currentPetAppearance,
canonicalAppearanceIds,
dropdownRef,
dispatchToOutfit,
}) {
const currentIndex = petAppearances.indexOf(currentPetAppearance);
const prevPetAppearance = petAppearances[currentIndex - 1];
const nextPetAppearance = petAppearances[currentIndex + 1];
return (
<Box
display="flex"
justifyContent="flex-end"
marginBottom="4"
// Space for the position-absolute PosePicker mode switcher
paddingLeft="12"
>
<IconButton
aria-label="Go to previous appearance"
icon={<ArrowBackIcon />}
size="sm"
marginRight="2"
isDisabled={prevPetAppearance == null}
onClick={() =>
dispatchToOutfit({
type: "setPose",
pose: prevPetAppearance.pose,
appearanceId: prevPetAppearance.id,
})
}
/>
<Select
size="sm"
width="auto"
value={currentPetAppearance.id}
ref={dropdownRef}
onChange={(e) => {
const id = e.target.value;
const petAppearance = petAppearances.find((pa) => pa.id === id);
dispatchToOutfit({
type: "setPose",
pose: petAppearance.pose,
appearanceId: petAppearance.id,
});
}}
>
{petAppearances.map((pa) => (
<option key={pa.id} value={pa.id}>
{POSE_NAMES[pa.pose]}{" "}
{canonicalAppearanceIds.includes(pa.id) && "⭐️"}
{pa.isGlitched && "👾"} ({pa.id})
</option>
))}
</Select>
<IconButton
aria-label="Go to next appearance"
icon={<ArrowForwardIcon />}
size="sm"
marginLeft="2"
isDisabled={nextPetAppearance == null}
onClick={() =>
dispatchToOutfit({
type: "setPose",
pose: nextPetAppearance.pose,
appearanceId: nextPetAppearance.id,
})
}
/>
</Box>
);
}
function PosePickerSupportPoseFields({ petAppearance, speciesId, colorId }) {
const { supportSecret } = useSupport();
const [mutatePose, poseMutation] = useMutation(
gql`
mutation PosePickerSupportSetPetAppearancePose(
$appearanceId: ID!
$pose: Pose!
$supportSecret: String!
) {
setPetAppearancePose(
appearanceId: $appearanceId
pose: $pose
supportSecret: $supportSecret
) {
id
pose
}
}
`,
{
refetchQueries: [
{
query: gql`
query PosePickerSupportRefetchCanonicalAppearances(
$speciesId: ID!
$colorId: ID!
) {
...CanonicalPetAppearances
}
${canonicalPetAppearancesFragment}
`,
variables: { speciesId, colorId },
},
],
}
);
const [mutateIsGlitched, isGlitchedMutation] = useMutation(
gql`
mutation PosePickerSupportSetPetAppearanceIsGlitched(
$appearanceId: ID!
$isGlitched: Boolean!
$supportSecret: String!
) {
setPetAppearanceIsGlitched(
appearanceId: $appearanceId
isGlitched: $isGlitched
supportSecret: $supportSecret
) {
id
isGlitched
}
}
`,
{
refetchQueries: [
{
query: gql`
query PosePickerSupportRefetchCanonicalAppearances(
$speciesId: ID!
$colorId: ID!
) {
...CanonicalPetAppearances
}
${canonicalPetAppearancesFragment}
`,
variables: { speciesId, colorId },
},
],
}
);
return (
<Box>
<Box display="flex" flexDirection="row" alignItems="center">
<Select
size="sm"
value={petAppearance.pose}
flex="0 1 200px"
icon={
poseMutation.loading ? (
<Spinner />
) : poseMutation.data ? (
<CheckCircleIcon />
) : undefined
}
onChange={(e) => {
const pose = e.target.value;
mutatePose({
variables: {
appearanceId: petAppearance.id,
pose,
supportSecret,
},
optimisticResponse: {
__typename: "Mutation",
setPetAppearancePose: {
__typename: "PetAppearance",
id: petAppearance.id,
pose,
},
},
}).catch((e) => {
/* Discard errors here; we'll show them in the UI! */
});
}}
isInvalid={poseMutation.error != null}
>
{Object.entries(POSE_NAMES).map(([pose, name]) => (
<option key={pose} value={pose}>
{name}
</option>
))}
</Select>
<Select
size="sm"
marginLeft="2"
flex="0 1 150px"
value={petAppearance.isGlitched}
icon={
isGlitchedMutation.loading ? (
<Spinner />
) : isGlitchedMutation.data ? (
<CheckCircleIcon />
) : undefined
}
onChange={(e) => {
const isGlitched = e.target.value === "true";
mutateIsGlitched({
variables: {
appearanceId: petAppearance.id,
isGlitched,
supportSecret,
},
optimisticResponse: {
__typename: "Mutation",
setPetAppearanceIsGlitched: {
__typename: "PetAppearance",
id: petAppearance.id,
isGlitched,
},
},
}).catch((e) => {
/* Discard errors here; we'll show them in the UI! */
});
}}
isInvalid={isGlitchedMutation.error != null}
>
<option value="false">Valid</option>
<option value="true">Glitched</option>
</Select>
</Box>
{poseMutation.error && (
<Box color="red.400">{poseMutation.error.message}</Box>
)}
{isGlitchedMutation.error && (
<Box color="red.400">{isGlitchedMutation.error.message}</Box>
)}
</Box>
);
}
export function PosePickerSupportSwitch({ isChecked, onChange }) {
return (
<Box as="label" display="flex" flexDirection="row" alignItems="center">
<Box fontSize="sm">
<span role="img" aria-label="Support">
💖
</span>
</Box>
<Switch
colorScheme="pink"
marginLeft="1"
size="sm"
isChecked={isChecked}
onChange={onChange}
/>
</Box>
);
}
const POSE_NAMES = {
HAPPY_MASC: "Happy Masc",
HAPPY_FEM: "Happy Fem",
SAD_MASC: "Sad Masc",
SAD_FEM: "Sad Fem",
SICK_MASC: "Sick Masc",
SICK_FEM: "Sick Fem",
UNCONVERTED: "Unconverted",
UNKNOWN: "Unknown",
};
const canonicalPetAppearancesFragment = gql`
fragment CanonicalPetAppearances on Query {
happyMasc: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: HAPPY_MASC
) {
id
}
sadMasc: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: SAD_MASC
) {
id
}
sickMasc: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: SICK_MASC
) {
id
}
happyFem: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: HAPPY_FEM
) {
id
}
sadFem: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: SAD_FEM
) {
id
}
sickFem: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: SICK_FEM
) {
id
}
unconverted: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: UNCONVERTED
) {
id
}
unknown: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: UNKNOWN
) {
id
}
}
`;
export default PosePickerSupport;

View File

@ -0,0 +1,19 @@
import useSupport from "./useSupport";
/**
* SupportOnly only shows its contents to Support users. For most users, the
* content will be hidden!
*
* To become a Support user, you visit /?supportSecret=..., which saves the
* secret to your device.
*
* Note that this component doesn't check that the secret is *correct*, so it's
* possible to view this UI by faking an invalid secret. That's okay, because
* the server checks the provided secret for each Support request.
*/
function SupportOnly({ children }) {
const { isSupportUser } = useSupport();
return isSupportUser ? children : null;
}
export default SupportOnly;

View File

@ -0,0 +1,32 @@
import * as React from "react";
/**
* useSupport returns the Support secret that the server requires for Support
* actions... if the user has it set. For most users, this returns nothing!
*
* Specifically, we return an object of:
* - isSupportUser: true iff the `supportSecret` is set
* - supportSecret: the secret saved to this device, or null if not set
*
* To become a Support user, you visit /?supportSecret=..., which saves the
* secret to your device.
*
* Note that this hook doesn't check that the secret is *correct*, so it's
* possible that it will return an invalid secret. That's okay, because
* the server checks the provided secret for each Support request.
*/
function useSupport() {
const supportSecret = React.useMemo(
() =>
typeof localStorage !== "undefined"
? localStorage.getItem("supportSecret")
: null,
[]
);
const isSupportUser = supportSecret != null;
return { isSupportUser, supportSecret };
}
export default useSupport;

View File

@ -0,0 +1,249 @@
import React from "react";
import { useToast } from "@chakra-ui/react";
import { useRouter } from "next/router";
import { useDebounce } from "../util";
import useCurrentUser from "../components/useCurrentUser";
import gql from "graphql-tag";
import { useMutation } from "@apollo/client";
import { outfitStatesAreEqual } from "./useOutfitState";
function useOutfitSaving(outfitState, dispatchToOutfit) {
const { isLoggedIn, id: currentUserId } = useCurrentUser();
const { pathname, push: pushHistory } = useRouter();
const toast = useToast();
// There's not a way to reset an Apollo mutation state to clear out the error
// when the outfit changes… so we track the error state ourselves!
const [saveError, setSaveError] = React.useState(null);
// Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
// to the server.
const isNewOutfit = outfitState.id == null;
// Whether this outfit's latest local changes have been saved to the server.
// And log it to the console!
const latestVersionIsSaved =
outfitState.savedOutfitState &&
outfitStatesAreEqual(
outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState
);
React.useEffect(() => {
console.debug(
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
latestVersionIsSaved,
outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState
);
}, [
latestVersionIsSaved,
outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState,
]);
// Only logged-in users can save outfits - and they can only save new outfits,
// or outfits they created.
const canSaveOutfit =
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
// Users can delete their own outfits too. The logic is slightly different
// than for saving, because you can save an outfit that hasn't been saved
// yet, but you can't delete it.
const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation(
gql`
mutation UseOutfitSaving_SaveOutfit(
$id: ID # Optional, is null when saving new outfits.
$name: String # Optional, server may fill in a placeholder.
$speciesId: ID!
$colorId: ID!
$pose: Pose!
$wornItemIds: [ID!]!
$closetedItemIds: [ID!]!
) {
outfit: saveOutfit(
id: $id
name: $name
speciesId: $speciesId
colorId: $colorId
pose: $pose
wornItemIds: $wornItemIds
closetedItemIds: $closetedItemIds
) {
id
name
petAppearance {
id
species {
id
}
color {
id
}
pose
}
wornItems {
id
}
closetedItems {
id
}
creator {
id
}
}
}
`,
{
context: { sendAuth: true },
update: (cache, { data: { outfit } }) => {
// After save, add this outfit to the current user's outfit list. This
// will help when navigating back to Your Outfits, to force a refresh.
// https://www.apollographql.com/docs/react/caching/cache-interaction/#example-updating-the-cache-after-a-mutation
cache.modify({
id: cache.identify(outfit.creator),
fields: {
outfits: (existingOutfitRefs = [], { readField }) => {
const isAlreadyInList = existingOutfitRefs.some(
(ref) => readField("id", ref) === outfit.id
);
if (isAlreadyInList) {
return existingOutfitRefs;
}
const newOutfitRef = cache.writeFragment({
data: outfit,
fragment: gql`
fragment NewOutfit on Outfit {
id
}
`,
});
return [...existingOutfitRefs, newOutfitRef];
},
},
});
// Also, send a `rename` action, if this is still the current outfit,
// and the server renamed it (e.g. "Untitled outfit (1)"). (It's
// tempting to do a full reset, in case the server knows something we
// don't, but we don't want to clobber changes the user made since
// starting the save!)
if (outfit.id === outfitState.id && outfit.name !== outfitState.name) {
dispatchToOutfit({
type: "rename",
outfitName: outfit.name,
});
}
},
}
);
const saveOutfitFromProvidedState = React.useCallback(
(outfitState) => {
sendSaveOutfitMutation({
variables: {
id: outfitState.id,
name: outfitState.name,
speciesId: outfitState.speciesId,
colorId: outfitState.colorId,
pose: outfitState.pose,
wornItemIds: [...outfitState.wornItemIds],
closetedItemIds: [...outfitState.closetedItemIds],
},
})
.then(({ data: { outfit } }) => {
// Navigate to the new saved outfit URL. Our Apollo cache should pick
// up the data from this mutation response, and combine it with the
// existing cached data, to make this smooth without any loading UI.
if (pathname !== `/outfits/[outfitId]`) {
pushHistory(`/outfits/${outfit.id}`);
}
})
.catch((e) => {
console.error(e);
setSaveError(e);
toast({
status: "error",
title: "Sorry, there was an error saving this outfit!",
description: "Maybe check your connection and try again.",
});
});
},
// It's important that this callback _doesn't_ change when the outfit
// changes, so that the auto-save effect is only responding to the
// debounced state!
[sendSaveOutfitMutation, pathname, pushHistory, toast]
);
const saveOutfit = React.useCallback(
() => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
[saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras]
);
// Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
// which only contains the basic fields, and will keep a stable object
// identity until actual changes occur. Then, save the outfit after the user
// has left it alone for long enough, so long as it's actually different
// than the saved state.
const debouncedOutfitState = useDebounce(
outfitState.outfitStateWithoutExtras,
2000,
{
// When the outfit ID changes, update the debounced state immediately!
forceReset: (debouncedOutfitState, newOutfitState) =>
debouncedOutfitState.id !== newOutfitState.id,
}
);
// HACK: This prevents us from auto-saving the outfit state that's still
// loading. I worry that this might not catch other loading scenarios
// though, like if the species/color/pose is in the GQL cache, but the
// items are still loading in... not sure where this would happen tho!
const debouncedOutfitStateIsSaveable =
debouncedOutfitState.speciesId &&
debouncedOutfitState.colorId &&
debouncedOutfitState.pose;
React.useEffect(() => {
if (
!isNewOutfit &&
canSaveOutfit &&
debouncedOutfitStateIsSaveable &&
!outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
) {
console.info(
"[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
outfitState.savedOutfitState,
debouncedOutfitState
);
saveOutfitFromProvidedState(debouncedOutfitState);
}
}, [
isNewOutfit,
canSaveOutfit,
debouncedOutfitState,
debouncedOutfitStateIsSaveable,
outfitState.savedOutfitState,
saveOutfitFromProvidedState,
]);
// When the outfit changes, clear out the error state from previous saves.
// We'll send the mutation again after the debounce, and we don't want to
// show the error UI in the meantime!
React.useEffect(() => {
setSaveError(null);
}, [outfitState.outfitStateWithoutExtras]);
return {
canSaveOutfit,
canDeleteOutfit,
isNewOutfit,
isSaving,
latestVersionIsSaved,
saveError,
saveOutfit,
};
}
export default useOutfitSaving;

View File

@ -0,0 +1,708 @@
import React from "react";
import gql from "graphql-tag";
import produce, { enableMapSet } from "immer";
import { useQuery, useApolloClient } from "@apollo/client";
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
import { useRouter } from "next/router";
enableMapSet();
export const OutfitStateContext = React.createContext(null);
function useOutfitState() {
const apolloClient = useApolloClient();
const urlOutfitState = useParseOutfitUrl();
const [localOutfitState, dispatchToOutfit] = React.useReducer(
outfitStateReducer(apolloClient),
urlOutfitState
);
// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
// about the outfit. We'll use it to initialize the local state.
const {
loading: outfitLoading,
error: outfitError,
data: outfitData,
} = useQuery(
gql`
query OutfitStateSavedOutfit($id: ID!) {
outfit(id: $id) {
id
name
updatedAt
creator {
id
}
petAppearance {
id
species {
id
}
color {
id
}
pose
}
wornItems {
id
}
closetedItems {
id
}
# TODO: Consider pre-loading some fields, instead of doing them in
# follow-up queries?
}
}
`,
{
variables: { id: urlOutfitState.id },
skip: urlOutfitState.id == null,
returnPartialData: true,
onCompleted: (outfitData) => {
dispatchToOutfit({
type: "resetToSavedOutfitData",
savedOutfitData: outfitData.outfit,
});
},
}
);
const creator = outfitData?.outfit?.creator;
const updatedAt = outfitData?.outfit?.updatedAt;
// We memoize this to make `outfitStateWithoutExtras` an even more reliable
// stable object!
const savedOutfitState = React.useMemo(
() => getOutfitStateFromOutfitData(outfitData?.outfit),
[outfitData?.outfit]
);
// Choose which customization state to use. We want it to match the outfit in
// the URL immediately, without having to wait for any effects, to avoid race
// conditions!
//
// The reducer is generally the main source of truth for live changes!
//
// But if:
// - it's not initialized yet (e.g. the first frame of navigating to an
// outfit from Your Outfits), or
// - it's for a different outfit than the URL says (e.g. clicking Back
// or Forward to switch between saved outfits),
//
// Then use saved outfit data or the URL query string instead, because that's
// a better representation of the outfit in the URL. (If the saved outfit
// data isn't loaded yet, then this will be a customization state with
// partial data, and that's okay.)
let outfitState;
if (
urlOutfitState.id === localOutfitState.id &&
localOutfitState.speciesId != null &&
localOutfitState.colorId != null
) {
// Use the reducer state: they're both for the same saved outfit, or both
// for an unsaved outfit (null === null). But we don't use it when it's
// *only* got the ID, and no other fields yet.
console.debug("[useOutfitState] Choosing local outfit state");
outfitState = localOutfitState;
} else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
// Use the saved outfit state: it's for the saved outfit the URL points to.
console.debug("[useOutfitState] Choosing saved outfit state");
outfitState = savedOutfitState;
} else {
// Use the URL state: it's more up-to-date than any of the others. (Worst
// case, it's empty except for ID, which is fine while the saved outfit
// data loads!)
console.debug("[useOutfitState] Choosing URL outfit state");
outfitState = urlOutfitState;
}
// When unpacking the customization state, we call `Array.from` on our item
// IDs. It's more convenient to manage them as a Set in state, but most
// callers will find it more convenient to access them as arrays! e.g. for
// `.map()`.
const { id, name, speciesId, colorId, pose, appearanceId } = outfitState;
const wornItemIds = Array.from(outfitState.wornItemIds);
const closetedItemIds = Array.from(outfitState.closetedItemIds);
const allItemIds = [...wornItemIds, ...closetedItemIds];
const {
loading: itemsLoading,
error: itemsError,
data: itemsData,
} = useQuery(
gql`
query OutfitStateItems(
$allItemIds: [ID!]!
$speciesId: ID!
$colorId: ID!
) {
items(ids: $allItemIds) {
# TODO: De-dupe this from SearchPanel?
id
name
thumbnailUrl
isNc
isPb
currentUserOwnsThis
currentUserWantsThis
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
# This enables us to quickly show the item when the user clicks it!
...ItemAppearanceForOutfitPreview
# This is used to group items by zone, and to detect conflicts when
# wearing a new item.
layers {
zone {
id
label @client
}
}
restrictedZones {
id
label @client
isCommonlyUsedByItems @client
}
}
}
# NOTE: We skip this query if items is empty for perf reasons. If
# you're adding more fields, consider changing that condition!
}
${itemAppearanceFragment}
`,
{
variables: { allItemIds, speciesId, colorId },
context: { sendAuth: true },
// Skip if this outfit has no items, as an optimization; or if we don't
// have the species/color ID loaded yet because we're waiting on the
// saved outfit to load.
skip: allItemIds.length === 0 || speciesId == null || colorId == null,
}
);
const resultItems = itemsData?.items || [];
// Okay, time for some big perf hacks! Lower down in the app, we use
// React.memo to avoid re-rendering Item components if the items haven't
// updated. In simpler cases, we just make the component take the individual
// item fields as props... but items are complex and that makes it annoying
// :p Instead, we do these tricks to reuse physical item objects if they're
// still deep-equal to the previous version. This is because React.memo uses
// object identity to compare its props, so now when it checks whether
// `oldItem === newItem`, the answer will be `true`, unless the item really
// _did_ change!
const [cachedItemObjects, setCachedItemObjects] = React.useState([]);
let items = resultItems.map((item) => {
const cachedItemObject = cachedItemObjects.find((i) => i.id === item.id);
if (
cachedItemObject &&
JSON.stringify(cachedItemObject) === JSON.stringify(item)
) {
return cachedItemObject;
}
return item;
});
if (
items.length === cachedItemObjects.length &&
items.every((_, index) => items[index] === cachedItemObjects[index])
) {
// Even reuse the entire array if none of the items changed!
items = cachedItemObjects;
}
React.useEffect(() => {
setCachedItemObjects(items);
}, [items, setCachedItemObjects]);
const itemsById = {};
for (const item of items) {
itemsById[item.id] = item;
}
const zonesAndItems = getZonesAndItems(
itemsById,
wornItemIds,
closetedItemIds
);
const incompatibleItems = items
.filter((i) => i.appearanceOn.layers.length === 0)
.sort((a, b) => a.name.localeCompare(b.name));
const url = buildOutfitUrl(outfitState);
const outfitStateWithExtras = {
id,
creator,
updatedAt,
zonesAndItems,
incompatibleItems,
name,
wornItemIds,
closetedItemIds,
allItemIds,
speciesId,
colorId,
pose,
appearanceId,
url,
// We use this plain outfit state objects in `useOutfitSaving`! Unlike the
// full `outfitState` object, which we rebuild each render,
// `outfitStateWithoutExtras` will mostly only change when there is an
// actual change to outfit state.
outfitStateWithoutExtras: outfitState,
savedOutfitState,
};
// Keep the URL up-to-date. (We don't listen to it, though 😅)
// TODO: Seems like we should hook this in with the actual router... I'm
// avoiding it rn, but I'm worried Next.js won't necessarily play nice
// with this hack, even though react-router did. Hard to predict!
React.useEffect(() => {
if (typeof history !== "undefined") {
history.replaceState(null, "", url);
}
}, [url]);
return {
loading: outfitLoading || itemsLoading,
error: outfitError || itemsError,
outfitState: outfitStateWithExtras,
dispatchToOutfit,
};
}
const outfitStateReducer = (apolloClient) => (baseState, action) => {
console.info("[useOutfitState] Action:", action);
switch (action.type) {
case "rename":
return produce(baseState, (state) => {
state.name = action.outfitName;
});
case "setSpeciesAndColor":
return produce(baseState, (state) => {
state.speciesId = action.speciesId;
state.colorId = action.colorId;
state.pose = action.pose;
state.appearanceId = null;
});
case "wearItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds } = state;
const { itemId, itemIdsToReconsider = [] } = action;
// Move conflicting items to the closet.
//
// We do this by looking them up in the Apollo Cache, which is going to
// include the relevant item data because the `useOutfitState` hook
// queries for it!
//
// (It could be possible to mess up the timing by taking an action
// while worn items are still partially loading, but I think it would
// require a pretty weird action sequence to make that happen... like,
// doing a search and it loads before the worn item data does? Anyway,
// Apollo will throw in that case, which should just essentially reject
// the action.)
let conflictingIds;
try {
conflictingIds = findItemConflicts(itemId, state, apolloClient);
} catch (e) {
console.error(e);
return;
}
for (const conflictingId of conflictingIds) {
wornItemIds.delete(conflictingId);
closetedItemIds.add(conflictingId);
}
// Move this item from the closet to the worn set.
closetedItemIds.delete(itemId);
wornItemIds.add(itemId);
reconsiderItems(itemIdsToReconsider, state, apolloClient);
});
case "unwearItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds } = state;
const { itemId, itemIdsToReconsider = [] } = action;
// Move this item from the worn set to the closet.
wornItemIds.delete(itemId);
closetedItemIds.add(itemId);
reconsiderItems(
// Don't include the unworn item in items to reconsider!
itemIdsToReconsider.filter((x) => x !== itemId),
state,
apolloClient
);
});
case "removeItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds } = state;
const { itemId, itemIdsToReconsider = [] } = action;
// Remove this item from both the worn set and the closet.
wornItemIds.delete(itemId);
closetedItemIds.delete(itemId);
reconsiderItems(
// Don't include the removed item in items to reconsider!
itemIdsToReconsider.filter((x) => x !== itemId),
state,
apolloClient
);
});
case "setPose":
return produce(baseState, (state) => {
state.pose = action.pose;
// Usually only the `pose` is specified, but `PosePickerSupport` can
// also specify a corresponding `appearanceId`, to get even more
// particular about which version of the pose to show if more than one.
state.appearanceId = action.appearanceId || null;
});
case "resetToSavedOutfitData":
return getOutfitStateFromOutfitData(action.savedOutfitData);
default:
throw new Error(`unexpected action ${JSON.stringify(action)}`);
}
};
const EMPTY_CUSTOMIZATION_STATE = {
id: null,
name: null,
speciesId: null,
colorId: null,
pose: null,
appearanceId: null,
wornItemIds: [],
closetedItemIds: [],
};
function useParseOutfitUrl() {
const { query } = useRouter();
// We memoize this to make `outfitStateWithoutExtras` an even more reliable
// stable object!
const memoizedOutfitState = React.useMemo(
() => readOutfitStateFromQuery(query),
[query]
);
return memoizedOutfitState;
}
export function readOutfitStateFromQuery(query) {
// For the /outfits/:id page, ignore the query string, and just wait for the
// outfit data to load in!
if (query.outfitId != null) {
return {
...EMPTY_CUSTOMIZATION_STATE,
id: query.outfitId,
};
}
// Otherwise, parse the query string, and fill in default values for anything
// not specified.
return {
id: null,
name: getValueFromQuery(query.name),
speciesId: getValueFromQuery(query.species) || "1",
colorId: getValueFromQuery(query.color) || "8",
pose: getValueFromQuery(query.pose) || "HAPPY_FEM",
appearanceId: getValueFromQuery(query.state) || null,
wornItemIds: new Set(getListFromQuery(query["objects[]"])),
closetedItemIds: new Set(getListFromQuery(query["closet[]"])),
};
}
/**
* getValueFromQuery reads the given value from Next's `router.query` as a
* single value. For example:
*
* ?foo=bar -> "bar" -> "bar"
* ?foo=bar&foo=baz -> ["bar", "baz"] -> "bar"
* ?lol=huh -> undefined -> null
*/
function getValueFromQuery(value) {
if (Array.isArray(value)) {
return value[0];
} else if (value != null) {
return value;
} else {
return null;
}
}
/**
* getListFromQuery reads the given value from Next's `router.query` as a list
* of values. For example:
*
* ?foo=bar -> "bar" -> ["bar"]
* ?foo=bar&foo=baz -> ["bar", "baz"] -> ["bar", "baz"]
* ?lol=huh -> undefined -> []
*/
function getListFromQuery(value) {
if (Array.isArray(value)) {
return value;
} else if (value != null) {
return [value];
} else {
return [];
}
}
function getOutfitStateFromOutfitData(outfit) {
if (!outfit) {
return EMPTY_CUSTOMIZATION_STATE;
}
return {
id: outfit.id,
name: outfit.name,
// Note that these fields are intentionally null if loading, rather than
// falling back to a default appearance like Blue Acara.
speciesId: outfit.petAppearance?.species?.id,
colorId: outfit.petAppearance?.color?.id,
pose: outfit.petAppearance?.pose,
// Whereas the items are more convenient to just leave as empty lists!
wornItemIds: new Set((outfit.wornItems || []).map((item) => item.id)),
closetedItemIds: new Set(
(outfit.closetedItems || []).map((item) => item.id)
),
};
}
function findItemConflicts(itemIdToAdd, state, apolloClient) {
const { wornItemIds, speciesId, colorId } = state;
const { items } = apolloClient.readQuery({
query: gql`
query OutfitStateItemConflicts(
$itemIds: [ID!]!
$speciesId: ID!
$colorId: ID!
) {
items(ids: $itemIds) {
id
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
layers {
zone {
id
}
}
restrictedZones {
id
}
}
}
}
`,
variables: {
itemIds: [itemIdToAdd, ...wornItemIds],
speciesId,
colorId,
},
});
const itemToAdd = items.find((i) => i.id === itemIdToAdd);
if (!itemToAdd.appearanceOn) {
return [];
}
const wornItems = Array.from(wornItemIds).map((id) =>
items.find((i) => i.id === id)
);
const itemToAddZoneSets = getItemZones(itemToAdd);
const conflictingIds = [];
for (const wornItem of wornItems) {
if (!wornItem.appearanceOn) {
continue;
}
const wornItemZoneSets = getItemZones(wornItem);
const itemsConflict =
setsIntersect(
itemToAddZoneSets.occupies,
wornItemZoneSets.occupiesOrRestricts
) ||
setsIntersect(
wornItemZoneSets.occupies,
itemToAddZoneSets.occupiesOrRestricts
);
if (itemsConflict) {
conflictingIds.push(wornItem.id);
}
}
return conflictingIds;
}
function getItemZones(item) {
const occupies = new Set(item.appearanceOn.layers.map((l) => l.zone.id));
const restricts = new Set(item.appearanceOn.restrictedZones.map((z) => z.id));
const occupiesOrRestricts = new Set([...occupies, ...restricts]);
return { occupies, occupiesOrRestricts };
}
function setsIntersect(a, b) {
for (const el of a) {
if (b.has(el)) {
return true;
}
}
return false;
}
/**
* Try to add these items back to the outfit, if there would be no conflicts.
* We use this in Search 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!
*
* This mutates state.wornItemIds directly, on the assumption that we're in an
* immer block, in which case mutation is the simplest API!
*/
function reconsiderItems(itemIdsToReconsider, state, apolloClient) {
for (const itemIdToReconsider of itemIdsToReconsider) {
const conflictingIds = findItemConflicts(
itemIdToReconsider,
state,
apolloClient
);
if (conflictingIds.length === 0) {
state.wornItemIds.add(itemIdToReconsider);
}
}
}
// TODO: Get this out of here, tbh...
function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
const wornItems = wornItemIds.map((id) => itemsById[id]).filter((i) => i);
const closetedItems = closetedItemIds
.map((id) => itemsById[id])
.filter((i) => i);
// We use zone label here, rather than ID, because some zones have the same
// label and we *want* to over-simplify that in this UI. (e.g. there are
// multiple Hat zones, and some items occupy different ones, but mostly let's
// just group them and if they don't conflict then all the better!)
const allItems = [...wornItems, ...closetedItems];
const itemsByZoneLabel = new Map();
for (const item of allItems) {
if (!item.appearanceOn) {
continue;
}
for (const layer of item.appearanceOn.layers) {
const zoneLabel = layer.zone.label;
if (!itemsByZoneLabel.has(zoneLabel)) {
itemsByZoneLabel.set(zoneLabel, []);
}
itemsByZoneLabel.get(zoneLabel).push(item);
}
}
let zonesAndItems = Array.from(itemsByZoneLabel.entries()).map(
([zoneLabel, items]) => ({
zoneLabel: zoneLabel,
items: [...items].sort((a, b) => a.name.localeCompare(b.name)),
})
);
zonesAndItems.sort((a, b) => a.zoneLabel.localeCompare(b.zoneLabel));
// As one last step, try to remove zone groups that aren't helpful.
const groupsWithConflicts = zonesAndItems.filter(
({ items }) => items.length > 1
);
const itemIdsWithConflicts = new Set(
groupsWithConflicts
.map(({ items }) => items)
.flat()
.map((item) => item.id)
);
const itemIdsWeHaveSeen = new Set();
zonesAndItems = zonesAndItems.filter(({ items }) => {
// We need all groups with more than one item. If there's only one, we get
// to think harder :)
if (items.length > 1) {
items.forEach((item) => itemIdsWeHaveSeen.add(item.id));
return true;
}
const item = items[0];
// Has the item been seen a group we kept, or an upcoming group with
// multiple conflicting items? If so, skip this group. If not, keep it.
if (itemIdsWeHaveSeen.has(item.id) || itemIdsWithConflicts.has(item.id)) {
return false;
} else {
itemIdsWeHaveSeen.add(item.id);
return true;
}
});
return zonesAndItems;
}
export function buildOutfitUrl(outfitState, { withoutOutfitId = false } = {}) {
const { id } = outfitState;
const origin =
typeof window !== "undefined"
? window.location.origin
: "https://impress-2020.openneo.net";
if (id && !withoutOutfitId) {
return origin + `/outfits/${id}`;
}
return origin + "/outfits/new?" + buildOutfitQueryString(outfitState);
}
function buildOutfitQueryString(outfitState) {
const {
name,
speciesId,
colorId,
pose,
appearanceId,
wornItemIds,
closetedItemIds,
} = outfitState;
const params = new URLSearchParams({
name: name || "",
species: speciesId || "",
color: colorId || "",
pose: pose || "",
});
for (const itemId of wornItemIds) {
params.append("objects[]", itemId);
}
for (const itemId of closetedItemIds) {
params.append("closet[]", itemId);
}
if (appearanceId != null) {
// `state` is an old name for compatibility with old-style DTI URLs. It
// refers to "PetState", the database table name for pet appearances.
params.append("state", appearanceId);
}
return params.toString();
}
/**
* Whether the two given outfit states represent identical customizations.
*/
export function outfitStatesAreEqual(a, b) {
return buildOutfitQueryString(a) === buildOutfitQueryString(b);
}
export default useOutfitState;

View File

@ -0,0 +1,137 @@
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import { useDebounce } from "../util";
import { emptySearchQuery } from "./SearchToolbar";
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
import { SEARCH_PER_PAGE } from "./SearchPanel";
/**
* useSearchResults manages the actual querying and state management of search!
*/
export function useSearchResults(
query,
outfitState,
currentPageNumber,
{ skip = false } = {}
) {
const { speciesId, colorId } = outfitState;
// We debounce the search query, so that we don't resend a new query whenever
// the user types anything.
const debouncedQuery = useDebounce(query, 300, {
waitForFirstPause: true,
initialValue: emptySearchQuery,
});
// NOTE: This query should always load ~instantly, from the client cache.
const { data: zoneData } = useQuery(gql`
query SearchPanelZones {
allZones {
id
label
}
}
`);
const allZones = zoneData?.allZones || [];
const filterToZones = query.filterToZoneLabel
? allZones.filter((z) => z.label === query.filterToZoneLabel)
: [];
const filterToZoneIds = filterToZones.map((z) => z.id);
const currentPageIndex = currentPageNumber - 1;
const offset = currentPageIndex * SEARCH_PER_PAGE;
// Here's the actual GQL query! At the bottom we have more config than usual!
const {
loading: loadingGQL,
error,
data,
} = useQuery(
gql`
query SearchPanel(
$query: String!
$fitsPet: FitsPetSearchFilter
$itemKind: ItemKindSearchFilter
$currentUserOwnsOrWants: OwnsOrWants
$zoneIds: [ID!]!
$speciesId: ID!
$colorId: ID!
$offset: Int!
$perPage: Int!
) {
itemSearch: itemSearchV2(
query: $query
fitsPet: $fitsPet
itemKind: $itemKind
currentUserOwnsOrWants: $currentUserOwnsOrWants
zoneIds: $zoneIds
) {
id
numTotalItems
items(offset: $offset, limit: $perPage) {
# TODO: De-dupe this from useOutfitState?
id
name
thumbnailUrl
isNc
isPb
currentUserOwnsThis
currentUserWantsThis
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
# This enables us to quickly show the item when the user clicks it!
...ItemAppearanceForOutfitPreview
# This is used to group items by zone, and to detect conflicts when
# wearing a new item.
layers {
zone {
id
label @client
}
}
restrictedZones {
id
label @client
isCommonlyUsedByItems @client
}
}
}
}
}
${itemAppearanceFragment}
`,
{
variables: {
query: debouncedQuery.value,
fitsPet: { speciesId, colorId },
itemKind: debouncedQuery.filterToItemKind,
currentUserOwnsOrWants: debouncedQuery.filterToCurrentUserOwnsOrWants,
zoneIds: filterToZoneIds,
speciesId,
colorId,
offset,
perPage: SEARCH_PER_PAGE,
},
context: { sendAuth: true },
skip:
skip ||
(!debouncedQuery.value &&
!debouncedQuery.filterToItemKind &&
!debouncedQuery.filterToZoneLabel &&
!debouncedQuery.filterToCurrentUserOwnsOrWants),
onError: (e) => {
console.error("Error loading search results", e);
},
// Return `numTotalItems` from the GQL cache while waiting for next page!
returnPartialData: true,
}
);
const loading = debouncedQuery !== query || loadingGQL;
const items = data?.itemSearch?.items ?? [];
const numTotalItems = data?.itemSearch?.numTotalItems ?? null;
const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE);
return { loading, error, items, numTotalPages };
}

View File

@ -0,0 +1,147 @@
import React from "react";
import { Tooltip, useColorModeValue, Flex, Icon } from "@chakra-ui/react";
import { CheckCircleIcon, WarningTwoIcon } from "@chakra-ui/icons";
function HTML5Badge({ usesHTML5, isLoading, tooltipLabel }) {
// `delayedUsesHTML5` stores the last known value of `usesHTML5`, when
// `isLoading` was `false`. This enables us to keep showing the badge, even
// when loading a new appearance - because it's unlikely the badge will
// change between different appearances for the same item, and the flicker is
// annoying!
const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null);
React.useEffect(() => {
if (!isLoading) {
setDelayedUsesHTML5(usesHTML5);
}
}, [usesHTML5, isLoading]);
if (delayedUsesHTML5 === true) {
return (
<GlitchBadgeLayout
hasGlitches={false}
aria-label="HTML5 supported!"
tooltipLabel={
tooltipLabel ||
"This item is converted to HTML5, and ready to use on Neopets.com!"
}
>
<CheckCircleIcon fontSize="xs" />
<Icon
viewBox="0 0 36 36"
fontSize="xl"
// Visual re-balancing, there's too much visual right-padding here!
marginRight="-1"
>
{/* From Twemoji Keycap 5 */}
<path
fill="currentColor"
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
/>
</Icon>
</GlitchBadgeLayout>
);
} else if (delayedUsesHTML5 === false) {
return (
<GlitchBadgeLayout
hasGlitches={true}
aria-label="HTML5 not supported"
tooltipLabel={
tooltipLabel || (
<>
This item 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!
</>
)
}
>
<WarningTwoIcon fontSize="xs" marginRight="1" />
<Icon viewBox="0 0 36 36" fontSize="xl">
{/* From Twemoji Keycap 5 */}
<path
fill="currentColor"
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
/>
{/* From Twemoji Not Allowed */}
<path
fill="#DD2E44"
opacity="0.75"
d="M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zm13 18c0 2.565-.753 4.95-2.035 6.965L11.036 7.036C13.05 5.753 15.435 5 18 5c7.18 0 13 5.821 13 13zM5 18c0-2.565.753-4.95 2.036-6.964l17.929 17.929C22.95 30.247 20.565 31 18 31c-7.179 0-13-5.82-13-13z"
/>
</Icon>
</GlitchBadgeLayout>
);
} else {
// If no `usesHTML5` value has been provided yet, we're empty for now!
return null;
}
}
export function GlitchBadgeLayout({
hasGlitches = true,
children,
tooltipLabel,
...props
}) {
const [isHovered, setIsHovered] = React.useState(false);
const [isFocused, setIsFocused] = React.useState(false);
const greenBackground = useColorModeValue("green.100", "green.900");
const greenBorderColor = useColorModeValue("green.600", "green.500");
const greenTextColor = useColorModeValue("green.700", "white");
const yellowBackground = useColorModeValue("yellow.100", "yellow.900");
const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500");
const yellowTextColor = useColorModeValue("yellow.700", "white");
return (
<Tooltip
textAlign="center"
fontSize="xs"
placement="bottom"
label={tooltipLabel}
// HACK: Chakra tooltips seem inconsistent about staying open when focus
// comes from touch events. But I really want this one to work on
// mobile!
isOpen={isHovered || isFocused}
>
<Flex
align="center"
backgroundColor={hasGlitches ? yellowBackground : greenBackground}
borderColor={hasGlitches ? yellowBorderColor : greenBorderColor}
color={hasGlitches ? yellowTextColor : greenTextColor}
border="1px solid"
borderRadius="md"
boxShadow="md"
paddingX="2"
paddingY="1"
transition="all 0.2s"
tabIndex="0"
_focus={{ outline: "none", boxShadow: "outline" }}
// For consistency between the HTML5Badge & OutfitKnownGlitchesBadge
minHeight="30px"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
>
{children}
</Flex>
</Tooltip>
);
}
export function layerUsesHTML5(layer) {
return Boolean(
layer.svgUrl ||
layer.canvasMovieLibraryUrl ||
// If this glitch is applied, then `svgUrl` will be null, but there's still
// an HTML5 manifest that the official player can render.
(layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT")
);
}
export default HTML5Badge;

View File

@ -0,0 +1,97 @@
import * as React from "react";
import { ClassNames } from "@emotion/react";
import { Box, useColorModeValue } from "@chakra-ui/react";
import { createIcon } from "@chakra-ui/icons";
const HangerIcon = createIcon({
displayName: "HangerIcon",
// https://www.svgrepo.com/svg/108090/clothes-hanger
viewBox: "0 0 473 473",
path: (
<path
fill="currentColor"
d="M451.426,315.003c-0.517-0.344-1.855-0.641-2.41-0.889l-201.09-88.884v-28.879c38.25-4.6,57.136-29.835,57.136-62.28c0-35.926-25.283-63.026-59.345-63.026c-35.763,0-65.771,29.481-65.771,64.384c0,6.005,4.973,10.882,10.978,10.882c1.788,0,3.452-0.535,4.934-1.291c3.519-1.808,6.024-5.365,6.024-9.591c0-22.702,20.674-42.62,44.217-42.62c22.003,0,37.982,17.356,37.982,41.262c0,23.523-19.011,41.262-44.925,41.262c-6.005,0-10.356,4.877-10.356,10.882v21.267v21.353c0,0.21-0.421,0.383-0.401,0.593L35.61,320.55C7.181,330.792-2.554,354.095,0.554,371.881c3.194,18.293,18.704,30.074,38.795,30.074H422.26c23.782,0,42.438-12.307,48.683-32.942C477.11,348.683,469.078,326.766,451.426,315.003z M450.115,364.031c-3.452,11.427-13.607,18.8-27.846,18.8H39.349c-9.725,0-16.104-5.394-17.5-13.368c-1.587-9.104,4.265-22.032,21.831-28.42l199.531-94.583l196.844,87.65C449.303,340.717,453.434,353.072,450.115,364.031z"
/>
),
});
function HangerSpinner({ size = "md", ...props }) {
const boxSize = { sm: "32px", md: "48px" }[size];
const color = useColorModeValue("green.500", "green.300");
return (
<ClassNames>
{({ css }) => (
<Box
className={css`
/*
Adapted from animate.css "swing". We spend 75% of the time swinging,
then 25% of the time pausing before the next loop.
We use this animation for folks who are okay with dizzy-ish motion.
For reduced motion, we use a pulse-fade instead.
*/
@keyframes swing {
15% {
transform: rotate3d(0, 0, 1, 15deg);
}
30% {
transform: rotate3d(0, 0, 1, -10deg);
}
45% {
transform: rotate3d(0, 0, 1, 5deg);
}
60% {
transform: rotate3d(0, 0, 1, -5deg);
}
75% {
transform: rotate3d(0, 0, 1, 0deg);
}
100% {
transform: rotate3d(0, 0, 1, 0deg);
}
}
/*
A homebrew fade-pulse animation. We use this for folks who don't
like motion. It's an important accessibility thing!
*/
@keyframes fade-pulse {
0% {
opacity: 0.2;
}
50% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
@media (prefers-reduced-motion: no-preference) {
animation: 1.2s infinite swing;
transform-origin: top center;
}
@media (prefers-reduced-motion: reduce) {
animation: 1.6s infinite fade-pulse;
}
`}
{...props}
>
<HangerIcon boxSize={boxSize} color={color} transition="color 0.2s" />
</Box>
)}
</ClassNames>
);
}
export default HangerSpinner;

View File

@ -0,0 +1,432 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
Badge,
Box,
SimpleGrid,
Tooltip,
Wrap,
WrapItem,
useColorModeValue,
useTheme,
} from "@chakra-ui/react";
import {
CheckIcon,
EditIcon,
NotAllowedIcon,
StarIcon,
} from "@chakra-ui/icons";
import { HiSparkles } from "react-icons/hi";
import Link from "next/link";
import SquareItemCard from "./SquareItemCard";
import { safeImageUrl, useCommonStyles } from "../util";
import usePreferArchive from "./usePreferArchive";
function ItemCard({ item, badges, variant = "list", ...props }) {
const { brightBackground } = useCommonStyles();
switch (variant) {
case "grid":
return <SquareItemCard item={item} {...props} />;
case "list":
return (
<Link href={`/items/${item.id}`} passHref>
<Box
as="a"
display="block"
p="2"
boxShadow="lg"
borderRadius="lg"
background={brightBackground}
transition="all 0.2s"
className="item-card"
width="100%"
minWidth="0"
{...props}
>
<ItemCardContent
item={item}
badges={badges}
focusSelector=".item-card:hover &, .item-card:focus &"
/>
</Box>
</Link>
);
default:
throw new Error(`Unexpected ItemCard variant: ${variant}`);
}
}
export function ItemCardContent({
item,
badges,
isWorn,
isDisabled,
itemNameId,
focusSelector,
}) {
return (
<Box display="flex">
<Box>
<Box flex="0 0 auto" marginRight="3">
<ItemThumbnail
item={item}
isActive={isWorn}
isDisabled={isDisabled}
focusSelector={focusSelector}
/>
</Box>
</Box>
<Box flex="1 1 0" minWidth="0" marginTop="1px">
<ItemName
id={itemNameId}
isWorn={isWorn}
isDisabled={isDisabled}
focusSelector={focusSelector}
>
{item.name}
</ItemName>
{badges}
</Box>
</Box>
);
}
/**
* ItemThumbnail shows a small preview image for the item, including some
* hover/focus and worn/unworn states.
*/
export function ItemThumbnail({
item,
size = "md",
isActive,
isDisabled,
focusSelector,
...props
}) {
const [preferArchive] = usePreferArchive();
const theme = useTheme();
const borderColor = useColorModeValue(
theme.colors.green["700"],
"transparent"
);
const focusBorderColor = useColorModeValue(
theme.colors.green["600"],
"transparent"
);
return (
<ClassNames>
{({ css }) => (
<Box
width={size === "lg" ? "80px" : "50px"}
height={size === "lg" ? "80px" : "50px"}
transition="all 0.15s"
transformOrigin="center"
position="relative"
className={css([
{
transform: "scale(0.8)",
},
!isDisabled &&
!isActive && {
[focusSelector]: {
opacity: "0.9",
transform: "scale(0.9)",
},
},
!isDisabled &&
isActive && {
opacity: 1,
transform: "none",
},
])}
{...props}
>
<Box
borderRadius="lg"
boxShadow="md"
border="1px"
overflow="hidden"
width="100%"
height="100%"
className={css([
{
borderColor: `${borderColor} !important`,
},
!isDisabled &&
!isActive && {
[focusSelector]: {
borderColor: `${focusBorderColor} !important`,
},
},
])}
>
{/* If the item is still loading, wait with an empty box. */}
{item && (
<Box
as="img"
width="100%"
height="100%"
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
alt={`Thumbnail art for ${item.name}`}
/>
)}
</Box>
</Box>
)}
</ClassNames>
);
}
/**
* ItemName shows the item's name, including some hover/focus and worn/unworn
* states.
*/
function ItemName({ children, isDisabled, focusSelector, ...props }) {
const theme = useTheme();
return (
<ClassNames>
{({ css }) => (
<Box
fontSize="md"
transition="all 0.15s"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
className={
!isDisabled &&
css`
${focusSelector} {
opacity: 0.9;
font-weight: ${theme.fontWeights.medium};
}
input:checked + .item-container & {
opacity: 1;
font-weight: ${theme.fontWeights.bold};
}
`
}
{...props}
>
{children}
</Box>
)}
</ClassNames>
);
}
export function ItemCardList({ children }) {
return (
<SimpleGrid columns={{ sm: 1, md: 2, lg: 3 }} spacing="6">
{children}
</SimpleGrid>
);
}
export function ItemBadgeList({ children, ...props }) {
return (
<Wrap spacing="2" opacity="0.7" {...props}>
{React.Children.map(
children,
(badge) => badge && <WrapItem>{badge}</WrapItem>
)}
</Wrap>
);
}
export function ItemBadgeTooltip({ label, children }) {
return (
<Tooltip
label={<Box textAlign="center">{label}</Box>}
placement="top"
openDelay={400}
>
{children}
</Tooltip>
);
}
export const NcBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return (
<ItemBadgeTooltip label="Neocash">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
colorScheme="purple"
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
NC
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
});
export const NpBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return (
<ItemBadgeTooltip label="Neopoints">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
NP
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
});
export const PbBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return (
<ItemBadgeTooltip label="This item is only obtainable via paintbrush">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
colorScheme="orange"
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
PB
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
});
export const ItemKindBadge = React.forwardRef(
({ isNc, isPb, isEditButton, ...props }, ref) => {
if (isNc) {
return <NcBadge ref={ref} isEditButton={isEditButton} {...props} />;
} else if (isPb) {
return <PbBadge ref={ref} isEditButton={isEditButton} {...props} />;
} else {
return <NpBadge ref={ref} isEditButton={isEditButton} {...props} />;
}
}
);
export function YouOwnThisBadge({ variant = "long" }) {
let badge = (
<Badge
colorScheme="green"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<CheckIcon aria-label="Check" />
{variant === "medium" && <Box marginLeft="1">Own</Box>}
{variant === "long" && <Box marginLeft="1">You own this!</Box>}
</Badge>
);
if (variant === "short" || variant === "medium") {
badge = (
<ItemBadgeTooltip label="You own this item">{badge}</ItemBadgeTooltip>
);
}
return badge;
}
export function YouWantThisBadge({ variant = "long" }) {
let badge = (
<Badge
colorScheme="blue"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<StarIcon aria-label="Star" />
{variant === "medium" && <Box marginLeft="1">Want</Box>}
{variant === "long" && <Box marginLeft="1">You want this!</Box>}
</Badge>
);
if (variant === "short" || variant === "medium") {
badge = (
<ItemBadgeTooltip label="You want this item">{badge}</ItemBadgeTooltip>
);
}
return badge;
}
function ZoneBadge({ variant, zoneLabel }) {
// Shorten the label when necessary, to make the badges less bulky
const shorthand = zoneLabel
.replace("Background Item", "BG Item")
.replace("Foreground Item", "FG Item")
.replace("Lower-body", "Lower")
.replace("Upper-body", "Upper")
.replace("Transient", "Trans")
.replace("Biology", "Bio");
if (variant === "restricts") {
return (
<ItemBadgeTooltip
label={`Restricted: This item can't be worn with ${zoneLabel} items`}
>
<Badge>
<Box display="flex" alignItems="center">
{shorthand} <NotAllowedIcon marginLeft="1" />
</Box>
</Badge>
</ItemBadgeTooltip>
);
}
if (shorthand !== zoneLabel) {
return (
<ItemBadgeTooltip label={zoneLabel}>
<Badge>{shorthand}</Badge>
</ItemBadgeTooltip>
);
}
return <Badge>{shorthand}</Badge>;
}
export function getZoneBadges(zones, propsForAllBadges) {
// Get the sorted zone labels. Sometimes an item occupies multiple zones of
// the same name, so it's important to de-duplicate them!
let labels = zones.map((z) => z.label);
labels = new Set(labels);
labels = [...labels].sort();
return labels.map((label) => (
<ZoneBadge key={label} zoneLabel={label} {...propsForAllBadges} />
));
}
export function MaybeAnimatedBadge() {
return (
<ItemBadgeTooltip label="Maybe animated? (Support only)">
<Badge
colorScheme="orange"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<Box as={HiSparkles} aria-label="Sparkles" />
</Badge>
</ItemBadgeTooltip>
);
}
export default ItemCard;

View File

@ -0,0 +1,471 @@
import React from "react";
import LRU from "lru-cache";
import { Box, Grid, useToast } from "@chakra-ui/react";
import { loadImage, logAndCapture, safeImageUrl } from "../util";
import usePreferArchive from "./usePreferArchive";
// Importing EaselJS and TweenJS puts them directly into the `window` object!
// The bundled scripts are built to attach themselves to `window.createjs`, and
// `window.createjs` is where the Neopets movie libraries expects to find them!
import "easeljs/lib/easeljs";
import "tweenjs/lib/tweenjs";
function OutfitMovieLayer({
libraryUrl,
width,
height,
placeholderImageUrl = null,
isPaused = false,
onLoad = null,
onError = null,
onLowFps = null,
canvasProps = {},
}) {
const [preferArchive] = usePreferArchive();
const [stage, setStage] = React.useState(null);
const [library, setLibrary] = React.useState(null);
const [movieClip, setMovieClip] = React.useState(null);
const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
const canvasRef = React.useRef(null);
const hasShownErrorMessageRef = React.useRef(false);
const toast = useToast();
// Set the canvas's internal dimensions to be higher, if the device has high
// DPI like retina. But we'll keep the layout width/height as expected!
const internalWidth = width * window.devicePixelRatio;
const internalHeight = height * window.devicePixelRatio;
const callOnLoadIfNotYetCalled = React.useCallback(() => {
setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
if (!alreadyHasCalledOnLoad && onLoad) {
onLoad();
}
return true;
});
}, [onLoad]);
const updateStage = React.useCallback(() => {
if (!stage) {
return;
}
try {
stage.update();
} catch (e) {
// If rendering the frame fails, log it and proceed. If it's an
// animation, then maybe the next frame will work? Also alert the user,
// just as an FYI. (This is pretty uncommon, so I'm not worried about
// being noisy!)
if (!hasShownErrorMessageRef.current) {
console.error(`Error rendering movie clip ${libraryUrl}`);
logAndCapture(e);
toast({
status: "warning",
title:
"Hmm, we're maybe having trouble playing one of these animations.",
description:
"If it looks wrong, try pausing and playing, or reloading the " +
"page. Sorry!",
duration: 10000,
isClosable: true,
});
// We do this via a ref, not state, because I want to guarantee that
// future calls see the new value. With state, React's effects might
// not happen in the right order for it to work!
hasShownErrorMessageRef.current = true;
}
}
}, [stage, toast, libraryUrl]);
// This effect gives us a `stage` corresponding to the canvas element.
React.useLayoutEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
if (canvas.getContext("2d") == null) {
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
toast({
status: "warning",
title: "Oops, too many animations!",
description:
`Your device is out of memory, so we can't show any more ` +
`animations. Try removing some items, or using another device.`,
duration: null,
isClosable: true,
});
return;
}
setStage((stage) => {
if (stage && stage.canvas === canvas) {
return stage;
}
return new window.createjs.Stage(canvas);
});
return () => {
setStage(null);
if (canvas) {
// There's a Safari bug where it doesn't reliably garbage-collect
// canvas data. Clean it up ourselves, rather than leaking memory over
// time! https://stackoverflow.com/a/52586606/107415
// https://bugs.webkit.org/show_bug.cgi?id=195325
canvas.width = 0;
canvas.height = 0;
}
};
}, [libraryUrl, toast]);
// This effect gives us the `library` and `movieClip`, based on the incoming
// `libraryUrl`.
React.useEffect(() => {
let canceled = false;
const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
movieLibraryPromise
.then((library) => {
if (canceled) {
return;
}
setLibrary(library);
const movieClip = buildMovieClip(library, libraryUrl);
setMovieClip(movieClip);
})
.catch((e) => {
console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
if (onError) {
onError(e);
}
});
return () => {
canceled = true;
movieLibraryPromise.cancel();
setLibrary(null);
setMovieClip(null);
};
}, [libraryUrl, preferArchive, onError]);
// This effect puts the `movieClip` on the `stage`, when both are ready.
React.useEffect(() => {
if (!stage || !movieClip) {
return;
}
stage.addChild(movieClip);
// Render the movie's first frame. If it's animated and we're not paused,
// then another effect will perform subsequent updates.
updateStage();
// This is when we trigger `onLoad`: once we're actually showing it!
callOnLoadIfNotYetCalled();
setMovieIsLoaded(true);
return () => stage.removeChild(movieClip);
}, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
// This effect updates the `stage` according to the `library`'s framerate,
// but only if there's actual animation to do - i.e., there's more than one
// frame to show, and we're not paused.
React.useEffect(() => {
if (!stage || !movieClip || !library) {
return;
}
if (isPaused || !hasAnimations(movieClip)) {
return;
}
const targetFps = library.properties.fps;
let lastFpsLoggedAtInMs = performance.now();
let numFramesSinceLastLogged = 0;
const intervalId = setInterval(() => {
updateStage();
numFramesSinceLastLogged++;
const now = performance.now();
const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
if (timeSinceLastFpsLoggedAtInSec > 2) {
const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
const roundedFps = Math.round(fps * 100) / 100;
console.debug(
`[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`
);
if (onLowFps && fps < 2) {
onLowFps(fps);
}
lastFpsLoggedAtInMs = now;
numFramesSinceLastLogged = 0;
}
}, 1000 / targetFps);
return () => clearInterval(intervalId);
}, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);
// This effect keeps the `movieClip` scaled correctly, based on the canvas
// size and the `library`'s natural size declaration. (If the canvas size
// changes on window resize, then this will keep us responsive, so long as
// the parent updates our width/height props on window resize!)
React.useEffect(() => {
if (!stage || !movieClip || !library) {
return;
}
movieClip.scaleX = internalWidth / library.properties.width;
movieClip.scaleY = internalHeight / library.properties.height;
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
// to `false`, so that we don't advance by a frame. This keeps us
// really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing.
stage.tickOnUpdate = false;
updateStage();
stage.tickOnUpdate = true;
}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
return (
<Grid templateAreas="single-shared-area">
<canvas
ref={canvasRef}
width={internalWidth}
height={internalHeight}
style={{
width: width,
height: height,
gridArea: "single-shared-area",
}}
data-is-loaded={movieIsLoaded}
{...canvasProps}
/>
{/* While the movie is loading, we show our image version as a
* placeholder, because it generally loads much faster.
* TODO: Show a loading indicator for this partially-loaded state? */}
{placeholderImageUrl && (
<Box
as="img"
src={safeImageUrl(placeholderImageUrl)}
width={width}
height={height}
gridArea="single-shared-area"
opacity={movieIsLoaded ? 0 : 1}
transition="opacity 0.2s"
onLoad={callOnLoadIfNotYetCalled}
/>
)}
</Grid>
);
}
function loadScriptTag(src) {
let script;
let canceled = false;
let resolved = false;
const scriptTagPromise = new Promise((resolve, reject) => {
script = document.createElement("script");
script.onload = () => {
if (canceled) return;
resolved = true;
resolve(script);
};
script.onerror = (e) => {
if (canceled) return;
reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
};
script.src = src;
document.body.appendChild(script);
});
scriptTagPromise.cancel = () => {
if (resolved) return;
script.src = "";
canceled = true;
};
return scriptTagPromise;
}
const MOVIE_LIBRARY_CACHE = new LRU(10);
export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
const cancelableResourcePromises = [];
const cancelAllResources = () =>
cancelableResourcePromises.forEach((p) => p.cancel());
// Most of the logic for `loadMovieLibrary` is inside this async function.
// But we want to attach more fields to the promise before returning it; so
// we declare this async function separately, then call it, then edit the
// returned promise!
const createMovieLibraryPromise = async () => {
// First, check the LRU cache. This will enable us to quickly return movie
// libraries, without re-loading and re-parsing and re-executing.
const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
if (cachedLibrary) {
return cachedLibrary;
}
// Then, load the script tag. (Make sure we set it up to be cancelable!)
const scriptPromise = loadScriptTag(
safeImageUrl(librarySrc, { preferArchive })
);
cancelableResourcePromises.push(scriptPromise);
await scriptPromise;
// These library JS files are interesting in their operation. It seems like
// the idea is, it pushes an object to a global array, and you need to snap
// it up and see it at the end of the array! And I don't really see a way to
// like, get by a name or ID that we know by this point. So, here we go, just
// try to grab it once it arrives!
//
// I'm not _sure_ this method is reliable, but it seems to be stable so far
// in Firefox for me. The things I think I'm observing are:
// - Script execution order should match insert order,
// - Onload execution order should match insert order,
// - BUT, script executions might be batched before onloads.
// - So, each script grabs the _first_ composition from the list, and
// deletes it after grabbing. That way, it serves as a FIFO queue!
// I'm not suuure this is happening as I'm expecting, vs I'm just not seeing
// the race anymore? But fingers crossed!
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
throw new Error(
`Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`
);
}
const [compositionId, composition] = Object.entries(
window.AdobeAn.compositions
)[0];
if (Object.keys(window.AdobeAn.compositions).length > 1) {
console.warn(
`Grabbing composition ${compositionId}, but there are >1 here: `,
Object.keys(window.AdobeAn.compositions).length
);
}
delete window.AdobeAn.compositions[compositionId];
const library = composition.getLibrary();
// One more loading step as part of loading this library is loading the
// images it uses for sprites.
//
// TODO: I guess the manifest has these too, so if we could use our DB cache
// to get the manifest to us faster, then we could avoid a network RTT
// on the critical path by preloading these images before the JS file
// even gets to us?
const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
const manifestImages = new Map(
library.properties.manifest.map(({ id, src }) => [
id,
loadImage(librarySrcDir + "/" + src, {
crossOrigin: "anonymous",
preferArchive,
}),
])
);
// Wait for the images, and make sure they're cancelable while we do.
const manifestImagePromises = manifestImages.values();
cancelableResourcePromises.push(...manifestImagePromises);
await Promise.all(manifestImagePromises);
// Finally, once we have the images loaded, the library object expects us to
// mutate it (!) to give it the actual image and sprite sheet objects from
// the loaded images. That's how the MovieClip's internal JS objects will
// access the loaded data!
const images = composition.getImages();
for (const [id, image] of manifestImages.entries()) {
images[id] = await image;
}
const spriteSheets = composition.getSpriteSheet();
for (const { name, frames } of library.ssMetadata) {
const image = await manifestImages.get(name);
spriteSheets[name] = new window.createjs.SpriteSheet({
images: [image],
frames,
});
}
MOVIE_LIBRARY_CACHE.set(librarySrc, library);
return library;
};
const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
// When any part of the movie library fails, we also cancel the other
// resources ourselves, to avoid stray throws for resources that fail after
// the parent catches the initial failure. We re-throw the initial failure
// for the parent to handle, though!
cancelAllResources();
throw e;
});
// To cancel a `loadMovieLibrary`, cancel all of the resource promises we
// load as part of it. That should effectively halt the async function above
// (anything not yet loaded will stop loading), and ensure that stray
// failures don't trigger uncaught promise rejection warnings.
movieLibraryPromise.cancel = cancelAllResources;
return movieLibraryPromise;
}
export function buildMovieClip(library, libraryUrl) {
let constructorName;
try {
const fileName = decodeURI(libraryUrl).split("/").pop();
const fileNameWithoutExtension = fileName.split(".")[0];
constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
if (constructorName.match(/^[0-9]/)) {
constructorName = "_" + constructorName;
}
} catch (e) {
throw new Error(
`Movie libraryUrl ${JSON.stringify(
libraryUrl
)} did not match expected format: ${e.message}`
);
}
const LibraryMovieClipConstructor = library[constructorName];
if (!LibraryMovieClipConstructor) {
throw new Error(
`Expected JS movie library ${libraryUrl} to contain a constructor ` +
`named ${constructorName}, but it did not: ${Object.keys(library)}`
);
}
const movieClip = new LibraryMovieClipConstructor();
return movieClip;
}
/**
* Recursively scans the given MovieClip (or child createjs node), to see if
* there are any animated areas.
*/
export function hasAnimations(createjsNode) {
return (
// Some nodes have simple animation frames.
createjsNode.totalFrames > 1 ||
// Tweens are a form of animation that can happen separately from frames.
// They expect timer ticks to happen, and they change the scene accordingly.
createjsNode?.timeline?.tweens?.length >= 1 ||
// And some nodes have _children_ that are animated.
(createjsNode.children || []).some(hasAnimations)
);
}
export default OutfitMovieLayer;

View File

@ -0,0 +1,541 @@
import React from "react";
import {
Box,
DarkMode,
Flex,
Text,
useColorModeValue,
useToast,
} from "@chakra-ui/react";
import LRU from "lru-cache";
import { WarningIcon } from "@chakra-ui/icons";
import { ClassNames } from "@emotion/react";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import OutfitMovieLayer, {
buildMovieClip,
hasAnimations,
loadMovieLibrary,
} from "./OutfitMovieLayer";
import HangerSpinner from "./HangerSpinner";
import { loadImage, safeImageUrl, useLocalStorage } from "../util";
import useOutfitAppearance from "./useOutfitAppearance";
import usePreferArchive from "./usePreferArchive";
/**
* OutfitPreview is for rendering a full outfit! It accepts outfit data,
* fetches the appearance data for it, and preloads and renders the layers
* together.
*
* If the species/color/pose fields are null and a `placeholder` node is
* provided instead, we'll render the placeholder. And then, once those props
* become non-null, we'll keep showing the placeholder below the loading
* overlay until loading completes. (We use this on the homepage to show the
* beach splash until outfit data arrives!)
*
* TODO: There's some duplicate work happening in useOutfitAppearance and
* useOutfitState both getting appearance data on first load...
*/
function OutfitPreview(props) {
const { preview } = useOutfitPreview(props);
return preview;
}
/**
* useOutfitPreview is like `<OutfitPreview />`, but a bit more power!
*
* It takes the same props and returns a `preview` field, which is just like
* `<OutfitPreview />` - but it also returns `appearance` data too, in case you
* want to show some additional UI that uses the appearance data we loaded!
*/
export function useOutfitPreview({
speciesId,
colorId,
pose,
wornItemIds,
appearanceId = null,
isLoading = false,
placeholder = null,
loadingDelayMs,
spinnerVariant,
onChangeHasAnimations = null,
...props
}) {
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
const toast = useToast();
const appearance = useOutfitAppearance({
speciesId,
colorId,
pose,
appearanceId,
wornItemIds,
});
const { loading, error, visibleLayers } = appearance;
const {
loading: loading2,
error: error2,
loadedLayers,
layersHaveAnimations,
} = usePreloadLayers(visibleLayers);
const onMovieError = React.useCallback(() => {
if (!toast.isActive("outfit-preview-on-movie-error")) {
toast({
id: "outfit-preview-on-movie-error",
status: "warning",
title: "Oops, we couldn't load one of these animations.",
description: "We'll show a static image version instead.",
duration: null,
isClosable: true,
});
}
}, [toast]);
const onLowFps = React.useCallback(
(fps) => {
setIsPaused(true);
console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`);
if (!toast.isActive("outfit-preview-on-low-fps")) {
toast({
id: "outfit-preview-on-low-fps",
status: "warning",
title: "Sorry, the animation was lagging, so we paused it! 😖",
description:
"We do this to help make sure your machine doesn't lag too much! " +
"You can unpause the preview to try again.",
duration: null,
isClosable: true,
});
}
},
[setIsPaused, toast]
);
React.useEffect(() => {
if (onChangeHasAnimations) {
onChangeHasAnimations(layersHaveAnimations);
}
}, [layersHaveAnimations, onChangeHasAnimations]);
const textColor = useColorModeValue("green.700", "white");
let preview;
if (error || error2) {
preview = (
<FullScreenCenter>
<Text color={textColor} d="flex" alignItems="center">
<WarningIcon />
<Box width={2} />
Could not load preview. Try again?
</Text>
</FullScreenCenter>
);
} else {
preview = (
<OutfitLayers
loading={isLoading || loading || loading2}
visibleLayers={loadedLayers}
placeholder={placeholder}
loadingDelayMs={loadingDelayMs}
spinnerVariant={spinnerVariant}
onMovieError={onMovieError}
onLowFps={onLowFps}
doTransitions
isPaused={isPaused}
{...props}
/>
);
}
return { appearance, preview };
}
/**
* OutfitLayers is the raw UI component for rendering outfit layers. It's
* used both in the main outfit preview, and in other minor UIs!
*/
export function OutfitLayers({
loading,
visibleLayers,
placeholder = null,
loadingDelayMs = 500,
spinnerVariant = "overlay",
doTransitions = false,
isPaused = true,
onMovieError = null,
onLowFps = null,
...props
}) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const [preferArchive] = usePreferArchive();
const containerRef = React.useRef(null);
const [canvasSize, setCanvasSize] = React.useState(0);
const [loadingDelayHasPassed, setLoadingDelayHasPassed] =
React.useState(false);
// When we start in a loading state, or re-enter a loading state, start the
// loading delay timer.
React.useEffect(() => {
if (loading) {
setLoadingDelayHasPassed(false);
const t = setTimeout(
() => setLoadingDelayHasPassed(true),
loadingDelayMs
);
return () => clearTimeout(t);
}
}, [loadingDelayMs, loading]);
React.useLayoutEffect(() => {
function computeAndSaveCanvasSize() {
setCanvasSize(
// Follow an algorithm similar to the <img> sizing: a square that
// covers the available space, without exceeding the natural image size
// (which is 600px).
//
// TODO: Once we're entirely off PNGs, we could drop the 600
// requirement, and let SVGs and movies scale up as far as they
// want...
Math.min(
containerRef.current.offsetWidth,
containerRef.current.offsetHeight,
600
)
);
}
computeAndSaveCanvasSize();
window.addEventListener("resize", computeAndSaveCanvasSize);
return () => window.removeEventListener("resize", computeAndSaveCanvasSize);
}, [setCanvasSize]);
return (
<ClassNames>
{({ css }) => (
<Box
pos="relative"
height="100%"
width="100%"
maxWidth="600px"
maxHeight="600px"
// Create a stacking context, so the z-indexed layers don't escape!
zIndex="0"
ref={containerRef}
data-loading={loading ? true : undefined}
{...props}
>
{placeholder && (
<FullScreenCenter>
<Box
// We show the placeholder until there are visible layers, at which
// point we fade it out.
opacity={visibleLayers.length === 0 ? 1 : 0}
transition="opacity 0.2s"
width="100%"
height="100%"
maxWidth="600px"
maxHeight="600px"
>
{placeholder}
</Box>
</FullScreenCenter>
)}
<TransitionGroup enter={false} exit={doTransitions}>
{visibleLayers.map((layer) => (
<CSSTransition
// We manage the fade-in and fade-out separately! The fade-out
// happens here, when the layer exits the DOM.
key={layer.id}
timeout={200}
>
<FadeInOnLoad
as={FullScreenCenter}
zIndex={layer.zone.depth}
className={css`
&.exit {
opacity: 1;
}
&.exit-active {
opacity: 0;
transition: opacity 0.2s;
}
`}
>
{layer.canvasMovieLibraryUrl ? (
<OutfitMovieLayer
libraryUrl={layer.canvasMovieLibraryUrl}
placeholderImageUrl={getBestImageUrlForLayer(layer, {
hiResMode,
})}
width={canvasSize}
height={canvasSize}
isPaused={isPaused}
onError={onMovieError}
onLowFps={onLowFps}
/>
) : (
<Box
as="img"
src={safeImageUrl(
getBestImageUrlForLayer(layer, { hiResMode }),
{ preferArchive }
)}
alt=""
objectFit="contain"
maxWidth="100%"
maxHeight="100%"
/>
)}
</FadeInOnLoad>
</CSSTransition>
))}
</TransitionGroup>
<FullScreenCenter
zIndex="9000"
// This is similar to our Delay util component, but Delay disappears
// immediately on load, whereas we want this to fade out smoothly. We
// also use a timeout to delay the fade-in by 0.5s, but don't delay the
// fade-out at all. (The timeout was an awkward choice, it was hard to
// find a good CSS way to specify this delay well!)
opacity={loading && loadingDelayHasPassed ? 1 : 0}
transition="opacity 0.2s"
>
{spinnerVariant === "overlay" && (
<>
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
backgroundColor="gray.900"
opacity="0.7"
/>
{/* Against the dark overlay, use the Dark Mode spinner. */}
<DarkMode>
<HangerSpinner />
</DarkMode>
</>
)}
{spinnerVariant === "corner" && (
<HangerSpinner
size="sm"
position="absolute"
bottom="2"
right="2"
/>
)}
</FullScreenCenter>
</Box>
)}
</ClassNames>
);
}
export function FullScreenCenter({ children, ...otherProps }) {
return (
<Flex
pos="absolute"
top="0"
right="0"
bottom="0"
left="0"
alignItems="center"
justifyContent="center"
{...otherProps}
>
{children}
</Flex>
);
}
export function getBestImageUrlForLayer(layer, { hiResMode = false } = {}) {
if (hiResMode && layer.svgUrl) {
return layer.svgUrl;
} else {
return layer.imageUrl;
}
}
/**
* usePreloadLayers preloads the images for the given layers, and yields them
* when done. This enables us to keep the old outfit preview on screen until
* all the new layers are ready, then show them all at once!
*/
export function usePreloadLayers(layers) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const [preferArchive] = usePreferArchive();
const [error, setError] = React.useState(null);
const [loadedLayers, setLoadedLayers] = React.useState([]);
const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
// NOTE: This condition would need to change if we started loading one at a
// time, or if the error case would need to show a partial state!
const loading = layers.length > 0 && loadedLayers !== layers;
React.useEffect(() => {
// HACK: Don't clear the preview when we have zero layers, because it
// usually means the parent is still loading data. I feel like this isn't
// the right abstraction, though...
if (layers.length === 0) {
return;
}
let canceled = false;
setError(null);
setLayersHaveAnimations(false);
const minimalAssetPromises = [];
const imageAssetPromises = [];
const movieAssetPromises = [];
for (const layer of layers) {
const imageAssetPromise = loadImage(
getBestImageUrlForLayer(layer, { hiResMode }),
{ preferArchive }
);
imageAssetPromises.push(imageAssetPromise);
if (layer.canvasMovieLibraryUrl) {
// Start preloading the movie. But we won't block on it! The blocking
// request will still be the image, which we'll show as a
// placeholder, which should usually be noticeably faster!
const movieLibraryPromise = loadMovieLibrary(
layer.canvasMovieLibraryUrl,
{ preferArchive }
);
const movieAssetPromise = movieLibraryPromise.then((library) => ({
library,
libraryUrl: layer.canvasMovieLibraryUrl,
}));
movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl;
movieAssetPromise.cancel = () => movieLibraryPromise.cancel();
movieAssetPromises.push(movieAssetPromise);
// The minimal asset for the movie case is *either* the image *or*
// the movie, because we can start rendering when either is ready.
minimalAssetPromises.push(
Promise.any([imageAssetPromise, movieAssetPromise])
);
} else {
minimalAssetPromises.push(imageAssetPromise);
}
}
// When the minimal assets have loaded, we can say the layers have
// loaded, and allow the UI to start showing them!
Promise.all(minimalAssetPromises)
.then(() => {
if (canceled) return;
setLoadedLayers(layers);
})
.catch((e) => {
if (canceled) return;
console.error("Error preloading outfit layers", e);
setError(e);
// Cancel any remaining promises, if cancelable.
imageAssetPromises.forEach((p) => p.cancel && p.cancel());
movieAssetPromises.forEach((p) => p.cancel && p.cancel());
});
// As the movie assets come in, check them for animations, to decide
// whether to show the Play/Pause button.
const checkHasAnimations = (asset) => {
if (canceled) return;
let assetHasAnimations;
try {
assetHasAnimations = getHasAnimationsForMovieAsset(asset);
} catch (e) {
console.error("Error testing layers for animations", e);
setError(e);
return;
}
setLayersHaveAnimations(
(alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations
);
};
movieAssetPromises.forEach((p) =>
p.then(checkHasAnimations).catch((e) => {
console.error(`Error preloading movie library ${p.libraryUrl}:`, e);
})
);
return () => {
canceled = true;
};
}, [layers, hiResMode, preferArchive]);
return { loading, error, loadedLayers, layersHaveAnimations };
}
// This cache is large because it's only storing booleans; mostly just capping
// it to put *some* upper bound on memory growth.
const HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE = new LRU(50);
function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
// This operation can be pretty expensive! We store a cache to only do it
// once per layer per session ish, instead of on each outfit change.
const cachedHasAnimations =
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl);
if (cachedHasAnimations) {
return cachedHasAnimations;
}
const movieClip = buildMovieClip(library, libraryUrl);
// Some movie clips require you to tick to the first frame of the movie
// before the children mount onto the stage. If we detect animations
// without doing this, we'll incorrectly say no, because we see no children!
// Example: http://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js
movieClip.advance();
const movieClipHasAnimations = hasAnimations(movieClip);
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations);
return movieClipHasAnimations;
}
/**
* FadeInOnLoad attaches an `onLoad` handler to its single child, and fades in
* the container element once it triggers.
*/
function FadeInOnLoad({ children, ...props }) {
const [isLoaded, setIsLoaded] = React.useState(false);
const onLoad = React.useCallback(() => setIsLoaded(true), []);
const child = React.Children.only(children);
const wrappedChild = React.cloneElement(child, { onLoad });
return (
<Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}>
{wrappedChild}
</Box>
);
}
// Polyfill Promise.any for older browsers: https://github.com/ungap/promise-any
// NOTE: Normally I would've considered Promise.any within our support browser
// range… but it's affected 25 users in the past two months, which is
// surprisingly high. And the polyfill is small, so let's do it! (11/2021)
Promise.any =
Promise.any ||
function ($) {
return new Promise(function (D, E, A, L) {
A = [];
L = $.map(function ($, i) {
return Promise.resolve($).then(D, function (O) {
return ((A[i] = O), --L) || E({ errors: A });
});
}).length;
});
};
export default OutfitPreview;

View File

@ -0,0 +1,21 @@
import { Box } from "@chakra-ui/react";
function OutfitThumbnail({ outfitId, updatedAt, ...props }) {
const versionTimestamp = new Date(updatedAt).getTime();
// NOTE: It'd be more reliable for testing to use a relative path, but
// generating these on dev is SO SLOW, that I'd rather just not.
const thumbnailUrl150 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/150.png`;
const thumbnailUrl300 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/300.png`;
return (
<Box
as="img"
src={thumbnailUrl150}
srcSet={`${thumbnailUrl150} 1x, ${thumbnailUrl300} 2x`}
{...props}
/>
);
}
export default OutfitThumbnail;

View File

@ -0,0 +1,153 @@
import React from "react";
import { Box, Button, Flex, Select } from "@chakra-ui/react";
import Link from "next/link";
import { useRouter } from "next/router";
function PaginationToolbar({
isLoading,
numTotalPages,
currentPageNumber,
goToPageNumber,
buildPageUrl,
size = "md",
...props
}) {
const pagesAreLoaded = currentPageNumber != null && numTotalPages != null;
const hasPrevPage = pagesAreLoaded && currentPageNumber > 1;
const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages;
const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null;
const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null;
return (
<Flex align="center" justify="space-between" {...props}>
<LinkOrButton
href={prevPageUrl}
onClick={
prevPageUrl == null
? () => goToPageNumber(currentPageNumber - 1)
: undefined
}
_disabled={{
cursor: isLoading ? "wait" : "not-allowed",
opacity: 0.4,
}}
isDisabled={!hasPrevPage}
size={size}
>
Prev
</LinkOrButton>
{numTotalPages > 0 && (
<Flex align="center" paddingX="4" fontSize={size}>
<Box flex="0 0 auto">Page</Box>
<Box width="1" />
<PageNumberSelect
currentPageNumber={currentPageNumber}
numTotalPages={numTotalPages}
onChange={goToPageNumber}
marginBottom="-2px"
size={size}
/>
<Box width="1" />
<Box flex="0 0 auto">of {numTotalPages}</Box>
</Flex>
)}
<LinkOrButton
href={nextPageUrl}
onClick={
nextPageUrl == null
? () => goToPageNumber(currentPageNumber + 1)
: undefined
}
_disabled={{
cursor: isLoading ? "wait" : "not-allowed",
opacity: 0.4,
}}
isDisabled={!hasNextPage}
size={size}
>
Next
</LinkOrButton>
</Flex>
);
}
export function useRouterPagination(totalCount, numPerPage) {
const { query, push: pushHistory } = useRouter();
const currentOffset = parseInt(query.offset) || 0;
const currentPageIndex = Math.floor(currentOffset / numPerPage);
const currentPageNumber = currentPageIndex + 1;
const numTotalPages = totalCount ? Math.ceil(totalCount / numPerPage) : null;
const buildPageUrl = React.useCallback(
(newPageNumber) => {
const newParams = new URLSearchParams(query);
const newPageIndex = newPageNumber - 1;
const newOffset = newPageIndex * numPerPage;
newParams.set("offset", newOffset);
return "?" + newParams.toString();
},
[query, numPerPage]
);
const goToPageNumber = React.useCallback(
(newPageNumber) => {
pushHistory(buildPageUrl(newPageNumber));
},
[buildPageUrl, pushHistory]
);
return {
numTotalPages,
currentPageNumber,
goToPageNumber,
buildPageUrl,
};
}
function LinkOrButton({ href, ...props }) {
if (href != null) {
return (
<Link href={href} passHref>
<Button as="a" {...props} />
</Link>
);
} else {
return <Button {...props} />;
}
}
function PageNumberSelect({
currentPageNumber,
numTotalPages,
onChange,
...props
}) {
const allPageNumbers = Array.from({ length: numTotalPages }, (_, i) => i + 1);
const handleChange = React.useCallback(
(e) => onChange(Number(e.target.value)),
[onChange]
);
return (
<Select
value={currentPageNumber}
onChange={handleChange}
width="7ch"
variant="flushed"
textAlign="center"
{...props}
>
{allPageNumbers.map((pageNumber) => (
<option key={pageNumber} value={pageNumber}>
{pageNumber}
</option>
))}
</Select>
);
}
export default PaginationToolbar;

View File

@ -0,0 +1,507 @@
import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import { Box, Flex, Select, Text, useColorModeValue } from "@chakra-ui/react";
import { Delay, logAndCapture, useFetch } from "../util";
/**
* SpeciesColorPicker lets the user pick the species/color of their pet.
*
* It preloads all species, colors, and valid species/color pairs; and then
* ensures that the outfit is always in a valid state.
*
* NOTE: This component is memoized with React.memo. It's not the cheapest to
* re-render on every outfit change. This contributes to
* wearing/unwearing items being noticeably slower on lower-power
* devices.
*/
function SpeciesColorPicker({
speciesId,
colorId,
idealPose,
showPlaceholders = false,
colorPlaceholderText = "",
speciesPlaceholderText = "",
stateMustAlwaysBeValid = false,
isDisabled = false,
speciesIsDisabled = false,
size = "md",
speciesTestId = null,
colorTestId = null,
onChange,
}) {
const { loading: loadingMeta, error: errorMeta, data: meta } = useQuery(gql`
query SpeciesColorPicker {
allSpecies {
id
name
standardBodyId # Used for keeping items on during standard color changes
}
allColors {
id
name
isStandard # Used for keeping items on during standard color changes
}
}
`);
const {
loading: loadingValids,
error: errorValids,
valids,
} = useAllValidPetPoses();
const allColors = (meta && [...meta.allColors]) || [];
allColors.sort((a, b) => a.name.localeCompare(b.name));
const allSpecies = (meta && [...meta.allSpecies]) || [];
allSpecies.sort((a, b) => a.name.localeCompare(b.name));
const textColor = useColorModeValue("inherit", "green.50");
if ((loadingMeta || loadingValids) && !showPlaceholders) {
return (
<Delay ms={5000}>
<Text color={textColor} textShadow="md">
Loading species/color data
</Text>
</Delay>
);
}
if (errorMeta || errorValids) {
return (
<Text color={textColor} textShadow="md">
Error loading species/color data.
</Text>
);
}
// When the color changes, check if the new pair is valid, and update the
// outfit if so!
const onChangeColor = (e) => {
const newColorId = e.target.value;
console.debug(`SpeciesColorPicker.onChangeColor`, {
// for IMPRESS-2020-1H
speciesId,
colorId,
newColorId,
});
// Ignore switching to the placeholder option. It shouldn't generally be
// doable once real options exist, and it doesn't represent a valid or
// meaningful transition in the case where it could happen.
if (newColorId === "SpeciesColorPicker-color-loading-placeholder") {
return;
}
const species = allSpecies.find((s) => s.id === speciesId);
const newColor = allColors.find((c) => c.id === newColorId);
const validPoses = getValidPoses(valids, speciesId, newColorId);
const isValid = validPoses.size > 0;
if (stateMustAlwaysBeValid && !isValid) {
// NOTE: This shouldn't happen, because we should hide invalid colors.
logAndCapture(
new Error(
`Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
`with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` +
`colorId=${newColorId}.`
)
);
return;
}
const closestPose = getClosestPose(validPoses, idealPose);
onChange(species, newColor, isValid, closestPose);
};
// When the species changes, check if the new pair is valid, and update the
// outfit if so!
const onChangeSpecies = (e) => {
const newSpeciesId = e.target.value;
console.debug(`SpeciesColorPicker.onChangeSpecies`, {
// for IMPRESS-2020-1H
speciesId,
newSpeciesId,
colorId,
});
// Ignore switching to the placeholder option. It shouldn't generally be
// doable once real options exist, and it doesn't represent a valid or
// meaningful transition in the case where it could happen.
if (newSpeciesId === "SpeciesColorPicker-species-loading-placeholder") {
return;
}
const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
if (!newSpecies) {
// Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species
// ends up coming out of `onChange`!
console.debug({ allSpecies, loadingMeta, errorMeta, meta });
logAndCapture(
new Error(
`Assertion error in SpeciesColorPicker: species not found. ` +
`speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` +
`colorId=${colorId}.`
)
);
return;
}
let color = allColors.find((c) => c.id === colorId);
let validPoses = getValidPoses(valids, newSpeciesId, colorId);
let isValid = validPoses.size > 0;
if (stateMustAlwaysBeValid && !isValid) {
// If `stateMustAlwaysBeValid`, but the user switches to a species that
// doesn't support this color, that's okay and normal! We'll just switch
// to one of the four basic colors instead.
const basicColorId = ["8", "34", "61", "84"][
Math.floor(Math.random() * 4)
];
const basicColor = allColors.find((c) => c.id === basicColorId);
color = basicColor;
validPoses = getValidPoses(valids, newSpeciesId, color.id);
isValid = true;
}
const closestPose = getClosestPose(validPoses, idealPose);
onChange(newSpecies, color, isValid, closestPose);
};
// In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this
// species, so the user can't switch. (We handle species differently: if you
// switch to a new species and the color is invalid, we reset the color. We
// think this matches users' mental hierarchy of species -> color: showing
// supported colors for a species makes sense, but the other way around feels
// confusing and restrictive.)
//
// Also, if a color is provided that wouldn't normally be visible, we still
// show it. This can happen when someone models a new species/color combo for
// the first time - the boxes will still be red as if it were invalid, but
// this still smooths out the experience a lot.
let visibleColors = allColors;
if (stateMustAlwaysBeValid && valids && speciesId) {
visibleColors = visibleColors.filter(
(c) => getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId
);
}
return (
<Flex direction="row">
<SpeciesColorSelect
aria-label="Pet color"
value={colorId || "SpeciesColorPicker-color-loading-placeholder"}
// We also wait for the valid pairs before enabling, so users can't
// trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading.
isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId
}
isDisabled={isDisabled}
onChange={onChangeColor}
size={size}
valids={valids}
speciesId={speciesId}
colorId={colorId}
data-test-id={colorTestId}
>
{
// If the selected color isn't in the set we have here, show the
// placeholder. (Can happen during loading, or if an invalid color ID
// like null is intentionally provided while the real value loads.)
!visibleColors.some((c) => c.id === colorId) && (
<option value="SpeciesColorPicker-color-loading-placeholder">
{colorPlaceholderText}
</option>
)
}
{
// A long name for sizing! Should appear below the placeholder, out
// of view.
visibleColors.length === 0 && <option>Dimensional</option>
}
{visibleColors.map((color) => (
<option key={color.id} value={color.id}>
{color.name}
</option>
))}
</SpeciesColorSelect>
<Box width={size === "sm" ? 2 : 4} />
<SpeciesColorSelect
aria-label="Pet species"
value={speciesId || "SpeciesColorPicker-species-loading-placeholder"}
// We also wait for the valid pairs before enabling, so users can't
// trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading.
isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId
}
isDisabled={isDisabled || speciesIsDisabled}
// Don't fade out in the speciesIsDisabled case; it's more like a
// read-only state.
_disabled={
speciesIsDisabled
? { opacity: "1", cursor: "not-allowed" }
: undefined
}
onChange={onChangeSpecies}
size={size}
valids={valids}
speciesId={speciesId}
colorId={colorId}
data-test-id={speciesTestId}
>
{
// If the selected species isn't in the set we have here, show the
// placeholder. (Can happen during loading, or if an invalid species
// ID like null is intentionally provided while the real value
// loads.)
!allSpecies.some((s) => s.id === speciesId) && (
<option value="SpeciesColorPicker-species-loading-placeholder">
{speciesPlaceholderText}
</option>
)
}
{
// A long name for sizing! Should appear below the placeholder, out
// of view.
allSpecies.length === 0 && <option>Tuskaninny</option>
}
{allSpecies.map((species) => (
<option key={species.id} value={species.id}>
{species.name}
</option>
))}
</SpeciesColorSelect>
</Flex>
);
}
const SpeciesColorSelect = ({
size,
valids,
speciesId,
colorId,
isDisabled,
isLoading,
...props
}) => {
const backgroundColor = useColorModeValue("white", "gray.600");
const borderColor = useColorModeValue("green.600", "transparent");
const textColor = useColorModeValue("inherit", "green.50");
const loadingProps = isLoading
? {
// Visually the disabled state is the same as the normal state, but
// with a wait cursor. We don't expect this to take long, and the flash
// of content is rough!
opacity: "1 !important",
cursor: "wait !important",
}
: {};
return (
<Select
backgroundColor={backgroundColor}
color={textColor}
size={size}
border="1px"
borderColor={borderColor}
boxShadow="md"
width="auto"
transition="all 0.25s"
_hover={{
borderColor: "green.400",
}}
isInvalid={
valids &&
speciesId &&
colorId &&
!pairIsValid(valids, speciesId, colorId)
}
isDisabled={isDisabled || isLoading}
errorBorderColor="red.300"
{...props}
{...loadingProps}
/>
);
};
let cachedResponseForAllValidPetPoses = null;
/**
* useAllValidPoses fetches the valid pet poses, as a `valids` object ready to
* pass into the various validity-checker utility functions!
*
* In addition to the network caching, we globally cache this response in the
* client code as `cachedResponseForAllValidPetPoses`. This helps prevent extra
* re-renders when client-side navigating between pages, similar to how cached
* data from GraphQL serves on the first render, without a loading state.
*/
export function useAllValidPetPoses() {
const networkResponse = useFetch("/api/validPetPoses", {
responseType: "arrayBuffer",
// If we already have globally-cached valids, skip the request.
skip: cachedResponseForAllValidPetPoses != null,
});
// Use the globally-cached response if we have one, or await the network
// response if not.
const response = cachedResponseForAllValidPetPoses || networkResponse;
const { loading, error, data: validsBuffer } = response;
const valids = React.useMemo(
() => validsBuffer && new DataView(validsBuffer),
[validsBuffer]
);
// Once a network response comes in, save it as the globally-cached response.
React.useEffect(() => {
if (
networkResponse &&
!networkResponse.loading &&
!cachedResponseForAllValidPetPoses
) {
cachedResponseForAllValidPetPoses = networkResponse;
}
}, [networkResponse]);
return { loading, error, valids };
}
function getPairByte(valids, speciesId, colorId) {
// Reading a bit table, owo!
const speciesIndex = speciesId - 1;
const colorIndex = colorId - 1;
const numColors = valids.getUint8(1);
const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
try {
return valids.getUint8(pairByteIndex);
} catch (e) {
logAndCapture(
new Error(
`Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`
)
);
return 0;
}
}
function pairIsValid(valids, speciesId, colorId) {
return getPairByte(valids, speciesId, colorId) !== 0;
}
export function getValidPoses(valids, speciesId, colorId) {
const pairByte = getPairByte(valids, speciesId, colorId);
const validPoses = new Set();
if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC");
if (pairByte & 0b00000010) validPoses.add("SAD_MASC");
if (pairByte & 0b00000100) validPoses.add("SICK_MASC");
if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM");
if (pairByte & 0b00010000) validPoses.add("SAD_FEM");
if (pairByte & 0b00100000) validPoses.add("SICK_FEM");
if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
return validPoses;
}
export function getClosestPose(validPoses, idealPose) {
return closestPosesInOrder[idealPose].find((p) => validPoses.has(p)) || null;
}
// For each pose, in what order do we prefer to match other poses?
//
// The principles of this ordering are:
// - Happy/sad matters more than gender presentation.
// - "Sick" is an unpopular emotion, and it's better to change gender
// presentation and stay happy/sad than to become sick.
// - Sad is a better fallback for sick than happy.
// - Unconverted vs converted is the biggest possible difference.
// - Unknown is the pose of last resort - even coming from another unknown.
const closestPosesInOrder = {
HAPPY_MASC: [
"HAPPY_MASC",
"HAPPY_FEM",
"SAD_MASC",
"SAD_FEM",
"SICK_MASC",
"SICK_FEM",
"UNCONVERTED",
"UNKNOWN",
],
HAPPY_FEM: [
"HAPPY_FEM",
"HAPPY_MASC",
"SAD_FEM",
"SAD_MASC",
"SICK_FEM",
"SICK_MASC",
"UNCONVERTED",
"UNKNOWN",
],
SAD_MASC: [
"SAD_MASC",
"SAD_FEM",
"HAPPY_MASC",
"HAPPY_FEM",
"SICK_MASC",
"SICK_FEM",
"UNCONVERTED",
"UNKNOWN",
],
SAD_FEM: [
"SAD_FEM",
"SAD_MASC",
"HAPPY_FEM",
"HAPPY_MASC",
"SICK_FEM",
"SICK_MASC",
"UNCONVERTED",
"UNKNOWN",
],
SICK_MASC: [
"SICK_MASC",
"SICK_FEM",
"SAD_MASC",
"SAD_FEM",
"HAPPY_MASC",
"HAPPY_FEM",
"UNCONVERTED",
"UNKNOWN",
],
SICK_FEM: [
"SICK_FEM",
"SICK_MASC",
"SAD_FEM",
"SAD_MASC",
"HAPPY_FEM",
"HAPPY_MASC",
"UNCONVERTED",
"UNKNOWN",
],
UNCONVERTED: [
"UNCONVERTED",
"HAPPY_FEM",
"HAPPY_MASC",
"SAD_FEM",
"SAD_MASC",
"SICK_FEM",
"SICK_MASC",
"UNKNOWN",
],
UNKNOWN: [
"HAPPY_FEM",
"HAPPY_MASC",
"SAD_FEM",
"SAD_MASC",
"SICK_FEM",
"SICK_MASC",
"UNCONVERTED",
"UNKNOWN",
],
};
export default React.memo(SpeciesColorPicker);

View File

@ -0,0 +1,458 @@
import React from "react";
import {
Box,
IconButton,
Skeleton,
useColorModeValue,
useTheme,
useToken,
} from "@chakra-ui/react";
import { ClassNames } from "@emotion/react";
import Link from "next/link";
import { safeImageUrl, useCommonStyles } from "../util";
import { CheckIcon, CloseIcon, StarIcon } from "@chakra-ui/icons";
import usePreferArchive from "./usePreferArchive";
function SquareItemCard({
item,
showRemoveButton = false,
onRemove = () => {},
tradeMatchingMode = null,
footer = null,
...props
}) {
const outlineShadowValue = useToken("shadows", "outline");
const mdRadiusValue = useToken("radii", "md");
const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200");
const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200");
const [
tradeMatchOwnShadowColorValue,
tradeMatchWantShadowColorValue,
] = useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]);
// When this is a trade match, give it an extra colorful shadow highlight so
// it stands out! (They'll generally be sorted to the front anyway, but this
// make it easier to scan a user's lists page, and to learn how the sorting
// works!)
let tradeMatchShadow;
if (tradeMatchingMode === "offering" && item.currentUserWantsThis) {
tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`;
} else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) {
tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`;
} else {
tradeMatchShadow = null;
}
return (
<ClassNames>
{({ css }) => (
// SquareItemCard renders in large lists of 1k+ items, so we get a big
// perf win by using Emotion directly instead of Chakra's styled-system
// Box.
<div
className={css`
position: relative;
display: flex;
`}
role="group"
>
<Link href={`/items/${item.id}`} passHref>
<Box
as="a"
className={css`
border-radius: ${mdRadiusValue};
transition: all 0.2s;
&:hover,
&:focus {
transform: scale(1.05);
}
&:focus {
box-shadow: ${outlineShadowValue};
outline: none;
}
`}
{...props}
>
<SquareItemCardLayout
name={item.name}
thumbnailImage={
<ItemThumbnail
item={item}
tradeMatchingMode={tradeMatchingMode}
/>
}
removeButton={
showRemoveButton ? (
<SquareItemCardRemoveButton onClick={onRemove} />
) : null
}
boxShadow={tradeMatchShadow}
footer={footer}
/>
</Box>
</Link>
{showRemoveButton && (
<div
className={css`
position: absolute;
right: 0;
top: 0;
transform: translate(50%, -50%);
z-index: 1;
/* Apply some padding, so accidental clicks around the button
* don't click the link instead, or vice-versa! */
padding: 0.75em;
opacity: 0;
[role="group"]:hover &,
[role="group"]:focus-within &,
&:hover,
&:focus-within {
opacity: 1;
}
`}
>
<SquareItemCardRemoveButton onClick={onRemove} />
</div>
)}
</div>
)}
</ClassNames>
);
}
function SquareItemCardLayout({
name,
thumbnailImage,
footer,
minHeightNumLines = 2,
boxShadow = null,
}) {
const { brightBackground } = useCommonStyles();
const brightBackgroundValue = useToken("colors", brightBackground);
const theme = useTheme();
return (
// SquareItemCard renders in large lists of 1k+ items, so we get a big perf
// win by using Emotion directly instead of Chakra's styled-system Box.
<ClassNames>
{({ css }) => (
<div
className={css`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: ${boxShadow || theme.shadows.md};
border-radius: ${theme.radii.md};
padding: ${theme.space["3"]};
width: calc(80px + 2em);
background: ${brightBackgroundValue};
`}
>
{thumbnailImage}
<div
className={css`
margin-top: ${theme.space["1"]};
font-size: ${theme.fontSizes.sm};
/* Set min height to match a 2-line item name, so the cards
* in a row aren't toooo differently sized... */
min-height: ${minHeightNumLines * 1.5 + "em"};
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
`}
// HACK: Emotion turns this into -webkit-display: -webkit-box?
style={{ display: "-webkit-box" }}
>
{name}
</div>
{footer && (
<Box marginTop="2" width="100%">
{footer}
</Box>
)}
</div>
)}
</ClassNames>
);
}
function ItemThumbnail({ item, tradeMatchingMode }) {
const [preferArchive] = usePreferArchive();
const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray";
const thumbnailShadowColor = useColorModeValue(
`${kindColorScheme}.200`,
`${kindColorScheme}.600`
);
const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor);
const mdRadiusValue = useToken("radii", "md");
// Normally, we just show the owns/wants badges depending on whether the
// current user owns/wants it. But, in a trade list, we use trade-matching
// mode instead: only show the badge if it represents a viable trade, and add
// some extra flair to it, too!
let showOwnsBadge;
let showWantsBadge;
let showTradeMatchFlair;
if (tradeMatchingMode == null) {
showOwnsBadge = item.currentUserOwnsThis;
showWantsBadge = item.currentUserWantsThis;
showTradeMatchFlair = false;
} else if (tradeMatchingMode === "offering") {
showOwnsBadge = false;
showWantsBadge = item.currentUserWantsThis;
showTradeMatchFlair = true;
} else if (tradeMatchingMode === "seeking") {
showOwnsBadge = item.currentUserOwnsThis;
showWantsBadge = false;
showTradeMatchFlair = true;
} else if (tradeMatchingMode === "hide-all") {
showOwnsBadge = false;
showWantsBadge = false;
showTradeMatchFlair = false;
} else {
throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`);
}
return (
<ClassNames>
{({ css }) => (
<div
className={css`
position: relative;
`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
alt={`Thumbnail art for ${item.name}`}
width={80}
height={80}
className={css`
border-radius: ${mdRadiusValue};
box-shadow: 0 0 4px ${thumbnailShadowColorValue};
/* Don't let alt text flash in while loading */
&:-moz-loading {
visibility: hidden;
}
`}
loading="lazy"
/>
<div
className={css`
position: absolute;
top: -6px;
left: -6px;
display: flex;
flex-direction: column;
gap: 2px;
`}
>
{showOwnsBadge && (
<ItemOwnsWantsBadge
colorScheme="green"
label={
showTradeMatchFlair
? "You own this, and they want it!"
: "You own this"
}
>
<CheckIcon />
{showTradeMatchFlair && (
<div
className={css`
margin-left: 0.25em;
margin-right: 0.125rem;
`}
>
Match
</div>
)}
</ItemOwnsWantsBadge>
)}
{showWantsBadge && (
<ItemOwnsWantsBadge
colorScheme="blue"
label={
showTradeMatchFlair
? "You want this, and they own it!"
: "You want this"
}
>
<StarIcon />
{showTradeMatchFlair && (
<div
className={css`
margin-left: 0.25em;
margin-right: 0.125rem;
`}
>
Match
</div>
)}
</ItemOwnsWantsBadge>
)}
</div>
{item.isNc != null && (
<div
className={css`
position: absolute;
bottom: -6px;
right: -3px;
`}
>
<ItemThumbnailKindBadge colorScheme={kindColorScheme}>
{item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
</ItemThumbnailKindBadge>
</div>
)}
</div>
)}
</ClassNames>
);
}
function ItemOwnsWantsBadge({ colorScheme, children, label }) {
const badgeBackground = useColorModeValue(
`${colorScheme}.100`,
`${colorScheme}.500`
);
const badgeColor = useColorModeValue(
`${colorScheme}.500`,
`${colorScheme}.100`
);
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
badgeBackground,
badgeColor,
]);
return (
<ClassNames>
{({ css }) => (
<div
aria-label={label}
title={label}
className={css`
border-radius: 999px;
height: 16px;
min-width: 16px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 2px ${badgeBackgroundValue};
/* Decrease the padding: I don't want to hit the edges, but I want
* to be a circle when possible! */
padding-left: 0.125rem;
padding-right: 0.125rem;
/* Copied from Chakra <Badge> */
white-space: nowrap;
vertical-align: middle;
text-transform: uppercase;
font-size: 0.65rem;
font-weight: 700;
background: ${badgeBackgroundValue};
color: ${badgeColorValue};
`}
>
{children}
</div>
)}
</ClassNames>
);
}
function ItemThumbnailKindBadge({ colorScheme, children }) {
const badgeBackground = useColorModeValue(
`${colorScheme}.100`,
`${colorScheme}.500`
);
const badgeColor = useColorModeValue(
`${colorScheme}.500`,
`${colorScheme}.100`
);
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
badgeBackground,
badgeColor,
]);
return (
<ClassNames>
{({ css }) => (
<div
className={css`
/* Copied from Chakra <Badge> */
white-space: nowrap;
vertical-align: middle;
padding-left: 0.25rem;
padding-right: 0.25rem;
text-transform: uppercase;
font-size: 0.65rem;
border-radius: 0.125rem;
font-weight: 700;
background: ${badgeBackgroundValue};
color: ${badgeColorValue};
`}
>
{children}
</div>
)}
</ClassNames>
);
}
function SquareItemCardRemoveButton({ onClick }) {
const backgroundColor = useColorModeValue("gray.200", "gray.500");
return (
<IconButton
aria-label="Remove"
title="Remove"
icon={<CloseIcon />}
size="xs"
borderRadius="full"
boxShadow="lg"
backgroundColor={backgroundColor}
onClick={onClick}
_hover={{
// Override night mode's fade-out on hover
opacity: 1,
transform: "scale(1.15, 1.15)",
}}
_focus={{
transform: "scale(1.15, 1.15)",
boxShadow: "outline",
}}
/>
);
}
export function SquareItemCardSkeleton({ minHeightNumLines, footer = null }) {
return (
<SquareItemCardLayout
name={
<>
<Skeleton width="100%" height="1em" marginTop="2" />
{minHeightNumLines >= 3 && (
<Skeleton width="100%" height="1em" marginTop="2" />
)}
</>
}
thumbnailImage={<Skeleton width="80px" height="80px" />}
minHeightNumLines={minHeightNumLines}
footer={footer}
/>
);
}
export default SquareItemCard;

View File

@ -0,0 +1,135 @@
import gql from "graphql-tag";
function getVisibleLayers(petAppearance, itemAppearances) {
if (!petAppearance) {
return [];
}
const validItemAppearances = itemAppearances.filter((a) => a);
const petLayers = petAppearance.layers.map((l) => ({ ...l, source: "pet" }));
const itemLayers = validItemAppearances
.map((a) => a.layers)
.flat()
.map((l) => ({ ...l, source: "item" }));
let allLayers = [...petLayers, ...itemLayers];
const itemRestrictedZoneIds = new Set(
validItemAppearances
.map((a) => a.restrictedZones)
.flat()
.map((z) => z.id)
);
const petRestrictedZoneIds = new Set(
petAppearance.restrictedZones.map((z) => z.id)
);
const visibleLayers = allLayers.filter((layer) => {
// When an item restricts a zone, it hides pet layers of the same zone.
// We use this to e.g. make a hat hide a hair ruff.
//
// NOTE: Items' restricted layers also affect what items you can wear at
// the same time. We don't enforce anything about that here, and
// instead assume that the input by this point is valid!
if (layer.source === "pet" && itemRestrictedZoneIds.has(layer.zone.id)) {
return false;
}
// When a pet appearance restricts a zone, or when the pet is Unconverted,
// it makes body-specific items incompatible. We use this to disallow UCs
// from wearing certain body-specific Biology Effects, Statics, etc, while
// still allowing non-body-specific items in those zones! (I think this
// happens for some Invisible pet stuff, too?)
//
// TODO: We shouldn't be *hiding* these zones, like we do with items; we
// should be doing this way earlier, to prevent the item from even
// showing up even in search results!
//
// NOTE: This can result in both pet layers and items occupying the same
// zone, like Static, so long as the item isn't body-specific! That's
// correct, and the item layer should be on top! (Here, we implement
// it by placing item layers second in the list, and rely on JS sort
// stability, and *then* rely on the UI to respect that ordering when
// rendering them by depth. Not great! 😅)
//
// NOTE: We used to also include the pet appearance's *occupied* zones in
// this condition, not just the restricted zones, as a sensible
// defensive default, even though we weren't aware of any relevant
// items. But now we know that actually the "Bruce Brucey B Mouth"
// occupies the real Mouth zone, and still should be visible and
// above pet layers! So, we now only check *restricted* zones.
//
// NOTE: UCs used to implement their restrictions by listing specific
// zones, but it seems that the logic has changed to just be about
// UC-ness and body-specific-ness, and not necessarily involve the
// set of restricted zones at all. (This matters because e.g. UCs
// shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
// don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
// zone restriction case running too, because I don't think it
// _hurts_ anything, and I'm not confident enough in this conclusion.
//
// TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
// use zone restrictions?
if (
layer.source === "item" &&
layer.bodyId !== "0" &&
(petAppearance.pose === "UNCONVERTED" ||
petRestrictedZoneIds.has(layer.zone.id))
) {
return false;
}
// A pet appearance can also restrict its own zones. The Wraith Uni is an
// interesting example: it has a horn, but its zone restrictions hide it!
if (layer.source === "pet" && petRestrictedZoneIds.has(layer.zone.id)) {
return false;
}
return true;
});
visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth);
return visibleLayers;
}
// TODO: The web client could save bandwidth by applying @client to the `depth`
// field, because it already has zone depths cached.
export const itemAppearanceFragmentForGetVisibleLayers = gql`
fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
id
layers {
id
bodyId
zone {
id
depth
}
}
restrictedZones {
id
}
}
`;
// TODO: The web client could save bandwidth by applying @client to the `depth`
// field, because it already has zone depths cached.
export const petAppearanceFragmentForGetVisibleLayers = gql`
fragment PetAppearanceForGetVisibleLayers on PetAppearance {
id
pose
layers {
id
zone {
id
depth
}
}
restrictedZones {
id
}
}
`;
export default getVisibleLayers;

View File

@ -0,0 +1,237 @@
import { gql, useMutation, useQuery } from "@apollo/client";
import { useAuth0 } from "@auth0/auth0-react";
import { useLocalStorage } from "../util";
const NOT_LOGGED_IN_USER = {
isLoading: false,
isLoggedIn: false,
id: null,
username: null,
};
function useCurrentUser() {
const [authMode] = useAuthModeFeatureFlag();
const currentUserViaAuth0 = useCurrentUserViaAuth0({
isEnabled: authMode === "auth0",
});
const currentUserViaDb = useCurrentUserViaDb({
isEnabled: authMode === "db",
});
// In development, you can start the server with
// `IMPRESS_LOG_IN_AS=12345 vc dev` to simulate logging in as user 12345.
//
// This flag shouldn't be present in prod anyway, but the dev check is an
// extra safety precaution!
//
// NOTE: In package.json, we forward the flag to REACT_APP_IMPRESS_LOG_IN_AS,
// because create-react-app only forwards flags with that prefix.
if (
process.env["NODE_ENV"] === "development" &&
process.env["REACT_APP_IMPRESS_LOG_IN_AS"]
) {
const id = process.env["REACT_APP_IMPRESS_LOG_IN_AS"];
return {
isLoading: false,
isLoggedIn: true,
id,
username: `<Simulated User ${id}>`,
};
}
if (authMode === "auth0") {
return currentUserViaAuth0;
} else if (authMode === "db") {
return currentUserViaDb;
} else {
console.error(`Unexpected auth mode: ${JSON.stringify(authMode)}`);
return NOT_LOGGED_IN_USER;
}
}
function useCurrentUserViaAuth0({ isEnabled }) {
// NOTE: I don't think we can actually, by the rule of hooks, *not* ask for
// Auth0 login state when `isEnabled` is false, because `useAuth0`
// doesn't accept a similar parameter to disable itself. We'll just
// accept the redundant network effort during rollout, then delete it
// when we're done. (So, the param isn't actually doing a whole lot; I
// mostly have it for consistency with `useCurrentUserViaDb`, to make
// it clear where the real difference is.)
const { isLoading, isAuthenticated, user } = useAuth0();
if (!isEnabled) {
return NOT_LOGGED_IN_USER;
} else if (isLoading) {
return { ...NOT_LOGGED_IN_USER, isLoading: true };
} else if (!isAuthenticated) {
return NOT_LOGGED_IN_USER;
} else {
return {
isLoading: false,
isLoggedIn: true,
...getUserInfoFromAuth0Data(user),
};
}
}
function useCurrentUserViaDb({ isEnabled }) {
const { loading, data } = useQuery(
gql`
query useCurrentUser {
currentUser {
id
username
}
}
`,
{
skip: !isEnabled,
onError: (error) => {
// On error, we don't report anything to the user, but we do keep a
// record in the console. We figure that most errors are likely to be
// solvable by retrying the login button and creating a new session,
// which the user would do without an error prompt anyway; and if not,
// they'll either get an error when they try, or they'll see their
// login state continue to not work, which should be a clear hint that
// something is wrong and they need to reach out.
console.error("[useCurrentUser] Couldn't get current user:", error);
},
}
);
if (!isEnabled) {
return NOT_LOGGED_IN_USER;
} else if (loading) {
return { ...NOT_LOGGED_IN_USER, isLoading: true };
} else if (data?.currentUser == null) {
return NOT_LOGGED_IN_USER;
} else {
return {
isLoading: false,
isLoggedIn: true,
id: data.currentUser.id,
username: data.currentUser.username,
};
}
}
function getUserInfoFromAuth0Data(user) {
return {
id: user.sub?.match(/^auth0\|impress-([0-9]+)$/)?.[1],
username: user["https://oauth.impress-2020.openneo.net/username"],
};
}
/**
* useLoginActions returns a `startLogin` function to start login with Auth0,
* and a `logout` function to logout from whatever auth mode is in use.
*
* Note that `startLogin` is only supported with the Auth0 auto mode. In db
* mode, you should open a `LoginModal` instead!
*/
export function useLogout() {
const { logout: logoutWithAuth0 } = useAuth0();
const [authMode] = useAuthModeFeatureFlag();
const [sendLogoutMutation, { loading, error }] = useMutation(
gql`
mutation useLogout_Logout {
logout {
id
}
}
`,
{
update: (cache, { data }) => {
// Evict the `currentUser` from the cache, which will force all queries
// on the page that depend on it to update. (This includes the
// GlobalHeader that shows who you're logged in as!)
//
// We also evict the user themself, to force-update things that we're
// allowed to see about this user (e.g. private lists).
//
// I don't do any optimistic UI here, because auth is complex enough
// that I'd rather only show logout success after validating it through
// an actual server round-trip.
cache.evict({ id: "ROOT_QUERY", fieldName: "currentUser" });
if (data.logout?.id != null) {
cache.evict({ id: `User:${data.logout.id}` });
}
cache.gc();
},
}
);
const logoutWithDb = () => {
sendLogoutMutation().catch((e) => {}); // handled in error UI
};
if (authMode === "auth0") {
return [logoutWithAuth0, { loading: false, error: null }];
} else if (authMode === "db") {
return [logoutWithDb, { loading, error }];
} else {
console.error(`unexpected auth mode: ${JSON.stringify(authMode)}`);
return [() => {}, { loading: false, error: null }];
}
}
/**
* useAuthModeFeatureFlag returns "db" by default, but "auto" if you're falling
* back to the old auth0-backed login mode.
*
* To set this manually, click "Better login system" on the homepage in the
* Coming Soon block, and switch the toggle.
*/
export function useAuthModeFeatureFlag() {
// We'll probably add a like, experimental gradual rollout thing here too.
// But for now we just check your device's local storage! (This is why we
// default to `null` instead of "auth0", I want to be unambiguous that this
// is the *absence* of a localStorage value, and not risk accidentally
// setting this override value to auth0 on everyone's devices 😅)
let [savedValue, setSavedValue] = useLocalStorage(
"DTIAuthModeFeatureFlag",
null
);
if (!["auth0", "db", null].includes(savedValue)) {
console.warn(
`Unexpected DTIAuthModeFeatureFlag value: %o. Ignoring.`,
savedValue
);
savedValue = null;
}
const value = savedValue || "db";
return [value, setSavedValue];
}
/**
* getAuthModeFeatureFlag returns the authMode at the time it's called.
* It's generally preferable to use `useAuthModeFeatureFlag` in a React
* setting, but we use this instead for Apollo stuff!
*/
export function getAuthModeFeatureFlag() {
const savedValueString = localStorage.getItem("DTIAuthModeFeatureFlag");
let savedValue;
try {
savedValue = JSON.parse(savedValueString);
} catch (error) {
console.warn(`DTIAuthModeFeatureFlag was not valid JSON. Ignoring.`);
savedValue = null;
}
if (!["auth0", "db", null].includes(savedValue)) {
console.warn(
`Unexpected DTIAuthModeFeatureFlag value: %o. Ignoring.`,
savedValue
);
savedValue = null;
}
return savedValue || "db";
}
export default useCurrentUser;

View File

@ -0,0 +1,194 @@
import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import getVisibleLayers, {
itemAppearanceFragmentForGetVisibleLayers,
petAppearanceFragmentForGetVisibleLayers,
} from "../components/getVisibleLayers";
/**
* useOutfitAppearance downloads the outfit's appearance data, and returns
* visibleLayers for rendering.
*/
export default function useOutfitAppearance(outfitState) {
const { wornItemIds, speciesId, colorId, pose, appearanceId } = outfitState;
// We split this query out from the other one, so that we can HTTP cache it.
//
// While Apollo gives us fine-grained caching during the page session, we can
// only HTTP a full query at a time.
//
// This is a minor optimization with respect to keeping the user's cache
// populated with their favorite species/color combinations. Once we start
// caching the items by body instead of species/color, this could make color
// changes really snappy!
//
// The larger optimization is that this enables the CDN to edge-cache the
// most popular species/color combinations, for very fast previews on the
// HomePage. At time of writing, Vercel isn't actually edge-caching these, I
// assume because our traffic isn't enough - so let's keep an eye on this!
const {
loading: loading1,
error: error1,
data: data1,
} = useQuery(
appearanceId == null
? gql`
query OutfitPetAppearance(
$speciesId: ID!
$colorId: ID!
$pose: Pose!
) {
petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: $pose
) {
...PetAppearanceForOutfitPreview
}
}
${petAppearanceFragment}
`
: gql`
query OutfitPetAppearanceById($appearanceId: ID!) {
petAppearance: petAppearanceById(id: $appearanceId) {
...PetAppearanceForOutfitPreview
}
}
${petAppearanceFragment}
`,
{
variables: {
speciesId,
colorId,
pose,
appearanceId,
},
skip:
speciesId == null ||
colorId == null ||
(pose == null && appearanceId == null),
}
);
const {
loading: loading2,
error: error2,
data: data2,
} = useQuery(
gql`
query OutfitItemsAppearance(
$speciesId: ID!
$colorId: ID!
$wornItemIds: [ID!]!
) {
items(ids: $wornItemIds) {
id
name # HACK: This is for HTML5 detection UI in OutfitControls!
appearance: appearanceOn(speciesId: $speciesId, colorId: $colorId) {
...ItemAppearanceForOutfitPreview
}
}
}
${itemAppearanceFragment}
`,
{
variables: {
speciesId,
colorId,
wornItemIds,
},
skip: speciesId == null || colorId == null || wornItemIds.length === 0,
}
);
const petAppearance = data1?.petAppearance;
const items = data2?.items;
const itemAppearances = React.useMemo(
() => (items || []).map((i) => i.appearance),
[items]
);
const visibleLayers = React.useMemo(
() => getVisibleLayers(petAppearance, itemAppearances),
[petAppearance, itemAppearances]
);
const bodyId = petAppearance?.bodyId;
return {
loading: loading1 || loading2,
error: error1 || error2,
petAppearance,
items: items || [],
itemAppearances,
visibleLayers,
bodyId,
};
}
export const appearanceLayerFragment = gql`
fragment AppearanceLayerForOutfitPreview on AppearanceLayer {
id
svgUrl
canvasMovieLibraryUrl
imageUrl: imageUrlV2(idealSize: SIZE_600)
bodyId
knownGlitches # For HTML5 & Known Glitches UI
zone {
id
depth @client
label @client
}
}
`;
export const appearanceLayerFragmentForSupport = gql`
fragment AppearanceLayerForSupport on AppearanceLayer {
id
remoteId # HACK: This is for Support tools, but other views don't need it
swfUrl # HACK: This is for Support tools, but other views don't need it
zone {
id
label @client # HACK: This is for Support tools, but other views don't need it
}
}
`;
export const itemAppearanceFragment = gql`
fragment ItemAppearanceForOutfitPreview on ItemAppearance {
id
layers {
id
...AppearanceLayerForOutfitPreview
...AppearanceLayerForSupport # HACK: Most users don't need this!
}
...ItemAppearanceForGetVisibleLayers
}
${appearanceLayerFragment}
${appearanceLayerFragmentForSupport}
${itemAppearanceFragmentForGetVisibleLayers}
`;
export const petAppearanceFragment = gql`
fragment PetAppearanceForOutfitPreview on PetAppearance {
id
bodyId
pose # For Known Glitches UI
isGlitched # For Known Glitches UI
species {
id # For Known Glitches UI
}
color {
id # For Known Glitches UI
}
layers {
id
...AppearanceLayerForOutfitPreview
}
...PetAppearanceForGetVisibleLayers
}
${appearanceLayerFragment}
${petAppearanceFragmentForGetVisibleLayers}
`;

View File

@ -0,0 +1,22 @@
import { useLocalStorage } from "../util";
/**
* usePreferArchive helps the user choose to try using our archive before
* using images.neopets.com, when images.neopets.com is being slow and bleh!
*/
function usePreferArchive() {
const [preferArchiveSavedValue, setPreferArchive] = useLocalStorage(
"DTIPreferArchive",
null
);
// Oct 13 2022: I might default this back to on again if the lag gets
// miserable again, but it's okaaay right now? ish? Bad enough that I want to
// offer this option, but decent enough that I don't want to turn it on by
// default and break new items yet!
const preferArchive = preferArchiveSavedValue ?? false;
return [preferArchive, setPreferArchive];
}
export default usePreferArchive;

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -0,0 +1 @@
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><path d="m36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18 0-9.94 8.06-18 18-18 9.941 0 18 8.06 18 18" fill="#ffcc4d"/><g fill="#664500"><ellipse cx="11.5" cy="17" rx="2.5" ry="3.5"/><ellipse cx="24.5" cy="17" rx="2.5" ry="3.5"/><path d="m5.999 13.5c-.208 0-.419-.065-.599-.2-.442-.331-.531-.958-.2-1.4 3.262-4.35 7.616-4.4 7.8-4.4.552 0 1 .448 1 1 0 .551-.445.998-.996 1-.155.002-3.568.086-6.204 3.6-.196.262-.497.4-.801.4zm24.002 0c-.305 0-.604-.138-.801-.4-2.641-3.521-6.061-3.599-6.206-3.6-.55-.006-.994-.456-.991-1.005.003-.551.447-.995.997-.995.184 0 4.537.05 7.8 4.4.332.442.242 1.069-.2 1.4-.18.135-.39.2-.599.2zm-6.516 14.879c-.011-.044-1.145-4.379-5.485-4.379s-5.474 4.335-5.485 4.379c-.053.213.044.431.232.544.188.112.433.086.596-.06.009-.008 1.013-.863 4.657-.863 3.59 0 4.617.83 4.656.863.095.09.219.137.344.137.084 0 .169-.021.246-.064.196-.112.294-.339.239-.557z"/></g><path d="m16 31c0 2.762-2.238 5-5 5s-5-2.238-5-5 4-10 5-10 5 7.238 5 10z" fill="#5dadec"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFAC33" d="M14 0c-1.721 0-3.343.406-4.793 1.111C8.814 1.043 8.412 1 8 1 4.134 1 1 4.134 1 8v12h.018C1.201 26.467 6.489 31.656 13 31.656c6.511 0 11.799-5.189 11.982-11.656H25v-9c0-6.075-4.925-11-11-11z"/><path fill="#9268CA" d="M22 27H4c-2.209 0-4 1.791-4 4v5h26v-5c0-2.209-1.791-4-4-4z"/><path fill="#7450A8" d="M21 32h1v4h-1zM4 32h1v4H4z"/><path fill="#FFDC5D" d="M10 22v6c0 1.657 1.343 3 3 3s3-1.343 3-3v-6h-6z"/><path fill="#FFDC5D" d="M9 5s-.003 5.308-5 5.936V17c0 4.971 4.029 9 9 9s9-4.029 9-9v-5.019C10.89 11.605 9 5 9 5z"/><path fill="#DF1F32" d="M17 22H9s1 2 4 2 4-2 4-2z"/><path fill="#9268CA" d="M29 36h-7l1-17h6z"/><path fill="#F9CA55" d="M31.541 15.443c-.144-.693-.822-1.139-1.517-.997L27.35 15H25c-1.104 0-2 .896-2 2v2h5c1.079 0 1.953-.857 1.992-1.927l.355-.073H31c0-.074-.028-.144-.045-.216.444-.276.698-.799.586-1.341z"/><path fill="#FFDC5D" d="M36 16c0-.552-.447-1-1-1l-6 1h-5c-.553 0-1 .448-1 1v2h6l6-2s1-.447 1-1z"/><path fill="#C1694F" d="M14 19.5h-2c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h2c.276 0 .5.224.5.5s-.224.5-.5.5z"/><path fill="#662113" d="M9 16c-.552 0-1-.448-1-1v-1c0-.552.448-1 1-1s1 .448 1 1v1c0 .552-.448 1-1 1zm8 0c-.552 0-1-.448-1-1v-1c0-.552.448-1 1-1s1 .448 1 1v1c0 .552-.448 1-1 1z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><path d="m22 27h-18c-2.209 0-4 1.791-4 4v5h26v-5c0-2.209-1.791-4-4-4z" fill="#4289c1"/><path d="m21 32h1v4h-1zm-17 0h1v4h-1z" fill="#2a6797"/><path d="m10 22v6c0 1.657 1.343 3 3 3s3-1.343 3-3v-6z" fill="#ffdc5d"/><path d="m29 36h-7l1-17h6z" fill="#4289c1"/><path d="m31.541 15.443c-.144-.693-.822-1.139-1.517-.997l-2.674.554h-2.35c-1.104 0-2 .896-2 2v2h5c1.079 0 1.953-.857 1.992-1.927l.355-.073h.653c0-.074-.028-.144-.045-.216.444-.276.698-.799.586-1.341z" fill="#f9ca55"/><path d="m36 16c0-.552-.447-1-1-1l-6 1h-5c-.553 0-1 .448-1 1v2h6l6-2s1-.447 1-1zm-32-10.062v11.062c0 4.971 4.029 9 9 9s9-4.029 9-9v-10.75z" fill="#ffdc5d"/><path d="m9 22h8s-1 2-4 2-4-2-4-2z" fill="#c1694f"/><path d="m9 16c-.552 0-1-.448-1-1v-1c0-.552.448-1 1-1s1 .448 1 1v1c0 .552-.448 1-1 1zm8 0c-.552 0-1-.448-1-1v-1c0-.552.448-1 1-1s1 .448 1 1v1c0 .552-.448 1-1 1z" fill="#662113"/><path d="m14 19.5h-2c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h2c.276 0 .5.224.5.5s-.224.5-.5.5z" fill="#c1694f"/><path d="m5.847 13.715c0 1.58-.801 2.861-1.788 2.861s-1.788-1.281-1.788-2.861.801-2.861 1.788-2.861 1.788 1.281 1.788 2.861zm17.882 0c0 1.58-.8 2.861-1.788 2.861s-1.788-1.281-1.788-2.861.8-2.861 1.788-2.861 1.788 1.281 1.788 2.861z" fill="#ffdc5d"/><path d="m13 .823c-7.019 0-10.139 4.684-10.139 8.588 0 3.903 1.343 4.986 1.56 3.903.78-3.903 3.12-5.101 3.12-5.101 4.68 3.904 3.9.781 3.9.781 4.679 4.684 2.34 0 2.34 0 1.56 1.562 6.239 1.562 6.239 1.562s.78 1.198 1.559 2.759c.78 1.562 1.56 0 1.56-3.903 0-3.905-3.9-8.589-10.139-8.589z" fill="#ffac33"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#CCD6DD" d="M17 27c-1.657 0-3-1.343-3-3v-4c0-1.657 1.343-3 3-3 .603-.006 6-1 6-5 0-2-2-4-5-4-2.441 0-4 2-4 3 0 1.657-1.343 3-3 3s-3-1.343-3-3c0-4.878 4.58-9 10-9 8 0 11 5.982 11 11 0 4.145-2.277 7.313-6.413 8.92-.9.351-1.79.587-2.587.747V24c0 1.657-1.343 3-3 3z"/><circle fill="#CCD6DD" cx="17" cy="32" r="3"/></svg>

After

Width:  |  Height:  |  Size: 388 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><circle cx="18" cy="18" fill="#ffcc4d" r="18"/><g fill="#664500"><ellipse cx="12.5" cy="16.5" rx="2.5" ry="3.5"/><ellipse cx="23.5" cy="16.5" rx="2.5" ry="3.5"/><path d="m29 12c-5.554 0-7.802-4.367-7.895-4.553-.247-.494-.047-1.095.447-1.342.492-.247 1.092-.048 1.34.443.075.146 1.821 3.452 6.108 3.452.553 0 1 .448 1 1 0 .553-.447 1-1 1zm-22-.006c-.552 0-1-.448-1-1s.448-1 1-1c5.083 0 5.996-3.12 6.033-3.253.145-.528.69-.848 1.219-.709.53.139.851.673.718 1.205-.049.194-1.266 4.757-7.97 4.757z"/></g><path d="m35.968 17.068-4.005.813-16.187 10.508 7.118.963 11.685-7.211c.703-.431.994-.835 1.198-1.747s.191-3.326.191-3.326z" fill="#ffac33"/><path d="m23.485 29.379c-.011-.044-1.145-2.879-5.485-2.879s-5.474 2.835-5.485 2.879c-.053.213.044.431.232.544.188.112.433.086.596-.06.009-.008 1.013-.863 4.657-.863 3.59 0 4.617.83 4.656.863.095.09.219.137.344.137.084 0 .169-.021.246-.064.196-.112.294-.339.239-.557z" fill="#664500"/><path d="m35.474 15.729c-.829-1.104-2.397-1.328-3.501-.5l-17.206 12.908c.646-.242 1.51-.444 2.615-.522.256-.018 2.66-.627 2.66-.627l1.293 1.036s.911.367 1.197.539l12.444-9.335c1.103-.827 1.326-2.395.498-3.499z" fill="#ccd6dd"/><path d="m28.686 20.87c-.448-.596-.787-1.145-1.383-.698l-9.922 7.443c.256-.018.5-.042.783-.044 1.36-.009 2.4.195 3.17.452l7.588-5.692c.596-.447.211-.864-.236-1.461z" fill="#da2f47"/><path d="m18.599 25.228 1.234 1.598.741-.595-1.197-1.587zm2.223-1.661 1.235 1.598.741-.595-1.197-1.587zm2.184-1.619 1.234 1.598.741-.595-1.197-1.587zm2.181-1.66 1.235 1.597.741-.594-1.197-1.587zm2.238-1.641 1.235 1.598.74-.595-1.196-1.587zm2.14-1.618 1.235 1.598.74-.595-1.196-1.587z" fill="#67757f"/><path d="m22.531 28.563.805.522s-.197-.362-.526-.726z" fill="#452e04"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><path fill="#664500" d="M28.457 17.797c-.06-.135-1.499-3.297-4.457-3.297-2.957 0-4.397 3.162-4.457 3.297-.092.207-.032.449.145.591.175.142.426.147.61.014.012-.009 1.262-.902 3.702-.902 2.426 0 3.674.881 3.702.901.088.066.194.099.298.099.11 0 .221-.037.312-.109.177-.142.238-.386.145-.594zm-12 0c-.06-.135-1.499-3.297-4.457-3.297-2.957 0-4.397 3.162-4.457 3.297-.092.207-.032.449.144.591.176.142.427.147.61.014.013-.009 1.262-.902 3.703-.902 2.426 0 3.674.881 3.702.901.088.066.194.099.298.099.11 0 .221-.037.312-.109.178-.142.237-.386.145-.594zM18 22c-3.623 0-6.027-.422-9-1-.679-.131-2 0-2 2 0 4 4.595 9 11 9 6.404 0 11-5 11-9 0-2-1.321-2.132-2-2-2.973.578-5.377 1-9 1z"/><path fill="#FFF" d="M9 23s3 1 9 1 9-1 9-1-2 4-9 4-9-4-9-4z"/></svg>

After

Width:  |  Height:  |  Size: 920 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18S0 27.941 0 18 8.059 0 18 0s18 8.059 18 18"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#292F33" d="M1.24 11.018c.24.239 1.438.957 1.677 1.675.24.717.72 4.784 2.158 5.981 1.483 1.232 7.077.774 8.148.24 2.397-1.195 2.691-4.531 3.115-6.221.239-.957 1.677-.957 1.677-.957s1.438 0 1.678.956c.424 1.691.72 5.027 3.115 6.221 1.072.535 6.666.994 8.151-.238 1.436-1.197 1.915-5.264 2.155-5.982.238-.717 1.438-1.435 1.677-1.674.241-.239.241-1.196 0-1.436-.479-.478-6.134-.904-12.223-.239-1.215.133-1.677.478-4.554.478-2.875 0-3.339-.346-4.553-.478-6.085-.666-11.741-.24-12.221.238-.239.239-.239 1.197 0 1.436z"/><path fill="#664500" d="M27.335 23.629c-.178-.161-.444-.171-.635-.029-.039.029-3.922 2.9-8.7 2.9-4.766 0-8.662-2.871-8.7-2.9-.191-.142-.457-.13-.635.029-.177.16-.217.424-.094.628C8.7 24.472 11.788 29.5 18 29.5s9.301-5.028 9.429-5.243c.123-.205.084-.468-.094-.628z"/></svg>

After

Width:  |  Height:  |  Size: 997 B

View File

@ -0,0 +1,3 @@
import WardrobePage from "./WardrobePage";
export { WardrobePage };

View File

@ -0,0 +1,526 @@
import React from "react";
import {
Box,
Flex,
Grid,
Heading,
Link,
useColorModeValue,
} from "@chakra-ui/react";
import loadableLibrary from "@loadable/component";
import * as Sentry from "@sentry/react";
import { WarningIcon } from "@chakra-ui/icons";
import NextImage from "next/image";
import ErrorGrundoImg from "./images/error-grundo.png";
/**
* Delay hides its content at first, then shows it after the given delay.
*
* This is useful for loading states: it can be disruptive to see a spinner or
* skeleton element for only a brief flash, we'd rather just show them if
* loading is genuinely taking a while!
*
* 300ms is a pretty good default: that's about when perception shifts from "it
* wasn't instant" to "the process took time".
* https://developers.google.com/web/fundamentals/performance/rail
*/
export function Delay({ children, ms = 300 }) {
const [isVisible, setIsVisible] = React.useState(false);
React.useEffect(() => {
const id = setTimeout(() => setIsVisible(true), ms);
return () => clearTimeout(id);
}, [ms, setIsVisible]);
return (
<Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s">
{children}
</Box>
);
}
/**
* Heading1 is a large, page-title-ish heading, with our DTI-brand-y Delicious
* font and some special typographical styles!
*/
export function Heading1({ children, ...props }) {
return (
<Heading
as="h1"
size="2xl"
fontFamily="Delicious, sans-serif"
fontWeight="800"
{...props}
>
{children}
</Heading>
);
}
/**
* Heading2 is a major subheading, with our DTI-brand-y Delicious font and some
* special typographical styles!!
*/
export function Heading2({ children, ...props }) {
return (
<Heading
as="h2"
size="xl"
fontFamily="Delicious, sans-serif"
fontWeight="700"
{...props}
>
{children}
</Heading>
);
}
/**
* Heading2 is a minor subheading, with our DTI-brand-y Delicious font and some
* special typographical styles!!
*/
export function Heading3({ children, ...props }) {
return (
<Heading
as="h3"
size="lg"
fontFamily="Delicious, sans-serif"
fontWeight="700"
{...props}
>
{children}
</Heading>
);
}
/**
* ErrorMessage is a simple error message for simple errors!
*/
export function ErrorMessage({ children, ...props }) {
return (
<Box color="red.400" {...props}>
{children}
</Box>
);
}
export function useCommonStyles() {
return {
brightBackground: useColorModeValue("white", "gray.700"),
bodyBackground: useColorModeValue("gray.50", "gray.800"),
};
}
/**
* safeImageUrl returns an HTTPS-safe image URL for Neopets assets!
*/
export function safeImageUrl(
urlString,
{ crossOrigin = null, preferArchive = false } = {}
) {
if (urlString == null) {
return urlString;
}
let url;
try {
url = new URL(
urlString,
// A few item thumbnail images incorrectly start with "/". When that
// happens, the correct URL is at images.neopets.com.
//
// So, we provide "http://images.neopets.com" as the base URL when
// parsing. Most URLs are absolute and will ignore it, but relative URLs
// will resolve relative to that base.
"http://images.neopets.com"
);
} catch (e) {
logAndCapture(
new Error(
`safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`
)
);
return "https://impress-2020.openneo.net/__error__URL-was-not-parseable__";
}
// Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our
// proxy if we need CORS headers.
if (
url.origin === "http://images.neopets.com" ||
url.origin === "https://images.neopets.com"
) {
url.protocol = "https:";
if (preferArchive) {
const archiveUrl = new URL(
`/api/readFromArchive`,
window.location.origin
);
archiveUrl.search = new URLSearchParams({ url: url.toString() });
url = archiveUrl;
} else if (crossOrigin) {
url.host = "images.neopets-asset-proxy.openneo.net";
}
} else if (
url.origin === "http://pets.neopets.com" ||
url.origin === "https://pets.neopets.com"
) {
url.protocol = "https:";
if (crossOrigin) {
url.host = "pets.neopets-asset-proxy.openneo.net";
}
}
if (url.protocol !== "https:" && url.hostname !== "localhost") {
logAndCapture(
new Error(
`safeImageUrl was provided an unsafe URL, but we don't know how to ` +
`upgrade it to HTTPS: ${urlString}. Returning a placeholder.`
)
);
return "https://impress-2020.openneo.net/__error__URL-was-not-HTTPS__";
}
return url.toString();
}
/**
* useDebounce helps make a rapidly-changing value change less! It waits for a
* pause in the incoming data before outputting the latest value.
*
* We use it in search: when the user types rapidly, we don't want to update
* our query and send a new request every keystroke. We want to wait for it to
* seem like they might be done, while still feeling responsive!
*
* Adapted from https://usehooks.com/useDebounce/
*/
export function useDebounce(
value,
delay,
{ waitForFirstPause = false, initialValue = null, forceReset = null } = {}
) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = React.useState(
waitForFirstPause ? initialValue : value
);
React.useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler);
};
},
[value, delay] // Only re-call effect if value or delay changes
);
// The `forceReset` option helps us decide whether to set the value
// immediately! We'll update it in an effect for consistency and clarity, but
// also return it immediately rather than wait a tick.
const shouldForceReset = forceReset && forceReset(debouncedValue, value);
React.useEffect(() => {
if (shouldForceReset) {
setDebouncedValue(value);
}
}, [shouldForceReset, value]);
return shouldForceReset ? value : debouncedValue;
}
/**
* useFetch uses `fetch` to fetch the given URL, and returns the request state.
*
* Our limited API is designed to match the `use-http` library!
*/
export function useFetch(url, { responseType, skip, ...fetchOptions }) {
// Just trying to be clear about what you'll get back ^_^` If we want to
// fetch non-binary data later, extend this and get something else from res!
if (responseType !== "arrayBuffer") {
throw new Error(`unsupported responseType ${responseType}`);
}
const [response, setResponse] = React.useState({
loading: skip ? false : true,
error: null,
data: null,
});
// We expect this to be a simple object, so this helps us only re-send the
// fetch when the options have actually changed, rather than e.g. a new copy
// of an identical object!
const fetchOptionsAsJson = JSON.stringify(fetchOptions);
React.useEffect(() => {
if (skip) {
return;
}
let canceled = false;
fetch(url, JSON.parse(fetchOptionsAsJson))
.then(async (res) => {
if (canceled) {
return;
}
const arrayBuffer = await res.arrayBuffer();
setResponse({ loading: false, error: null, data: arrayBuffer });
})
.catch((error) => {
if (canceled) {
return;
}
setResponse({ loading: false, error, data: null });
});
return () => {
canceled = true;
};
}, [skip, url, fetchOptionsAsJson]);
return response;
}
/**
* useLocalStorage is like React.useState, but it persists the value in the
* device's `localStorage`, so it comes back even after reloading the page.
*
* Adapted from https://usehooks.com/useLocalStorage/.
*/
let storageListeners = [];
export function useLocalStorage(key, initialValue) {
const loadValue = React.useCallback(() => {
if (typeof localStorage === "undefined") {
return initialValue;
}
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = React.useState(loadValue);
const setValue = React.useCallback(
(value) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
storageListeners.forEach((l) => l());
} catch (error) {
console.error(error);
}
},
[key]
);
const reloadValue = React.useCallback(() => {
setStoredValue(loadValue());
}, [loadValue, setStoredValue]);
// Listen for changes elsewhere on the page, and update here too!
React.useEffect(() => {
storageListeners.push(reloadValue);
return () => {
storageListeners = storageListeners.filter((l) => l !== reloadValue);
};
}, [reloadValue]);
// Listen for changes in other tabs, and update here too! (This does not
// catch same-page updates!)
React.useEffect(() => {
window.addEventListener("storage", reloadValue);
return () => window.removeEventListener("storage", reloadValue);
}, [reloadValue]);
return [storedValue, setValue];
}
export function loadImage(
rawSrc,
{ crossOrigin = null, preferArchive = false } = {}
) {
const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive });
const image = new Image();
let canceled = false;
let resolved = false;
const promise = new Promise((resolve, reject) => {
image.onload = () => {
if (canceled) return;
resolved = true;
resolve(image);
};
image.onerror = () => {
if (canceled) return;
reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
};
if (crossOrigin) {
image.crossOrigin = crossOrigin;
}
image.src = src;
});
promise.cancel = () => {
// NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel
// resolved images. That's because our approach to cancelation
// mutates the Image object we already returned, which could be
// surprising if the caller is using the Image and expected the
// `cancel` call to only cancel any in-flight network requests.
// (e.g. we cancel a DTI movie when it unloads from the page, but
// it might stick around in the movie cache, and we want those images
// to still work!)
if (resolved) return;
image.src = "";
canceled = true;
};
return promise;
}
/**
* loadable is a wrapper for `@loadable/component`, with extra error handling.
* Loading the page will often fail if you keep a session open during a deploy,
* because Vercel doesn't keep old JS chunks on the CDN. Recover by reloading!
*/
export function loadable(load, options) {
return loadableLibrary(
() =>
load().catch((e) => {
console.error("Error loading page, reloading:", e);
window.location.reload();
// Return a component that renders nothing, while we reload!
return () => null;
}),
options
);
}
/**
* logAndCapture will print an error to the console, and send it to Sentry.
*
* This is useful when there's a graceful recovery path, but it's still a
* genuinely unexpected error worth logging.
*/
export function logAndCapture(e) {
console.error(e);
Sentry.captureException(e);
}
export function getGraphQLErrorMessage(error) {
// If this is a GraphQL Bad Request error, show the message of the first
// error the server returned. Otherwise, just use the normal error message!
return (
error?.networkError?.result?.errors?.[0]?.message || error?.message || null
);
}
export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
// Log the detailed error to the console, so we can have a good debug
// experience without the parent worrying about it!
React.useEffect(() => {
if (error) {
console.error(error);
}
}, [error]);
return (
<Flex justify="center" marginTop="8">
<Grid
templateAreas='"icon title" "icon description" "icon details"'
templateColumns="auto minmax(0, 1fr)"
maxWidth="500px"
marginX="8"
columnGap="4"
>
<Box gridArea="icon" marginTop="2">
<Box
borderRadius="full"
boxShadow="md"
overflow="hidden"
width="100px"
height="100px"
>
<NextImage
src={ErrorGrundoImg}
alt="Distressed Grundo programmer"
width={100}
height={100}
layout="fixed"
/>
</Box>
</Box>
<Box gridArea="title" fontSize="lg" marginBottom="1">
{variant === "unexpected" && <>Ah dang, I broke it 😖</>}
{variant === "network" && <>Oops, it didn't work, sorry 😖</>}
{variant === "not-found" && <>Oops, page not found 😖</>}
</Box>
<Box gridArea="description" marginBottom="2">
{variant === "unexpected" && (
<>
There was an error displaying this page. I'll get info about it
automatically, but you can tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
{variant === "network" && (
<>
There was an error displaying this page. Check your internet
connection and try againand if you keep having trouble, please
tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
{variant === "not-found" && (
<>
We couldn't find this page. Maybe it's been deleted? Check the URL
and try againand if you keep having trouble, please tell me more
at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
</Box>
{error && (
<Box gridArea="details" fontSize="xs" opacity="0.8">
<WarningIcon
marginRight="1.5"
marginTop="-2px"
aria-label="Error message"
/>
"{getGraphQLErrorMessage(error)}"
</Box>
)}
</Grid>
</Flex>
);
}
export function TestErrorSender() {
React.useEffect(() => {
if (window.location.href.includes("send-test-error-for-sentry")) {
throw new Error("Test error for Sentry");
}
});
return null;
}

View File

@ -265,4 +265,5 @@
= include_javascript_libraries :jquery, :swfobject, :jquery_tmpl
= javascript_include_tag 'ajax_auth', 'jquery.jgrowl', 'wardrobe',
'ZeroClipboard.min', 'outfits/edit'
= javascript_include_tag 'wardrobe-2020-page', defer: true

8
bin/dev Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env sh
if ! gem list foreman -i --silent; then
echo "Installing foreman..."
gem install foreman
fi
exec foreman start -f Procfile.dev "$@"

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "app",
"dependencies": {
"@apollo/client": "^3.6.9",
"@auth0/auth0-react": "^1.0.0",
"@chakra-ui/icons": "^1.0.4",
"@chakra-ui/react": "^1.6.0",
"@emotion/react": "^11.1.4",
"@emotion/styled": "^11.0.0",
"@loadable/component": "^5.12.0",
"@sentry/react": "^5.30.0",
"easeljs": "^1.0.2",
"esbuild": "^0.19.0",
"framer-motion": "^4.1.11",
"graphql": "^15.5.0",
"graphql-tag": "^2.12.6",
"immer": "^9.0.6",
"lru-cache": "^6.0.0",
"next": "12.0.2",
"react": "^17.0.1",
"react-autosuggest": "^10.0.2",
"react-dom": "^17.0.1",
"react-icons": "^4.2.0",
"react-transition-group": "^4.3.0",
"tweenjs": "^1.0.2"
},
"scripts": {
"build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --loader:.js=jsx --loader:.png=file --loader:.svg=file"
}
}

BIN
vendor/cache/jsbundling-rails-1.1.2.gem vendored Normal file

Binary file not shown.

Binary file not shown.

3238
yarn.lock Normal file

File diff suppressed because it is too large Load Diff