Bundle wardrobe-2020 into the app
We add jsbuilding-rails to get esbuild running in the app, and then we copy-paste the files we need from impress-2020 into here! I stopped at the point where it was building successfully, but it's not running correctly: it's not sure about `process.env` in `next`, and I think the right next step is to delete the NextJS deps altogether and use React Router instead.
This commit is contained in:
parent
0e7bbd526f
commit
81b2a2b4a2
63 changed files with 18135 additions and 1 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -4,3 +4,16 @@ log/*.log
|
|||
tmp/**/*
|
||||
.env
|
||||
.vagrant
|
||||
|
||||
/app/assets/builds/*
|
||||
!/app/assets/builds/.keep
|
||||
|
||||
/node_modules
|
||||
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
|
|
|||
2
Gemfile
2
Gemfile
|
|
@ -78,3 +78,5 @@ group :test do
|
|||
gem 'factory_girl_rails', '~> 4.9'
|
||||
gem 'rspec-rails', '~> 2.0.0.beta.22'
|
||||
end
|
||||
|
||||
gem "jsbundling-rails", "~> 1.1"
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ GEM
|
|||
http_accept_language (2.1.1)
|
||||
i18n (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jsbundling-rails (1.1.2)
|
||||
railties (>= 6.0.0)
|
||||
launchy (2.5.2)
|
||||
addressable (~> 2.8)
|
||||
letter_opener (1.8.1)
|
||||
|
|
@ -329,6 +331,7 @@ DEPENDENCIES
|
|||
globalize (~> 6.2, >= 6.2.1)
|
||||
haml (~> 6.1, >= 6.1.1)
|
||||
http_accept_language (~> 2.1, >= 2.1.1)
|
||||
jsbundling-rails (~> 1.1)
|
||||
letter_opener (~> 1.8, >= 1.8.1)
|
||||
memcache-client (~> 1.8.5)
|
||||
mysql2 (~> 0.5.5)
|
||||
|
|
@ -354,4 +357,4 @@ RUBY VERSION
|
|||
ruby 3.1.4p223
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.18
|
||||
2.3.26
|
||||
|
|
|
|||
2
Procfile.dev
Normal file
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
0
app/assets/builds/.keep
Normal file
3
app/javascript/wardrobe-2020-page.js
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
1442
app/javascript/wardrobe-2020/ItemPage.js
Normal file
File diff suppressed because it is too large
Load diff
902
app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.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
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
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
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
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
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
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
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
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
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
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 | ||||