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.
13
.gitignore
vendored
|
@ -4,3 +4,16 @@ log/*.log
|
||||||
tmp/**/*
|
tmp/**/*
|
||||||
.env
|
.env
|
||||||
.vagrant
|
.vagrant
|
||||||
|
|
||||||
|
/app/assets/builds/*
|
||||||
|
!/app/assets/builds/.keep
|
||||||
|
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
2
Gemfile
|
@ -78,3 +78,5 @@ group :test do
|
||||||
gem 'factory_girl_rails', '~> 4.9'
|
gem 'factory_girl_rails', '~> 4.9'
|
||||||
gem 'rspec-rails', '~> 2.0.0.beta.22'
|
gem 'rspec-rails', '~> 2.0.0.beta.22'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
gem "jsbundling-rails", "~> 1.1"
|
||||||
|
|
|
@ -150,6 +150,8 @@ GEM
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
i18n (1.14.1)
|
i18n (1.14.1)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
|
jsbundling-rails (1.1.2)
|
||||||
|
railties (>= 6.0.0)
|
||||||
launchy (2.5.2)
|
launchy (2.5.2)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
letter_opener (1.8.1)
|
letter_opener (1.8.1)
|
||||||
|
@ -329,6 +331,7 @@ DEPENDENCIES
|
||||||
globalize (~> 6.2, >= 6.2.1)
|
globalize (~> 6.2, >= 6.2.1)
|
||||||
haml (~> 6.1, >= 6.1.1)
|
haml (~> 6.1, >= 6.1.1)
|
||||||
http_accept_language (~> 2.1, >= 2.1.1)
|
http_accept_language (~> 2.1, >= 2.1.1)
|
||||||
|
jsbundling-rails (~> 1.1)
|
||||||
letter_opener (~> 1.8, >= 1.8.1)
|
letter_opener (~> 1.8, >= 1.8.1)
|
||||||
memcache-client (~> 1.8.5)
|
memcache-client (~> 1.8.5)
|
||||||
mysql2 (~> 0.5.5)
|
mysql2 (~> 0.5.5)
|
||||||
|
@ -354,4 +357,4 @@ RUBY VERSION
|
||||||
ruby 3.1.4p223
|
ruby 3.1.4p223
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.4.18
|
2.3.26
|
||||||
|
|
2
Procfile.dev
Normal 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
3
app/javascript/wardrobe-2020-page.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { WardrobePage } from "./wardrobe-2020";
|
||||||
|
|
||||||
|
console.log("Hello, wardrobe page!", WardrobePage);
|
1442
app/javascript/wardrobe-2020/ItemPage.js
Normal file
902
app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js
Normal 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;
|
30
app/javascript/wardrobe-2020/ItemPageDrawer.js
Normal 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;
|
411
app/javascript/wardrobe-2020/ItemPageLayout.js
Normal 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;
|
336
app/javascript/wardrobe-2020/WardrobePage/Item.js
Normal 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);
|
|
@ -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;
|
557
app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js
Normal 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;
|
683
app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js
Normal 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;
|
|
@ -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 incorrectly—but 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
|
||||||
|
design—but 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 incorrectly—but 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;
|
743
app/javascript/wardrobe-2020/WardrobePage/PosePicker.js
Normal 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);
|
80
app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js
Normal 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;
|
284
app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js
Normal 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;
|
479
app/javascript/wardrobe-2020/WardrobePage/SearchToolbar.js
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
198
app/javascript/wardrobe-2020/WardrobePage/index.js
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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×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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 };
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
249
app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js
Normal 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;
|
708
app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js
Normal 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;
|
137
app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js
Normal 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 };
|
||||||
|
}
|
147
app/javascript/wardrobe-2020/components/HTML5Badge.js
Normal 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;
|
97
app/javascript/wardrobe-2020/components/HangerSpinner.js
Normal 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;
|
432
app/javascript/wardrobe-2020/components/ItemCard.js
Normal 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;
|
471
app/javascript/wardrobe-2020/components/OutfitMovieLayer.js
Normal 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;
|
541
app/javascript/wardrobe-2020/components/OutfitPreview.js
Normal 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;
|
21
app/javascript/wardrobe-2020/components/OutfitThumbnail.js
Normal 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;
|
153
app/javascript/wardrobe-2020/components/PaginationToolbar.js
Normal 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;
|
507
app/javascript/wardrobe-2020/components/SpeciesColorPicker.js
Normal 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);
|
458
app/javascript/wardrobe-2020/components/SquareItemCard.js
Normal 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;
|
135
app/javascript/wardrobe-2020/components/getVisibleLayers.js
Normal 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;
|
237
app/javascript/wardrobe-2020/components/useCurrentUser.js
Normal 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;
|
194
app/javascript/wardrobe-2020/components/useOutfitAppearance.js
Normal 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}
|
||||||
|
`;
|
22
app/javascript/wardrobe-2020/components/usePreferArchive.js
Normal 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;
|
BIN
app/javascript/wardrobe-2020/images/error-grundo.png
Normal file
After Width: | Height: | Size: 82 KiB |
1
app/javascript/wardrobe-2020/images/twemoji/cry.svg
Normal 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 KiB |
1
app/javascript/wardrobe-2020/images/twemoji/fem.svg
Normal 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 |
1
app/javascript/wardrobe-2020/images/twemoji/masc.svg
Normal 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 |
1
app/javascript/wardrobe-2020/images/twemoji/question.svg
Normal 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 |
1
app/javascript/wardrobe-2020/images/twemoji/sick.svg
Normal 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 |
1
app/javascript/wardrobe-2020/images/twemoji/smile.svg
Normal 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 |
|
@ -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 |
3
app/javascript/wardrobe-2020/index.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import WardrobePage from "./WardrobePage";
|
||||||
|
|
||||||
|
export { WardrobePage };
|
526
app/javascript/wardrobe-2020/util.js
Normal 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 again—and 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 again—and 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;
|
||||||
|
}
|
|
@ -265,4 +265,5 @@
|
||||||
= include_javascript_libraries :jquery, :swfobject, :jquery_tmpl
|
= include_javascript_libraries :jquery, :swfobject, :jquery_tmpl
|
||||||
= javascript_include_tag 'ajax_auth', 'jquery.jgrowl', 'wardrobe',
|
= javascript_include_tag 'ajax_auth', 'jquery.jgrowl', 'wardrobe',
|
||||||
'ZeroClipboard.min', 'outfits/edit'
|
'ZeroClipboard.min', 'outfits/edit'
|
||||||
|
= javascript_include_tag 'wardrobe-2020-page', defer: true
|
||||||
|
|
||||||
|
|
8
bin/dev
Executable 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
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|