Bundle wardrobe-2020 into the app
We add jsbuilding-rails to get esbuild running in the app, and then we copy-paste the files we need from impress-2020 into here! I stopped at the point where it was building successfully, but it's not running correctly: it's not sure about `process.env` in `next`, and I think the right next step is to delete the NextJS deps altogether and use React Router instead.
13
.gitignore
vendored
|
@ -4,3 +4,16 @@ log/*.log
|
|||
tmp/**/*
|
||||
.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
|
@ -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
|
@ -0,0 +1,2 @@
|
|||
web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server
|
||||
js: yarn build --watch
|
0
app/assets/builds/.keep
Normal file
3
app/javascript/wardrobe-2020-page.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { WardrobePage } from "./wardrobe-2020";
|
||||
|
||||
console.log("Hello, wardrobe page!", WardrobePage);
|
1442
app/javascript/wardrobe-2020/ItemPage.js
Normal file
902
app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js
Normal file
|
@ -0,0 +1,902 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
useToken,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Flex,
|
||||
} from "@chakra-ui/react";
|
||||
import { WarningTwoIcon } from "@chakra-ui/icons";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
|
||||
function SpeciesFacesPicker({
|
||||
selectedSpeciesId,
|
||||
selectedColorId,
|
||||
compatibleBodies,
|
||||
couldProbablyModelMoreData,
|
||||
onChange,
|
||||
isLoading,
|
||||
}) {
|
||||
// For basic colors (Blue, Green, Red, Yellow), we just use the hardcoded
|
||||
// data, which is part of the bundle and loads super-fast. For other colors,
|
||||
// we load in all the faces of that color, falling back to basic colors when
|
||||
// absent!
|
||||
//
|
||||
// TODO: Could we move this into our `build-cached-data` script, and just do
|
||||
// the query all the time, and have Apollo happen to satisfy it fast?
|
||||
// The semantics of returning our colorful random set could be weird…
|
||||
const selectedColorIsBasic = colorIsBasic(selectedColorId);
|
||||
const {
|
||||
loading: loadingGQL,
|
||||
error,
|
||||
data,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query SpeciesFacesPicker($selectedColorId: ID!) {
|
||||
color(id: $selectedColorId) {
|
||||
id
|
||||
appliedToAllCompatibleSpecies {
|
||||
id
|
||||
neopetsImageHash
|
||||
species {
|
||||
id
|
||||
}
|
||||
body {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { selectedColorId },
|
||||
skip: selectedColorId == null || selectedColorIsBasic,
|
||||
onError: (e) => console.error(e),
|
||||
}
|
||||
);
|
||||
|
||||
const allBodiesAreCompatible = compatibleBodies.some(
|
||||
(body) => body.representsAllBodies
|
||||
);
|
||||
const compatibleBodyIds = compatibleBodies.map((body) => body.id);
|
||||
|
||||
const speciesFacesFromData = data?.color?.appliedToAllCompatibleSpecies || [];
|
||||
|
||||
const allSpeciesFaces = DEFAULT_SPECIES_FACES.map((defaultSpeciesFace) => {
|
||||
const providedSpeciesFace = speciesFacesFromData.find(
|
||||
(f) => f.species.id === defaultSpeciesFace.speciesId
|
||||
);
|
||||
if (providedSpeciesFace) {
|
||||
return {
|
||||
...defaultSpeciesFace,
|
||||
colorId: selectedColorId,
|
||||
bodyId: providedSpeciesFace.body.id,
|
||||
// If this species/color pair exists, but without an image hash, then
|
||||
// we want to provide a face so that it's enabled, but use the fallback
|
||||
// image even though it's wrong, so that it looks like _something_.
|
||||
neopetsImageHash:
|
||||
providedSpeciesFace.neopetsImageHash ||
|
||||
defaultSpeciesFace.neopetsImageHash,
|
||||
};
|
||||
} else {
|
||||
return defaultSpeciesFace;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Wrap spacing="0" justify="center">
|
||||
{allSpeciesFaces.map((speciesFace) => (
|
||||
<WrapItem key={speciesFace.speciesId}>
|
||||
<SpeciesFaceOption
|
||||
speciesId={speciesFace.speciesId}
|
||||
speciesName={speciesFace.speciesName}
|
||||
colorId={speciesFace.colorId}
|
||||
neopetsImageHash={speciesFace.neopetsImageHash}
|
||||
isSelected={speciesFace.speciesId === selectedSpeciesId}
|
||||
// If the face color doesn't match the current color, this is a
|
||||
// fallback face for an invalid species/color pair.
|
||||
isValid={
|
||||
speciesFace.colorId === selectedColorId || selectedColorIsBasic
|
||||
}
|
||||
bodyIsCompatible={
|
||||
allBodiesAreCompatible ||
|
||||
compatibleBodyIds.includes(speciesFace.bodyId)
|
||||
}
|
||||
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
||||
onChange={onChange}
|
||||
isLoading={isLoading || loadingGQL}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
{error && (
|
||||
<Flex
|
||||
color="yellow.500"
|
||||
fontSize="xs"
|
||||
marginTop="1"
|
||||
textAlign="center"
|
||||
width="100%"
|
||||
align="flex-start"
|
||||
justify="center"
|
||||
>
|
||||
<WarningTwoIcon marginTop="0.4em" marginRight="1" />
|
||||
<Box>
|
||||
Error loading this color's pet photos.
|
||||
<br />
|
||||
Check your connection and try again.
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const SpeciesFaceOption = React.memo(
|
||||
({
|
||||
speciesId,
|
||||
speciesName,
|
||||
colorId,
|
||||
neopetsImageHash,
|
||||
isSelected,
|
||||
bodyIsCompatible,
|
||||
isValid,
|
||||
couldProbablyModelMoreData,
|
||||
onChange,
|
||||
isLoading,
|
||||
}) => {
|
||||
const selectedBorderColor = useColorModeValue("green.600", "green.400");
|
||||
const selectedBackgroundColor = useColorModeValue("green.200", "green.600");
|
||||
const focusBorderColor = "blue.400";
|
||||
const focusBackgroundColor = "blue.100";
|
||||
const [
|
||||
selectedBorderColorValue,
|
||||
selectedBackgroundColorValue,
|
||||
focusBorderColorValue,
|
||||
focusBackgroundColorValue,
|
||||
] = useToken("colors", [
|
||||
selectedBorderColor,
|
||||
selectedBackgroundColor,
|
||||
focusBorderColor,
|
||||
focusBackgroundColor,
|
||||
]);
|
||||
const xlShadow = useToken("shadows", "xl");
|
||||
|
||||
const [labelIsHovered, setLabelIsHovered] = React.useState(false);
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
|
||||
const isDisabled = isLoading || !isValid || !bodyIsCompatible;
|
||||
const isHappy = isLoading || (isValid && bodyIsCompatible);
|
||||
const emotionId = isHappy ? "1" : "2";
|
||||
const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer";
|
||||
|
||||
let disabledExplanation = null;
|
||||
if (isLoading) {
|
||||
// If we're still loading, don't try to explain anything yet!
|
||||
} else if (!isValid) {
|
||||
disabledExplanation = "(Can't be this color)";
|
||||
} else if (!bodyIsCompatible) {
|
||||
disabledExplanation = couldProbablyModelMoreData
|
||||
? "(Item needs models)"
|
||||
: "(Not compatible)";
|
||||
}
|
||||
|
||||
const tooltipLabel = (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
{speciesName}
|
||||
{disabledExplanation && (
|
||||
<div style={{ fontStyle: "italic", fontSize: "0.75em" }}>
|
||||
{disabledExplanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// NOTE: Because we render quite a few of these, avoiding using Chakra
|
||||
// elements like Box helps with render performance!
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<DeferredTooltip
|
||||
label={tooltipLabel}
|
||||
placement="top"
|
||||
gutter={-10}
|
||||
// We track hover and focus state manually for the tooltip, so that
|
||||
// keyboard nav to switch between options causes the tooltip to
|
||||
// follow. (By default, the tooltip appears on the first tab focus,
|
||||
// but not when you _change_ options!)
|
||||
isOpen={labelIsHovered || inputIsFocused}
|
||||
>
|
||||
<label
|
||||
style={{ cursor }}
|
||||
onMouseEnter={() => setLabelIsHovered(true)}
|
||||
onMouseLeave={() => setLabelIsHovered(false)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
aria-label={speciesName}
|
||||
name="species-faces-picker"
|
||||
value={speciesId}
|
||||
checked={isSelected}
|
||||
// It's possible to get this selected via the SpeciesColorPicker,
|
||||
// even if this would normally be disabled. If so, make this
|
||||
// option enabled, so keyboard users can focus and change it.
|
||||
disabled={isDisabled && !isSelected}
|
||||
onChange={() => onChange({ speciesId, colorId })}
|
||||
onFocus={() => setInputIsFocused(true)}
|
||||
onBlur={() => setInputIsFocused(false)}
|
||||
className={css`
|
||||
/* Copied from Chakra's <VisuallyHidden /> */
|
||||
border: 0px;
|
||||
clip: rect(0px, 0px, 0px, 0px);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
className={css`
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
input:checked + & {
|
||||
background: ${selectedBackgroundColorValue};
|
||||
border-radius: 6px;
|
||||
box-shadow: ${xlShadow},
|
||||
${selectedBorderColorValue} 0 0 2px 2px;
|
||||
transform: scale(1.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
input:focus + & {
|
||||
background: ${focusBackgroundColorValue};
|
||||
box-shadow: ${xlShadow}, ${focusBorderColorValue} 0 0 0 3px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<CrossFadeImage
|
||||
src={`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png`}
|
||||
srcSet={
|
||||
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
|
||||
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/6.png 2x`
|
||||
}
|
||||
alt={speciesName}
|
||||
width={55}
|
||||
height={55}
|
||||
data-is-loading={isLoading}
|
||||
data-is-disabled={isDisabled}
|
||||
className={css`
|
||||
filter: saturate(90%);
|
||||
opacity: 0.9;
|
||||
transition: all 0.2s;
|
||||
|
||||
&[data-is-disabled="true"] {
|
||||
filter: saturate(0%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&[data-is-loading="true"] {
|
||||
animation: 0.8s linear 0s infinite alternate none running
|
||||
pulse;
|
||||
}
|
||||
|
||||
input:checked + * &[data-body-is-disabled="false"] {
|
||||
opacity: 1;
|
||||
filter: saturate(110%);
|
||||
}
|
||||
|
||||
input:checked + * &[data-body-is-disabled="true"] {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Alt text for when the image fails to load! We hide it
|
||||
* while still loading though! */
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
&:-moz-loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
&:-moz-broken {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</DeferredTooltip>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* CrossFadeImage is like <img>, but listens for successful load events, and
|
||||
* fades from the previous image to the new image once it loads.
|
||||
*
|
||||
* We treat `src` as a unique key representing the image's identity, but we
|
||||
* also carry along the rest of the props during the fade, like `srcSet` and
|
||||
* `className`.
|
||||
*/
|
||||
function CrossFadeImage(incomingImageProps) {
|
||||
const [prevImageProps, setPrevImageProps] = React.useState(null);
|
||||
const [currentImageProps, setCurrentImageProps] = React.useState(null);
|
||||
|
||||
const incomingImageIsCurrentImage =
|
||||
incomingImageProps.src === currentImageProps?.src;
|
||||
|
||||
const onLoadNextImage = () => {
|
||||
setPrevImageProps(currentImageProps);
|
||||
setCurrentImageProps(incomingImageProps);
|
||||
};
|
||||
|
||||
// The main trick to this component is using React's `key` feature! When
|
||||
// diffing the rendered tree, if React sees two nodes with the same `key`, it
|
||||
// treats them as the same node and makes the prop changes to match.
|
||||
//
|
||||
// We usually use this in `.map`, to make sure that adds/removes in a list
|
||||
// don't cause our children to shift around and swap their React state or DOM
|
||||
// nodes with each other.
|
||||
//
|
||||
// But here, we use `key` to get React to transition the same <img> DOM node
|
||||
// between 3 different states!
|
||||
//
|
||||
// The image starts its life as the last in the list, from
|
||||
// `incomingImageProps`: it's invisible, and still loading. We use its `src`
|
||||
// as the `key`.
|
||||
//
|
||||
// When it loads, we update the state so that this `key` now belongs to the
|
||||
// _second_ node, from `currentImageProps`. React will see this and make the
|
||||
// correct transition for us: it sets opacity to 0, sets z-index to 2,
|
||||
// removes aria-hidden, and removes the `onLoad` handler.
|
||||
//
|
||||
// Then, when another image is ready to show, we update the state so that
|
||||
// this key now belongs to the _first_ node, from `prevImageProps` (and the
|
||||
// second node is showing something new). React sees this, and makes the
|
||||
// transition back to invisibility, but without the `onLoad` handler this
|
||||
// time! (And transitions the current image into view, like it did for this
|
||||
// one.)
|
||||
//
|
||||
// Finally, when yet _another_ image is ready to show, we stop rendering any
|
||||
// images with this key anymore, and so React unmounts the image entirely.
|
||||
//
|
||||
// Thanks, React, for handling our multiple overlapping transitions through
|
||||
// this little state machine! This could have been a LOT harder to write,
|
||||
// whew!
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
display: grid;
|
||||
grid-template-areas: "shared-overlapping-area";
|
||||
isolation: isolate; /* Avoid z-index conflicts with parent! */
|
||||
|
||||
> div {
|
||||
grid-area: shared-overlapping-area;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{prevImageProps && (
|
||||
<div
|
||||
key={prevImageProps.src}
|
||||
className={css`
|
||||
z-index: 3;
|
||||
opacity: 0;
|
||||
`}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element */}
|
||||
<img {...prevImageProps} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentImageProps && (
|
||||
<div
|
||||
key={currentImageProps.src}
|
||||
className={css`
|
||||
z-index: 2;
|
||||
opacity: 1;
|
||||
`}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element */}
|
||||
<img
|
||||
{...currentImageProps}
|
||||
// If the current image _is_ the incoming image, we'll allow
|
||||
// new props to come in and affect it. But if it's a new image
|
||||
// incoming, we want to stick to the last props the current
|
||||
// image had! (This matters for e.g. `bodyIsCompatible`
|
||||
// becoming true in `SpeciesFaceOption` and restoring color,
|
||||
// before the new color's image loads in.)
|
||||
{...(incomingImageIsCurrentImage ? incomingImageProps : {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!incomingImageIsCurrentImage && (
|
||||
<div
|
||||
key={incomingImageProps.src}
|
||||
className={css`
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
`}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element */}
|
||||
<img
|
||||
{...incomingImageProps}
|
||||
aria-hidden
|
||||
onLoad={onLoadNextImage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is
|
||||
* true before mounting it, and unmounts it after closing.
|
||||
*
|
||||
* This can drastically improve render performance when there are lots of
|
||||
* tooltip targets to re-render… but it comes with some limitations, like the
|
||||
* extra requirement to control `isOpen`, and some additional DOM structure!
|
||||
*/
|
||||
function DeferredTooltip({ children, isOpen, ...props }) {
|
||||
const [shouldShowTooltip, setShouldShowToolip] = React.useState(isOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShouldShowToolip(true);
|
||||
} else {
|
||||
const timeoutId = setTimeout(() => setShouldShowToolip(false), 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
{shouldShowTooltip && (
|
||||
<Tooltip isOpen={isOpen} {...props}>
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
// HACK: I'm just hardcoding all this, rather than connecting up to the
|
||||
// database and adding a loading state. Tbh I'm not sure it's a good idea
|
||||
// to load this dynamically until we have SSR to make it come in fast!
|
||||
// And it's not so bad if this gets out of sync with the database,
|
||||
// because the SpeciesColorPicker will still be usable!
|
||||
const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" };
|
||||
|
||||
export function colorIsBasic(colorId) {
|
||||
return ["8", "34", "61", "84"].includes(colorId);
|
||||
}
|
||||
|
||||
const DEFAULT_SPECIES_FACES = [
|
||||
{
|
||||
speciesName: "Acara",
|
||||
speciesId: "1",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "93",
|
||||
neopetsImageHash: "obxdjm88",
|
||||
},
|
||||
{
|
||||
speciesName: "Aisha",
|
||||
speciesId: "2",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "106",
|
||||
neopetsImageHash: "n9ozx4z5",
|
||||
},
|
||||
{
|
||||
speciesName: "Blumaroo",
|
||||
speciesId: "3",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "47",
|
||||
neopetsImageHash: "kfonqhdc",
|
||||
},
|
||||
{
|
||||
speciesName: "Bori",
|
||||
speciesId: "4",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "84",
|
||||
neopetsImageHash: "sc2hhvhn",
|
||||
},
|
||||
{
|
||||
speciesName: "Bruce",
|
||||
speciesId: "5",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "146",
|
||||
neopetsImageHash: "wqz8xn4t",
|
||||
},
|
||||
{
|
||||
speciesName: "Buzz",
|
||||
speciesId: "6",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "250",
|
||||
neopetsImageHash: "jc9klfxm",
|
||||
},
|
||||
{
|
||||
speciesName: "Chia",
|
||||
speciesId: "7",
|
||||
colorId: colors.RED,
|
||||
bodyId: "212",
|
||||
neopetsImageHash: "4lrb4n3f",
|
||||
},
|
||||
{
|
||||
speciesName: "Chomby",
|
||||
speciesId: "8",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "74",
|
||||
neopetsImageHash: "bdml26md",
|
||||
},
|
||||
{
|
||||
speciesName: "Cybunny",
|
||||
speciesId: "9",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "94",
|
||||
neopetsImageHash: "xl6msllv",
|
||||
},
|
||||
{
|
||||
speciesName: "Draik",
|
||||
speciesId: "10",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "132",
|
||||
neopetsImageHash: "bob39shq",
|
||||
},
|
||||
{
|
||||
speciesName: "Elephante",
|
||||
speciesId: "11",
|
||||
colorId: colors.RED,
|
||||
bodyId: "56",
|
||||
neopetsImageHash: "jhhhbrww",
|
||||
},
|
||||
{
|
||||
speciesName: "Eyrie",
|
||||
speciesId: "12",
|
||||
colorId: colors.RED,
|
||||
bodyId: "90",
|
||||
neopetsImageHash: "6kngmhvs",
|
||||
},
|
||||
{
|
||||
speciesName: "Flotsam",
|
||||
speciesId: "13",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "136",
|
||||
neopetsImageHash: "47vt32x2",
|
||||
},
|
||||
{
|
||||
speciesName: "Gelert",
|
||||
speciesId: "14",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "138",
|
||||
neopetsImageHash: "5nrd2lvd",
|
||||
},
|
||||
{
|
||||
speciesName: "Gnorbu",
|
||||
speciesId: "15",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "166",
|
||||
neopetsImageHash: "6c275jcg",
|
||||
},
|
||||
{
|
||||
speciesName: "Grarrl",
|
||||
speciesId: "16",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "119",
|
||||
neopetsImageHash: "j7q65fv4",
|
||||
},
|
||||
{
|
||||
speciesName: "Grundo",
|
||||
speciesId: "17",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "126",
|
||||
neopetsImageHash: "5xn4kjf8",
|
||||
},
|
||||
{
|
||||
speciesName: "Hissi",
|
||||
speciesId: "18",
|
||||
colorId: colors.RED,
|
||||
bodyId: "67",
|
||||
neopetsImageHash: "jsfvcqwt",
|
||||
},
|
||||
{
|
||||
speciesName: "Ixi",
|
||||
speciesId: "19",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "163",
|
||||
neopetsImageHash: "w32r74vo",
|
||||
},
|
||||
{
|
||||
speciesName: "Jetsam",
|
||||
speciesId: "20",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "147",
|
||||
neopetsImageHash: "kz43rnld",
|
||||
},
|
||||
{
|
||||
speciesName: "Jubjub",
|
||||
speciesId: "21",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "80",
|
||||
neopetsImageHash: "m267j935",
|
||||
},
|
||||
{
|
||||
speciesName: "Kacheek",
|
||||
speciesId: "22",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "117",
|
||||
neopetsImageHash: "4gsrb59g",
|
||||
},
|
||||
{
|
||||
speciesName: "Kau",
|
||||
speciesId: "23",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "201",
|
||||
neopetsImageHash: "ktlxmrtr",
|
||||
},
|
||||
{
|
||||
speciesName: "Kiko",
|
||||
speciesId: "24",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "51",
|
||||
neopetsImageHash: "42j5q3zx",
|
||||
},
|
||||
{
|
||||
speciesName: "Koi",
|
||||
speciesId: "25",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "208",
|
||||
neopetsImageHash: "ncfn87wk",
|
||||
},
|
||||
{
|
||||
speciesName: "Korbat",
|
||||
speciesId: "26",
|
||||
colorId: colors.RED,
|
||||
bodyId: "196",
|
||||
neopetsImageHash: "omx9c876",
|
||||
},
|
||||
{
|
||||
speciesName: "Kougra",
|
||||
speciesId: "27",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "143",
|
||||
neopetsImageHash: "rfsbh59t",
|
||||
},
|
||||
{
|
||||
speciesName: "Krawk",
|
||||
speciesId: "28",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "150",
|
||||
neopetsImageHash: "hxgsm5d4",
|
||||
},
|
||||
{
|
||||
speciesName: "Kyrii",
|
||||
speciesId: "29",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "175",
|
||||
neopetsImageHash: "blxmjgbk",
|
||||
},
|
||||
{
|
||||
speciesName: "Lenny",
|
||||
speciesId: "30",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "173",
|
||||
neopetsImageHash: "8r94jhfq",
|
||||
},
|
||||
{
|
||||
speciesName: "Lupe",
|
||||
speciesId: "31",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "199",
|
||||
neopetsImageHash: "z42535zh",
|
||||
},
|
||||
{
|
||||
speciesName: "Lutari",
|
||||
speciesId: "32",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "52",
|
||||
neopetsImageHash: "qgg6z8s7",
|
||||
},
|
||||
{
|
||||
speciesName: "Meerca",
|
||||
speciesId: "33",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "109",
|
||||
neopetsImageHash: "kk2nn2jr",
|
||||
},
|
||||
{
|
||||
speciesName: "Moehog",
|
||||
speciesId: "34",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "134",
|
||||
neopetsImageHash: "jgkoro5z",
|
||||
},
|
||||
{
|
||||
speciesName: "Mynci",
|
||||
speciesId: "35",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "95",
|
||||
neopetsImageHash: "xwlo9657",
|
||||
},
|
||||
{
|
||||
speciesName: "Nimmo",
|
||||
speciesId: "36",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "96",
|
||||
neopetsImageHash: "bx7fho8x",
|
||||
},
|
||||
{
|
||||
speciesName: "Ogrin",
|
||||
speciesId: "37",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "154",
|
||||
neopetsImageHash: "rjzmx24v",
|
||||
},
|
||||
{
|
||||
speciesName: "Peophin",
|
||||
speciesId: "38",
|
||||
colorId: colors.RED,
|
||||
bodyId: "55",
|
||||
neopetsImageHash: "kokc52kh",
|
||||
},
|
||||
{
|
||||
speciesName: "Poogle",
|
||||
speciesId: "39",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "76",
|
||||
neopetsImageHash: "fw6lvf3c",
|
||||
},
|
||||
{
|
||||
speciesName: "Pteri",
|
||||
speciesId: "40",
|
||||
colorId: colors.RED,
|
||||
bodyId: "156",
|
||||
neopetsImageHash: "tjhwbro3",
|
||||
},
|
||||
{
|
||||
speciesName: "Quiggle",
|
||||
speciesId: "41",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "78",
|
||||
neopetsImageHash: "jdto7mj4",
|
||||
},
|
||||
{
|
||||
speciesName: "Ruki",
|
||||
speciesId: "42",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "191",
|
||||
neopetsImageHash: "qsgbm5f6",
|
||||
},
|
||||
{
|
||||
speciesName: "Scorchio",
|
||||
speciesId: "43",
|
||||
colorId: colors.RED,
|
||||
bodyId: "187",
|
||||
neopetsImageHash: "hkjoncsx",
|
||||
},
|
||||
{
|
||||
speciesName: "Shoyru",
|
||||
speciesId: "44",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "46",
|
||||
neopetsImageHash: "mmvn4tkg",
|
||||
},
|
||||
{
|
||||
speciesName: "Skeith",
|
||||
speciesId: "45",
|
||||
colorId: colors.RED,
|
||||
bodyId: "178",
|
||||
neopetsImageHash: "fc4cxk3t",
|
||||
},
|
||||
{
|
||||
speciesName: "Techo",
|
||||
speciesId: "46",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "100",
|
||||
neopetsImageHash: "84gvowmj",
|
||||
},
|
||||
{
|
||||
speciesName: "Tonu",
|
||||
speciesId: "47",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "130",
|
||||
neopetsImageHash: "jd433863",
|
||||
},
|
||||
{
|
||||
speciesName: "Tuskaninny",
|
||||
speciesId: "48",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "188",
|
||||
neopetsImageHash: "q39wn6vq",
|
||||
},
|
||||
{
|
||||
speciesName: "Uni",
|
||||
speciesId: "49",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "257",
|
||||
neopetsImageHash: "njzvoflw",
|
||||
},
|
||||
{
|
||||
speciesName: "Usul",
|
||||
speciesId: "50",
|
||||
colorId: colors.RED,
|
||||
bodyId: "206",
|
||||
neopetsImageHash: "rox4mgh5",
|
||||
},
|
||||
{
|
||||
speciesName: "Vandagyre",
|
||||
speciesId: "55",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "306",
|
||||
neopetsImageHash: "xkntzsww",
|
||||
},
|
||||
{
|
||||
speciesName: "Wocky",
|
||||
speciesId: "51",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "101",
|
||||
neopetsImageHash: "dnr2kj4b",
|
||||
},
|
||||
{
|
||||
speciesName: "Xweetok",
|
||||
speciesId: "52",
|
||||
colorId: colors.RED,
|
||||
bodyId: "68",
|
||||
neopetsImageHash: "tdkqr2b6",
|
||||
},
|
||||
{
|
||||
speciesName: "Yurble",
|
||||
speciesId: "53",
|
||||
colorId: colors.RED,
|
||||
bodyId: "182",
|
||||
neopetsImageHash: "h95cs547",
|
||||
},
|
||||
{
|
||||
speciesName: "Zafara",
|
||||
speciesId: "54",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "180",
|
||||
neopetsImageHash: "x8c57g2l",
|
||||
},
|
||||
];
|
||||
|
||||
export default SpeciesFacesPicker;
|
30
app/javascript/wardrobe-2020/ItemPageDrawer.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
DrawerOverlay,
|
||||
useBreakpointValue,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { ItemPageContent } from "./ItemPage";
|
||||
|
||||
function ItemPageDrawer({ item, isOpen, onClose }) {
|
||||
const placement = useBreakpointValue({ base: "bottom", lg: "right" });
|
||||
|
||||
return (
|
||||
<Drawer placement={placement} size="md" isOpen={isOpen} onClose={onClose}>
|
||||
<DrawerOverlay>
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerBody>
|
||||
<ItemPageContent itemId={item.id} isEmbedded />
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</DrawerOverlay>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemPageDrawer;
|
411
app/javascript/wardrobe-2020/ItemPageLayout.js
Normal file
|
@ -0,0 +1,411 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Flex,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Portal,
|
||||
Select,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
useToast,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { ExternalLinkIcon, ChevronRightIcon } from "@chakra-ui/icons";
|
||||
import { gql, useMutation } from "@apollo/client";
|
||||
|
||||
import {
|
||||
ItemBadgeList,
|
||||
ItemKindBadge,
|
||||
ItemThumbnail,
|
||||
} from "./components/ItemCard";
|
||||
import { Heading1 } from "./util";
|
||||
|
||||
import useSupport from "./WardrobePage/support/useSupport";
|
||||
|
||||
function ItemPageLayout({ children, item, isEmbedded }) {
|
||||
return (
|
||||
<Box>
|
||||
<ItemPageHeader item={item} isEmbedded={isEmbedded} />
|
||||
<Box>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageHeader({ item, isEmbedded }) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
width="100%"
|
||||
>
|
||||
<SubtleSkeleton isLoaded={item?.thumbnailUrl} marginRight="4">
|
||||
<ItemThumbnail item={item} size="lg" isActive flex="0 0 auto" />
|
||||
</SubtleSkeleton>
|
||||
<Box>
|
||||
<SubtleSkeleton isLoaded={item?.name}>
|
||||
<Heading1
|
||||
lineHeight="1.1"
|
||||
// Nudge down the size a bit in the embed case, to better fit the
|
||||
// tighter layout!
|
||||
size={isEmbedded ? "xl" : "2xl"}
|
||||
>
|
||||
{item?.name || "Item name here"}
|
||||
</Heading1>
|
||||
</SubtleSkeleton>
|
||||
<ItemPageBadges item={item} isEmbedded={isEmbedded} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SubtleSkeleton hides the skeleton animation until a second has passed, and
|
||||
* doesn't fade in the content if it loads near-instantly. This helps avoid
|
||||
* flash-of-content stuff!
|
||||
*
|
||||
* For plain Skeletons, we often use <Delay><Skeleton /></Delay> instead. But
|
||||
* that pattern doesn't work as well for wrapper skeletons where we're using
|
||||
* placeholder content for layout: we don't want the delay if the content
|
||||
* really _is_ present!
|
||||
*/
|
||||
export function SubtleSkeleton({ isLoaded, ...props }) {
|
||||
const [shouldFadeIn, setShouldFadeIn] = React.useState(false);
|
||||
const [shouldShowSkeleton, setShouldShowSkeleton] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (!isLoaded) {
|
||||
setShouldFadeIn(true);
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setShouldShowSkeleton(true), 500);
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
fadeDuration={shouldFadeIn ? undefined : 0}
|
||||
startColor={shouldShowSkeleton ? undefined : "transparent"}
|
||||
endColor={shouldShowSkeleton ? undefined : "transparent"}
|
||||
isLoaded={isLoaded}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageBadges({ item, isEmbedded }) {
|
||||
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
|
||||
|
||||
return (
|
||||
<ItemBadgeList marginTop="1">
|
||||
<SubtleSkeleton isLoaded={item?.isNc != null}>
|
||||
<ItemKindBadgeWithSupportTools item={item} />
|
||||
</SubtleSkeleton>
|
||||
{
|
||||
// If the createdAt date is null (loaded and empty), hide the badge.
|
||||
item?.createdAt !== null && (
|
||||
<SubtleSkeleton
|
||||
// Distinguish between undefined (still loading) and null (loaded and
|
||||
// empty).
|
||||
isLoaded={item?.createdAt !== undefined}
|
||||
>
|
||||
<Badge
|
||||
display="block"
|
||||
minWidth="5.25em"
|
||||
boxSizing="content-box"
|
||||
textAlign="center"
|
||||
>
|
||||
{item?.createdAt && <ShortTimestamp when={item?.createdAt} />}
|
||||
</Badge>
|
||||
</SubtleSkeleton>
|
||||
)
|
||||
}
|
||||
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
||||
<LinkBadge
|
||||
href={`https://impress.openneo.net/items/${item?.id}`}
|
||||
isEmbedded={isEmbedded}
|
||||
>
|
||||
Classic DTI
|
||||
</LinkBadge>
|
||||
</SubtleSkeleton>
|
||||
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
||||
<LinkBadge
|
||||
href={
|
||||
"https://items.jellyneo.net/search/?name=" +
|
||||
encodeURIComponent(item?.name) +
|
||||
"&name_type=3"
|
||||
}
|
||||
isEmbedded={isEmbedded}
|
||||
>
|
||||
Jellyneo
|
||||
</LinkBadge>
|
||||
</SubtleSkeleton>
|
||||
{item?.isNc && (
|
||||
<SubtleSkeleton
|
||||
isLoaded={
|
||||
// Distinguish between undefined (still loading) and null (loaded
|
||||
// and empty).
|
||||
item?.ncTradeValueText !== undefined
|
||||
}
|
||||
>
|
||||
{item?.ncTradeValueText && (
|
||||
<LinkBadge href="http://www.neopets.com/~owls">
|
||||
OWLS: {item?.ncTradeValueText}
|
||||
</LinkBadge>
|
||||
)}
|
||||
</SubtleSkeleton>
|
||||
)}
|
||||
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
||||
{!item?.isNc && !item?.isPb && (
|
||||
<LinkBadge
|
||||
href={
|
||||
"http://www.neopets.com/shops/wizard.phtml?string=" +
|
||||
encodeURIComponent(item?.name)
|
||||
}
|
||||
isEmbedded={isEmbedded}
|
||||
>
|
||||
Shop Wiz
|
||||
</LinkBadge>
|
||||
)}
|
||||
</SubtleSkeleton>
|
||||
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
||||
{!item?.isNc && !item?.isPb && (
|
||||
<LinkBadge
|
||||
href={
|
||||
"http://www.neopets.com/portal/supershopwiz.phtml?string=" +
|
||||
encodeURIComponent(item?.name)
|
||||
}
|
||||
isEmbedded={isEmbedded}
|
||||
>
|
||||
Super Wiz
|
||||
</LinkBadge>
|
||||
)}
|
||||
</SubtleSkeleton>
|
||||
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
||||
{!item?.isNc && !item?.isPb && (
|
||||
<LinkBadge
|
||||
href={
|
||||
"http://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&search_string=" +
|
||||
encodeURIComponent(item?.name)
|
||||
}
|
||||
isEmbedded={isEmbedded}
|
||||
>
|
||||
Trade Post
|
||||
</LinkBadge>
|
||||
)}
|
||||
</SubtleSkeleton>
|
||||
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
||||
{!item?.isNc && !item?.isPb && (
|
||||
<LinkBadge
|
||||
href={
|
||||
"http://www.neopets.com/genie.phtml?type=process_genie&criteria=exact&auctiongenie=" +
|
||||
encodeURIComponent(item?.name)
|
||||
}
|
||||
isEmbedded={isEmbedded}
|
||||
>
|
||||
Auctions
|
||||
</LinkBadge>
|
||||
)}
|
||||
</SubtleSkeleton>
|
||||
</ItemBadgeList>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemKindBadgeWithSupportTools({ item }) {
|
||||
const { isSupportUser, supportSecret } = useSupport();
|
||||
const toast = useToast();
|
||||
|
||||
const ncRef = React.useRef(null);
|
||||
|
||||
const isNcAutoDetectedFromRarity =
|
||||
item?.rarityIndex === 500 || item?.rarityIndex === 0;
|
||||
|
||||
const [mutate, { loading }] = useMutation(gql`
|
||||
mutation ItemPageSupportSetIsManuallyNc(
|
||||
$itemId: ID!
|
||||
$isManuallyNc: Boolean!
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setItemIsManuallyNc(
|
||||
itemId: $itemId
|
||||
isManuallyNc: $isManuallyNc
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
isNc
|
||||
isManuallyNc
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
if (
|
||||
isSupportUser &&
|
||||
item?.rarityIndex != null &&
|
||||
item?.isManuallyNc != null
|
||||
) {
|
||||
// TODO: Could code-split this into a SupportOnly file...
|
||||
return (
|
||||
<Popover placement="bottom-start" initialFocusRef={ncRef} showArrow>
|
||||
<PopoverTrigger>
|
||||
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} isEditButton />
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent padding="4">
|
||||
<PopoverArrow />
|
||||
<VStack spacing="2" align="flex-start">
|
||||
<Flex align="center">
|
||||
<Box as="span" fontWeight="600" marginRight="2">
|
||||
NC:
|
||||
</Box>
|
||||
<Select
|
||||
ref={ncRef}
|
||||
size="xs"
|
||||
value={item.isManuallyNc ? "true" : "false"}
|
||||
onChange={(e) => {
|
||||
const isManuallyNc = e.target.value === "true";
|
||||
mutate({
|
||||
variables: {
|
||||
itemId: item.id,
|
||||
isManuallyNc,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
setItemIsManuallyNc: {
|
||||
__typename: "Item",
|
||||
id: item.id,
|
||||
isNc: isManuallyNc || isNcAutoDetectedFromRarity,
|
||||
isManuallyNc,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
status: "error",
|
||||
title:
|
||||
"Could not set NC status for this item. Try again?",
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="false">
|
||||
Auto-detect: {isNcAutoDetectedFromRarity ? "Yes" : "No"}.{" "}
|
||||
(Rarity {item.rarityIndex})
|
||||
</option>
|
||||
<option value="true">Manually set: Yes.</option>
|
||||
</Select>
|
||||
{loading && <Spinner size="sm" marginLeft="2" />}
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<Box as="span" fontWeight="600" marginRight="1">
|
||||
PB:
|
||||
</Box>
|
||||
<Select size="xs" isReadOnly value="auto-detect">
|
||||
<option value="auto-detect">
|
||||
Auto-detect: {item.isPb ? "Yes" : "No"}. (from description)
|
||||
</option>
|
||||
<option style={{ fontStyle: "italic" }}>
|
||||
(This cannot be manually set.)
|
||||
</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
<Badge
|
||||
colorScheme="pink"
|
||||
alignSelf="flex-end"
|
||||
marginBottom="-2"
|
||||
marginRight="-2"
|
||||
>
|
||||
Support <span aria-hidden="true">💖</span>
|
||||
</Badge>
|
||||
</VStack>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return <ItemKindBadge isNc={item?.isNc} isPb={item?.isPb} />;
|
||||
}
|
||||
|
||||
const LinkBadge = React.forwardRef(
|
||||
({ children, href, isEmbedded, ...props }, ref) => {
|
||||
return (
|
||||
<Badge
|
||||
ref={ref}
|
||||
as="a"
|
||||
href={href}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
// Normally we want to act like a normal webpage, and treat links as
|
||||
// normal. But when we're on the wardrobe page, we want to avoid
|
||||
// disrupting the outfit, and open in a new window instead.
|
||||
target={isEmbedded ? "_blank" : undefined}
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{
|
||||
// We also change the icon to signal whether this will launch in a new
|
||||
// window or not!
|
||||
isEmbedded ? (
|
||||
<ExternalLinkIcon marginLeft="1" />
|
||||
) : (
|
||||
<ChevronRightIcon />
|
||||
)
|
||||
}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const fullDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "long",
|
||||
});
|
||||
const monthYearFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const monthDayYearFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
function ShortTimestamp({ when }) {
|
||||
const date = new Date(when);
|
||||
|
||||
// To find the start of last month, take today, then set its date to the 1st
|
||||
// and its time to midnight (the start of this month), and subtract one
|
||||
// month. (JS handles negative months and rolls them over correctly.)
|
||||
const startOfLastMonth = new Date();
|
||||
startOfLastMonth.setDate(1);
|
||||
startOfLastMonth.setHours(0);
|
||||
startOfLastMonth.setMinutes(0);
|
||||
startOfLastMonth.setSeconds(0);
|
||||
startOfLastMonth.setMilliseconds(0);
|
||||
startOfLastMonth.setMonth(startOfLastMonth.getMonth() - 1);
|
||||
|
||||
const dateIsOlderThanLastMonth = date < startOfLastMonth;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={`First seen on ${fullDateFormatter.format(date)}`}
|
||||
placement="top"
|
||||
openDelay={400}
|
||||
>
|
||||
{dateIsOlderThanLastMonth
|
||||
? monthYearFormatter.format(date)
|
||||
: monthDayYearFormatter.format(date)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemPageLayout;
|
336
app/javascript/wardrobe-2020/WardrobePage/Item.js
Normal file
|
@ -0,0 +1,336 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
|
||||
import Link from "next/link";
|
||||
import { loadable } from "../util";
|
||||
|
||||
import {
|
||||
ItemCardContent,
|
||||
ItemBadgeList,
|
||||
ItemKindBadge,
|
||||
MaybeAnimatedBadge,
|
||||
YouOwnThisBadge,
|
||||
YouWantThisBadge,
|
||||
getZoneBadges,
|
||||
} from "../components/ItemCard";
|
||||
import SupportOnly from "./support/SupportOnly";
|
||||
import useSupport from "./support/useSupport";
|
||||
|
||||
const LoadableItemPageDrawer = loadable(() => import("../ItemPageDrawer"));
|
||||
const LoadableItemSupportDrawer = loadable(() =>
|
||||
import("./support/ItemSupportDrawer")
|
||||
);
|
||||
|
||||
/**
|
||||
* Item show a basic summary of an item, in the context of the current outfit!
|
||||
*
|
||||
* It also responds to the focus state of an `input` as its previous sibling.
|
||||
* This will be an invisible radio/checkbox that controls the actual wear
|
||||
* state.
|
||||
*
|
||||
* In fact, this component can't trigger wear or unwear events! When you click
|
||||
* it in the app, you're actually clicking a <label> that wraps the radio or
|
||||
* checkbox. Similarly, the parent provides the `onRemove` callback for the
|
||||
* Remove button.
|
||||
*
|
||||
* NOTE: This component is memoized with React.memo. It's surpisingly expensive
|
||||
* to re-render, because Chakra components are a lil bit expensive from
|
||||
* their internal complexity, and we have a lot of them here. And it can
|
||||
* add up when there's a lot of Items in the list. This contributes to
|
||||
* wearing/unwearing items being noticeably slower on lower-power
|
||||
* devices.
|
||||
*/
|
||||
function Item({
|
||||
item,
|
||||
itemNameId,
|
||||
isWorn,
|
||||
isInOutfit,
|
||||
onRemove,
|
||||
isDisabled = false,
|
||||
}) {
|
||||
const [infoDrawerIsOpen, setInfoDrawerIsOpen] = React.useState(false);
|
||||
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ItemContainer isDisabled={isDisabled}>
|
||||
<Box flex="1 1 0" minWidth="0">
|
||||
<ItemCardContent
|
||||
item={item}
|
||||
badges={<ItemBadges item={item} />}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={isWorn}
|
||||
isDiabled={isDisabled}
|
||||
focusSelector={containerHasFocus}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex="0 0 auto" marginTop="5px">
|
||||
{isInOutfit && (
|
||||
<ItemActionButton
|
||||
icon={<DeleteIcon />}
|
||||
label="Remove"
|
||||
onClick={(e) => {
|
||||
onRemove(item.id);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<EditIcon />}
|
||||
label="Support"
|
||||
onClick={(e) => {
|
||||
setSupportDrawerIsOpen(true);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<InfoIcon />}
|
||||
label="More info"
|
||||
to={`/items/${item.id}`}
|
||||
onClick={(e) => {
|
||||
const willProbablyOpenInNewTab =
|
||||
e.metaKey || e.shiftKey || e.altKey || e.ctrlKey;
|
||||
if (willProbablyOpenInNewTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInfoDrawerIsOpen(true);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</ItemContainer>
|
||||
<LoadableItemPageDrawer
|
||||
item={item}
|
||||
isOpen={infoDrawerIsOpen}
|
||||
onClose={() => setInfoDrawerIsOpen(false)}
|
||||
/>
|
||||
<SupportOnly>
|
||||
<LoadableItemSupportDrawer
|
||||
item={item}
|
||||
isOpen={supportDrawerIsOpen}
|
||||
onClose={() => setSupportDrawerIsOpen(false)}
|
||||
/>
|
||||
</SupportOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemSkeleton is a placeholder for when an Item is loading.
|
||||
*/
|
||||
function ItemSkeleton() {
|
||||
return (
|
||||
<ItemContainer isDisabled>
|
||||
<Skeleton width="50px" height="50px" />
|
||||
<Box width="3" />
|
||||
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
|
||||
</ItemContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemContainer is the outermost element of an `Item`.
|
||||
*
|
||||
* It provides spacing, but also is responsible for a number of hover/focus/etc
|
||||
* styles - including for its children, who sometimes reference it as an
|
||||
* .item-container parent!
|
||||
*/
|
||||
function ItemContainer({ children, isDisabled = false }) {
|
||||
const theme = useTheme();
|
||||
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["100"],
|
||||
theme.colors.gray["700"]
|
||||
);
|
||||
|
||||
const activeBorderColor = useColorModeValue(
|
||||
theme.colors.green["400"],
|
||||
theme.colors.green["500"]
|
||||
);
|
||||
|
||||
const focusCheckedBorderColor = useColorModeValue(
|
||||
theme.colors.green["800"],
|
||||
theme.colors.green["300"]
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
p="1"
|
||||
my="1"
|
||||
borderRadius="lg"
|
||||
d="flex"
|
||||
cursor={isDisabled ? undefined : "pointer"}
|
||||
border="1px"
|
||||
borderColor="transparent"
|
||||
className={cx([
|
||||
"item-container",
|
||||
!isDisabled &&
|
||||
css`
|
||||
&:hover,
|
||||
input:focus + & {
|
||||
background-color: ${focusBackgroundColor};
|
||||
}
|
||||
|
||||
input:active + & {
|
||||
border-color: ${activeBorderColor};
|
||||
}
|
||||
|
||||
input:checked:focus + & {
|
||||
border-color: ${focusCheckedBorderColor};
|
||||
}
|
||||
`,
|
||||
])}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemBadges({ item }) {
|
||||
const { isSupportUser } = useSupport();
|
||||
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
|
||||
const restrictedZones = item.appearanceOn.restrictedZones.filter(
|
||||
(z) => z.isCommonlyUsedByItems
|
||||
);
|
||||
const isMaybeAnimated = item.appearanceOn.layers.some(
|
||||
(l) => l.canvasMovieLibraryUrl
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemBadgeList>
|
||||
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
|
||||
{
|
||||
// This badge is unreliable, but it's helpful for looking for animated
|
||||
// items to test, so we show it only to support. We use this form
|
||||
// instead of <SupportOnly />, to avoid adding extra badge list spacing
|
||||
// on the additional empty child.
|
||||
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
|
||||
}
|
||||
{getZoneBadges(occupiedZones, { variant: "occupies" })}
|
||||
{getZoneBadges(restrictedZones, { variant: "restricts" })}
|
||||
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
|
||||
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
|
||||
</ItemBadgeList>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemActionButton is one of a list of actions a user can take for this item.
|
||||
*/
|
||||
function ItemActionButton({ icon, label, to, onClick }) {
|
||||
const theme = useTheme();
|
||||
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["300"],
|
||||
theme.colors.gray["800"]
|
||||
);
|
||||
const focusColor = useColorModeValue(
|
||||
theme.colors.gray["700"],
|
||||
theme.colors.gray["200"]
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Tooltip label={label} placement="top">
|
||||
<LinkOrButton
|
||||
component={IconButton}
|
||||
href={to}
|
||||
icon={icon}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
onClick={onClick}
|
||||
className={css`
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
${containerHasFocus} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: ${focusBackgroundColor};
|
||||
color: ${focusColor};
|
||||
}
|
||||
|
||||
/* On touch devices, always show the buttons! This avoids having to
|
||||
* tap to reveal them (which toggles the item), or worse,
|
||||
* accidentally tapping a hidden button without realizing! */
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkOrButton({ href, component = Button, ...props }) {
|
||||
const ButtonComponent = component;
|
||||
if (href != null) {
|
||||
return (
|
||||
<Link href={href} passHref>
|
||||
<ButtonComponent as="a" {...props} />
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return <ButtonComponent {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemListContainer is a container for Item components! Wrap your Item
|
||||
* components in this to ensure a consistent list layout.
|
||||
*/
|
||||
export function ItemListContainer({ children, ...props }) {
|
||||
return (
|
||||
<Flex direction="column" {...props}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemListSkeleton is a placeholder for when an ItemListContainer and its
|
||||
* Items are loading.
|
||||
*/
|
||||
export function ItemListSkeleton({ count, ...props }) {
|
||||
return (
|
||||
<ItemListContainer {...props}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<ItemSkeleton key={i} />
|
||||
))}
|
||||
</ItemListContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* containerHasFocus is a common CSS selector, for the case where our parent
|
||||
* .item-container is hovered or the adjacent hidden radio/checkbox is
|
||||
* focused.
|
||||
*/
|
||||
const containerHasFocus =
|
||||
".item-container:hover &, input:focus + .item-container &";
|
||||
|
||||
export default React.memo(Item);
|
|
@ -0,0 +1,92 @@
|
|||
import React from "react";
|
||||
import { Box, Flex, useBreakpointValue } from "@chakra-ui/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import ItemsPanel from "./ItemsPanel";
|
||||
import SearchToolbar, { searchQueryIsEmpty } from "./SearchToolbar";
|
||||
import SearchPanel from "./SearchPanel";
|
||||
import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
|
||||
|
||||
/**
|
||||
* ItemsAndSearchPanels manages the shared layout and state for:
|
||||
* - ItemsPanel, which shows the items in the outfit now, and
|
||||
* - SearchPanel, which helps you find new items to add.
|
||||
*
|
||||
* These panels don't share a _lot_ of concerns; they're mainly intertwined by
|
||||
* the fact that they share the SearchToolbar at the top!
|
||||
*
|
||||
* We try to keep the search concerns in the search components, by avoiding
|
||||
* letting any actual _logic_ live at the root here; and instead just
|
||||
* performing some wiring to help them interact with each other via simple
|
||||
* state and refs.
|
||||
*/
|
||||
function ItemsAndSearchPanels({
|
||||
loading,
|
||||
searchQuery,
|
||||
onChangeSearchQuery,
|
||||
outfitState,
|
||||
outfitSaving,
|
||||
dispatchToOutfit,
|
||||
}) {
|
||||
const scrollContainerRef = React.useRef();
|
||||
const searchQueryRef = React.useRef();
|
||||
const firstSearchResultRef = React.useRef();
|
||||
|
||||
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
|
||||
const [canUseSearchFooter] = useLocalStorage(
|
||||
"DTIFeatureFlagCanUseSearchFooter",
|
||||
false
|
||||
);
|
||||
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Flex direction="column" height="100%">
|
||||
{isShowingSearchFooter && <Box height="2" />}
|
||||
{!isShowingSearchFooter && (
|
||||
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
|
||||
<SearchToolbar
|
||||
query={searchQuery}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
onChange={onChangeSearchQuery}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
|
||||
<Box
|
||||
key="search-panel"
|
||||
flex="1 0 0"
|
||||
position="relative"
|
||||
overflowY="scroll"
|
||||
ref={scrollContainerRef}
|
||||
data-test-id="search-panel-scroll-container"
|
||||
>
|
||||
<SearchPanel
|
||||
query={searchQuery}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box position="relative" overflow="auto" key="items-panel">
|
||||
<Box px="4" py="2">
|
||||
<ItemsPanel
|
||||
loading={loading}
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemsAndSearchPanels;
|
557
app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js
Normal file
|
@ -0,0 +1,557 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Editable,
|
||||
EditablePreview,
|
||||
EditableInput,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Portal,
|
||||
Button,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
ModalCloseButton,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
CheckIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
QuestionIcon,
|
||||
WarningTwoIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
||||
import {
|
||||
Delay,
|
||||
ErrorMessage,
|
||||
getGraphQLErrorMessage,
|
||||
Heading1,
|
||||
Heading2,
|
||||
} from "../util";
|
||||
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
||||
import { BiRename } from "react-icons/bi";
|
||||
import { IoCloudUploadOutline } from "react-icons/io5";
|
||||
import { MdMoreVert } from "react-icons/md";
|
||||
import { buildOutfitUrl } from "./useOutfitState";
|
||||
import { gql, useMutation } from "@apollo/client";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
/**
|
||||
* ItemsPanel shows the items in the current outfit, and lets the user toggle
|
||||
* between them! It also shows an editable outfit name, to help set context.
|
||||
*
|
||||
* Note that this component provides an effective 1 unit of padding around
|
||||
* itself, which is uncommon in this app: we usually prefer to let parents
|
||||
* control the spacing!
|
||||
*
|
||||
* This is because Item has padding, but it's generally not visible; so, to
|
||||
* *look* aligned with the other elements like the headings, the headings need
|
||||
* to have extra padding. Essentially: while the Items _do_ stretch out the
|
||||
* full width of the container, it doesn't look like it!
|
||||
*/
|
||||
function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
|
||||
const { zonesAndItems, incompatibleItems } = outfitState;
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box>
|
||||
<Box px="1">
|
||||
<OutfitHeading
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
<Flex direction="column">
|
||||
{loading ? (
|
||||
<ItemZoneGroupsSkeleton
|
||||
itemCount={outfitState.allItemIds.length}
|
||||
/>
|
||||
) : (
|
||||
<TransitionGroup component={null}>
|
||||
{zonesAndItems.map(({ zoneLabel, items }) => (
|
||||
<CSSTransition
|
||||
key={zoneLabel}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
<ItemZoneGroup
|
||||
zoneLabel={zoneLabel}
|
||||
items={items}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</CSSTransition>
|
||||
))}
|
||||
{incompatibleItems.length > 0 && (
|
||||
<ItemZoneGroup
|
||||
zoneLabel="Incompatible"
|
||||
afterHeader={
|
||||
<Tooltip
|
||||
label="These items don't fit this pet"
|
||||
placement="top"
|
||||
openDelay={100}
|
||||
>
|
||||
<QuestionIcon fontSize="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
items={incompatibleItems}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
</TransitionGroup>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemZoneGroup shows the items for a particular zone, and lets the user
|
||||
* toggle between them.
|
||||
*
|
||||
* For each item, it renders a <label> with a visually-hidden radio button and
|
||||
* the Item component (which will visually reflect the radio's state). This
|
||||
* makes the list screen-reader- and keyboard-accessible!
|
||||
*/
|
||||
function ItemZoneGroup({
|
||||
zoneLabel,
|
||||
items,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
isDisabled = false,
|
||||
afterHeader = null,
|
||||
}) {
|
||||
// onChange is fired when the radio button becomes checked, not unchecked!
|
||||
const onChange = (e) => {
|
||||
const itemId = e.target.value;
|
||||
dispatchToOutfit({ type: "wearItem", itemId });
|
||||
};
|
||||
|
||||
// Clicking the radio button when already selected deselects it - this is how
|
||||
// you can select none!
|
||||
const onClick = (e) => {
|
||||
const itemId = e.target.value;
|
||||
if (outfitState.wornItemIds.includes(itemId)) {
|
||||
// We need the event handler to finish before this, so that simulated
|
||||
// events don't just come back around and undo it - but we can't just
|
||||
// solve that with `preventDefault`, because it breaks the radio's
|
||||
// intended visual updates when we unwear. So, we `setTimeout` to do it
|
||||
// after all event handlers resolve!
|
||||
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = React.useCallback(
|
||||
(itemId) => {
|
||||
dispatchToOutfit({ type: "removeItem", itemId });
|
||||
},
|
||||
[dispatchToOutfit]
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box mb="10">
|
||||
<Heading2 display="flex" alignItems="center" mx="1">
|
||||
{zoneLabel}
|
||||
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
|
||||
</Heading2>
|
||||
<ItemListContainer>
|
||||
<TransitionGroup component={null}>
|
||||
{items.map((item) => {
|
||||
const itemNameId =
|
||||
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
|
||||
const itemNode = (
|
||||
<Item
|
||||
item={item}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={
|
||||
!isDisabled && outfitState.wornItemIds.includes(item.id)
|
||||
}
|
||||
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
||||
onRemove={onRemove}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={item.id}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
itemNode
|
||||
) : (
|
||||
<label>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="radio"
|
||||
aria-labelledby={itemNameId}
|
||||
name={zoneLabel}
|
||||
value={item.id}
|
||||
checked={outfitState.wornItemIds.includes(item.id)}
|
||||
onChange={onChange}
|
||||
onClick={onClick}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{itemNode}
|
||||
</label>
|
||||
)}
|
||||
</CSSTransition>
|
||||
);
|
||||
})}
|
||||
</TransitionGroup>
|
||||
</ItemListContainer>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemZoneGroupSkeletons is a placeholder for when the items are loading.
|
||||
*
|
||||
* We try to match the approximate size of the incoming data! This is
|
||||
* especially nice for when you start with a fresh pet from the homepage, so
|
||||
* we don't show skeleton items that just clear away!
|
||||
*/
|
||||
function ItemZoneGroupsSkeleton({ itemCount }) {
|
||||
const groups = [];
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
// NOTE: I initially wrote this to return groups of 3, which looks good for
|
||||
// outfit shares I think, but looks bad for pet loading... once shares
|
||||
// become a more common use case, it might be useful to figure out how
|
||||
// to differentiate these cases and show 1-per-group for pets, but
|
||||
// maybe more for built outfits?
|
||||
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
|
||||
*/
|
||||
function ItemZoneGroupSkeleton({ itemCount }) {
|
||||
return (
|
||||
<Box mb="10">
|
||||
<Delay>
|
||||
<Skeleton
|
||||
mx="1"
|
||||
// 2.25rem font size, 1.25rem line height
|
||||
height={`${2.25 * 1.25}rem`}
|
||||
width="12rem"
|
||||
/>
|
||||
<ItemListSkeleton count={itemCount} />
|
||||
</Delay>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OutfitSavingIndicator shows a Save button, or the "Saved" or "Saving" state,
|
||||
* if the user can save this outfit. If not, this is empty!
|
||||
*/
|
||||
function OutfitSavingIndicator({ outfitSaving }) {
|
||||
const {
|
||||
canSaveOutfit,
|
||||
isNewOutfit,
|
||||
isSaving,
|
||||
latestVersionIsSaved,
|
||||
saveError,
|
||||
saveOutfit,
|
||||
} = outfitSaving;
|
||||
|
||||
const errorTextColor = useColorModeValue("red.600", "red.400");
|
||||
|
||||
if (!canSaveOutfit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNewOutfit) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
loadingText="Saving…"
|
||||
leftIcon={
|
||||
<Box
|
||||
// Adjust the visual balance toward the cloud
|
||||
marginBottom="-2px"
|
||||
>
|
||||
<IoCloudUploadOutline />
|
||||
</Box>
|
||||
}
|
||||
onClick={saveOutfit}
|
||||
data-test-id="wardrobe-save-outfit-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSaving) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saving-indicator"
|
||||
>
|
||||
<Spinner
|
||||
size="xs"
|
||||
marginRight="1.5"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saving…
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (latestVersionIsSaved) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saved-indicator"
|
||||
>
|
||||
<CheckIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saved
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (saveError) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-save-error-indicator"
|
||||
color={errorTextColor}
|
||||
>
|
||||
<WarningTwoIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Error saving
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// The most common way we'll hit this null is when the outfit is changing,
|
||||
// but the debouncing isn't done yet, so it's not saving yet.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OutfitHeading is an editable outfit name, as a big pretty page heading!
|
||||
* It also contains the outfit menu, for saving etc.
|
||||
*/
|
||||
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
|
||||
const { canDeleteOutfit } = outfitSaving;
|
||||
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
|
||||
|
||||
return (
|
||||
// The Editable wraps everything, including the menu, because the menu has
|
||||
// a Rename option.
|
||||
<Editable
|
||||
// Make sure not to ever pass `undefined` into here, or else the
|
||||
// component enters uncontrolled mode, and changing the value
|
||||
// later won't fix it!
|
||||
value={outfitState.name || ""}
|
||||
placeholder="Untitled outfit"
|
||||
onChange={(value) =>
|
||||
dispatchToOutfit({ type: "rename", outfitName: value })
|
||||
}
|
||||
>
|
||||
{({ onEdit }) => (
|
||||
<Flex align="center" marginBottom="6">
|
||||
<Box>
|
||||
<Box role="group" d="inline-block" position="relative" width="100%">
|
||||
<Heading1>
|
||||
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
|
||||
<EditableInput lineHeight="48px" />
|
||||
</Heading1>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
<Box flex="0 0 auto">
|
||||
<OutfitSavingIndicator outfitSaving={outfitSaving} />
|
||||
</Box>
|
||||
<Box width="2" />
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
variant="ghost"
|
||||
icon={<MdMoreVert />}
|
||||
aria-label="Outfit menu"
|
||||
borderRadius="full"
|
||||
fontSize="24px"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
{outfitState.id && (
|
||||
<MenuItem
|
||||
icon={<EditIcon />}
|
||||
as="a"
|
||||
href={outfitCopyUrl}
|
||||
target="_blank"
|
||||
>
|
||||
Edit a copy
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<BiRename />}
|
||||
onClick={() => {
|
||||
// Start the rename after a tick, so finishing up the click
|
||||
// won't just immediately remove focus from the Editable.
|
||||
setTimeout(onEdit, 0);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</MenuItem>
|
||||
{canDeleteOutfit && (
|
||||
<DeleteOutfitMenuItem outfitState={outfitState} />
|
||||
)}
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Flex>
|
||||
)}
|
||||
</Editable>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteOutfitMenuItem({ outfitState }) {
|
||||
const { id, name } = outfitState;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { push: pushHistory } = useRouter();
|
||||
|
||||
const [sendDeleteOutfitMutation, { loading, error }] = useMutation(
|
||||
gql`
|
||||
mutation DeleteOutfitMenuItem($id: ID!) {
|
||||
deleteOutfit(id: $id)
|
||||
}
|
||||
`,
|
||||
{
|
||||
context: { sendAuth: true },
|
||||
update(cache) {
|
||||
// Once this is deleted, evict it from the local cache, and "garbage
|
||||
// collect" to force all queries referencing this outfit to reload the
|
||||
// next time we see them. (This is especially important since we're
|
||||
// about to redirect to the user outfits page, which shouldn't show
|
||||
// the outfit anymore!)
|
||||
cache.evict(`Outfit:${id}`);
|
||||
cache.gc();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
We'll delete this data and remove it from your list of outfits.
|
||||
Links and image embeds pointing to this outfit will break. Is that
|
||||
okay?
|
||||
{error && (
|
||||
<ErrorMessage marginTop="1em">
|
||||
Error deleting outfit: "{getGraphQLErrorMessage(error)}". Try
|
||||
again?
|
||||
</ErrorMessage>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>No, keep this outfit</Button>
|
||||
<Box flex="1 0 auto" width="2" />
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={() =>
|
||||
sendDeleteOutfitMutation({ variables: { id } })
|
||||
.then(() => {
|
||||
pushHistory(`/your-outfits`);
|
||||
})
|
||||
.catch((e) => {
|
||||
/* handled in error UI */
|
||||
})
|
||||
}
|
||||
isLoading={loading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the
|
||||
* fade-out and height decrease when an Item or ItemZoneGroup is removed.
|
||||
*
|
||||
* Note that this _cannot_ be implemented as a wrapper component that returns a
|
||||
* CSSTransition. This is because the CSSTransition must be the direct child of
|
||||
* the TransitionGroup, and a wrapper breaks the parent-child relationship.
|
||||
*
|
||||
* See react-transition-group docs for more info!
|
||||
*/
|
||||
const fadeOutAndRollUpTransition = (css) => ({
|
||||
classNames: css`
|
||||
&-exit {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&-exit-active {
|
||||
opacity: 0;
|
||||
height: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
`,
|
||||
timeout: 500,
|
||||
onExit: (e) => {
|
||||
e.style.height = e.offsetHeight + "px";
|
||||
},
|
||||
});
|
||||
|
||||
export default ItemsPanel;
|
683
app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js
Normal file
|
@ -0,0 +1,683 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
DarkMode,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
IconButton,
|
||||
ListItem,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Portal,
|
||||
Stack,
|
||||
Switch,
|
||||
Tooltip,
|
||||
UnorderedList,
|
||||
useClipboard,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ArrowBackIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
SettingsIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { MdPause, MdPlayArrow } from "react-icons/md";
|
||||
import Link from "next/link";
|
||||
|
||||
import { getBestImageUrlForLayer } from "../components/OutfitPreview";
|
||||
import HTML5Badge, { layerUsesHTML5 } from "../components/HTML5Badge";
|
||||
import PosePicker from "./PosePicker";
|
||||
import SpeciesColorPicker from "../components/SpeciesColorPicker";
|
||||
import { loadImage, useLocalStorage } from "../util";
|
||||
import useCurrentUser from "../components/useCurrentUser";
|
||||
import useOutfitAppearance from "../components/useOutfitAppearance";
|
||||
import OutfitKnownGlitchesBadge from "./OutfitKnownGlitchesBadge";
|
||||
import usePreferArchive from "../components/usePreferArchive";
|
||||
|
||||
/**
|
||||
* OutfitControls is the set of controls layered over the outfit preview, to
|
||||
* control things like species/color and sharing links!
|
||||
*/
|
||||
function OutfitControls({
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
showAnimationControls,
|
||||
appearance,
|
||||
}) {
|
||||
const [focusIsLocked, setFocusIsLocked] = React.useState(false);
|
||||
const onLockFocus = React.useCallback(
|
||||
() => setFocusIsLocked(true),
|
||||
[setFocusIsLocked]
|
||||
);
|
||||
const onUnlockFocus = React.useCallback(
|
||||
() => setFocusIsLocked(false),
|
||||
[setFocusIsLocked]
|
||||
);
|
||||
|
||||
// HACK: As of 1.0.0-rc.0, Chakra's `toast` function rebuilds unnecessarily,
|
||||
// which triggers unnecessary rebuilds of the `onSpeciesColorChange`
|
||||
// callback, which causes the `React.memo` on `SpeciesColorPicker` to
|
||||
// fail, which harms performance. But it seems to work just fine if we
|
||||
// hold onto the first copy of the function we get! :/
|
||||
const _toast = useToast();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const toast = React.useMemo(() => _toast, []);
|
||||
|
||||
const onSpeciesColorChange = React.useCallback(
|
||||
(species, color, isValid, closestPose) => {
|
||||
if (isValid) {
|
||||
dispatchToOutfit({
|
||||
type: "setSpeciesAndColor",
|
||||
speciesId: species.id,
|
||||
colorId: color.id,
|
||||
pose: closestPose,
|
||||
});
|
||||
} else {
|
||||
// NOTE: This shouldn't be possible to trigger, because the
|
||||
// `stateMustAlwaysBeValid` prop should prevent it. But we have
|
||||
// it as a fallback, just in case!
|
||||
toast({
|
||||
title: `We haven't seen a ${color.name} ${species.name} before! 😓`,
|
||||
status: "warning",
|
||||
});
|
||||
}
|
||||
},
|
||||
[dispatchToOutfit, toast]
|
||||
);
|
||||
|
||||
const maybeUnlockFocus = (e) => {
|
||||
// We lock focus when a touch-device user taps the area. When they tap
|
||||
// empty space, we treat that as a toggle and release the focus lock.
|
||||
if (e.target === e.currentTarget) {
|
||||
onUnlockFocus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
role="group"
|
||||
pos="absolute"
|
||||
left="0"
|
||||
right="0"
|
||||
top="0"
|
||||
bottom="0"
|
||||
height="100%" // Required for Safari to size the grid correctly
|
||||
padding={{ base: 2, lg: 6 }}
|
||||
display="grid"
|
||||
overflow="auto"
|
||||
gridTemplateAreas={`"back play-pause sharing"
|
||||
"space space space"
|
||||
"picker picker picker"`}
|
||||
gridTemplateRows="auto minmax(1rem, 1fr) auto"
|
||||
className={cx(
|
||||
css`
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:focus-within,
|
||||
&.focus-is-locked {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ignore simulated hovers, only reveal for _real_ hovers. This helps
|
||||
* us avoid state conflicts with the focus-lock from clicks. */
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
focusIsLocked && "focus-is-locked"
|
||||
)}
|
||||
onClickCapture={(e) => {
|
||||
const opacity = parseFloat(
|
||||
getComputedStyle(e.currentTarget).opacity
|
||||
);
|
||||
if (opacity < 0.5) {
|
||||
// If the controls aren't visible right now, then clicks on them are
|
||||
// probably accidental. Ignore them! (We prevent default to block
|
||||
// built-in behaviors like link nav, and we stop propagation to block
|
||||
// our own custom click handlers. I don't know if I can prevent the
|
||||
// select clicks though?)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// We also show the controls, by locking focus. We'll undo this when
|
||||
// the user taps elsewhere (because it will trigger a blur event from
|
||||
// our child components), in `maybeUnlockFocus`.
|
||||
setFocusIsLocked(true);
|
||||
}
|
||||
}}
|
||||
onContextMenuCapture={() => {
|
||||
if (!toast.isActive("outfit-controls-context-menu-hint")) {
|
||||
toast({
|
||||
id: "outfit-controls-context-menu-hint",
|
||||
title:
|
||||
"By the way, to save this image, use the Download button!",
|
||||
description: "It's in the top right of the preview area.",
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
data-test-id="wardrobe-outfit-controls"
|
||||
>
|
||||
<Box gridArea="back" onClick={maybeUnlockFocus}>
|
||||
<BackButton outfitState={outfitState} />
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
gridArea="play-pause"
|
||||
// HACK: Better visual centering with other controls
|
||||
paddingTop="0.3rem"
|
||||
direction="column"
|
||||
align="center"
|
||||
>
|
||||
{showAnimationControls && <PlayPauseButton />}
|
||||
<Box height="2" />
|
||||
<HStack spacing="2" align="center" justify="center">
|
||||
<OutfitHTML5Badge appearance={appearance} />
|
||||
<OutfitKnownGlitchesBadge appearance={appearance} />
|
||||
<SettingsButton
|
||||
onLockFocus={onLockFocus}
|
||||
onUnlockFocus={onUnlockFocus}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Stack
|
||||
gridArea="sharing"
|
||||
alignSelf="flex-end"
|
||||
spacing={{ base: "2", lg: "4" }}
|
||||
align="flex-end"
|
||||
onClick={maybeUnlockFocus}
|
||||
>
|
||||
<Box>
|
||||
<DownloadButton outfitState={outfitState} />
|
||||
</Box>
|
||||
<Box>
|
||||
<CopyLinkButton outfitState={outfitState} />
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box gridArea="space" onClick={maybeUnlockFocus} />
|
||||
{outfitState.speciesId && outfitState.colorId && (
|
||||
<Flex gridArea="picker" justify="center" onClick={maybeUnlockFocus}>
|
||||
{/**
|
||||
* We try to center the species/color picker, but the left spacer will
|
||||
* shrink more than the pose picker container if we run out of space!
|
||||
*/}
|
||||
<Flex
|
||||
flex="1 1 0"
|
||||
paddingRight="3"
|
||||
align="center"
|
||||
justify="flex-end"
|
||||
/>
|
||||
<Box flex="0 0 auto">
|
||||
<DarkMode>
|
||||
<SpeciesColorPicker
|
||||
speciesId={outfitState.speciesId}
|
||||
colorId={outfitState.colorId}
|
||||
idealPose={outfitState.pose}
|
||||
onChange={onSpeciesColorChange}
|
||||
stateMustAlwaysBeValid
|
||||
speciesTestId="wardrobe-species-picker"
|
||||
colorTestId="wardrobe-color-picker"
|
||||
/>
|
||||
</DarkMode>
|
||||
</Box>
|
||||
<Flex flex="1 1 0" align="center" pl="2">
|
||||
<PosePicker
|
||||
speciesId={outfitState.speciesId}
|
||||
colorId={outfitState.colorId}
|
||||
pose={outfitState.pose}
|
||||
appearanceId={outfitState.appearanceId}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
onLockFocus={onLockFocus}
|
||||
onUnlockFocus={onUnlockFocus}
|
||||
data-test-id="wardrobe-pose-picker"
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function OutfitHTML5Badge({ appearance }) {
|
||||
const petIsUsingHTML5 =
|
||||
appearance.petAppearance?.layers.every(layerUsesHTML5);
|
||||
|
||||
const itemsNotUsingHTML5 = appearance.items.filter((item) =>
|
||||
item.appearance.layers.some((l) => !layerUsesHTML5(l))
|
||||
);
|
||||
itemsNotUsingHTML5.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const usesHTML5 = petIsUsingHTML5 && itemsNotUsingHTML5.length === 0;
|
||||
|
||||
let tooltipLabel;
|
||||
if (usesHTML5) {
|
||||
tooltipLabel = (
|
||||
<>This outfit is converted to HTML5, and ready to use on Neopets.com!</>
|
||||
);
|
||||
} else {
|
||||
tooltipLabel = (
|
||||
<Box>
|
||||
<Box as="p">
|
||||
This outfit isn't converted to HTML5 yet, so it might not appear in
|
||||
Neopets.com customization yet. Once it's ready, it could look a bit
|
||||
different than our temporary preview here. It might even be animated!
|
||||
</Box>
|
||||
{!petIsUsingHTML5 && (
|
||||
<Box as="p" marginTop="1em" fontWeight="bold">
|
||||
This pet is not yet converted.
|
||||
</Box>
|
||||
)}
|
||||
{itemsNotUsingHTML5.length > 0 && (
|
||||
<>
|
||||
<Box as="header" marginTop="1em" fontWeight="bold">
|
||||
The following items aren't yet converted:
|
||||
</Box>
|
||||
<UnorderedList>
|
||||
{itemsNotUsingHTML5.map((item) => (
|
||||
<ListItem key={item.id}>{item.name}</ListItem>
|
||||
))}
|
||||
</UnorderedList>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HTML5Badge
|
||||
usesHTML5={usesHTML5}
|
||||
isLoading={appearance.loading}
|
||||
tooltipLabel={tooltipLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BackButton takes you back home, or to Your Outfits if this outfit is yours.
|
||||
*/
|
||||
function BackButton({ outfitState }) {
|
||||
const currentUser = useCurrentUser();
|
||||
const outfitBelongsToCurrentUser =
|
||||
outfitState.creator && outfitState.creator.id === currentUser.id;
|
||||
|
||||
return (
|
||||
<Link href={outfitBelongsToCurrentUser ? "/your-outfits" : "/"} passHref>
|
||||
<ControlButton
|
||||
as="a"
|
||||
icon={<ArrowBackIcon />}
|
||||
aria-label="Leave this outfit"
|
||||
d="inline-flex" // Not sure why <a> requires this to style right! ^^`
|
||||
data-test-id="wardrobe-nav-back-button"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DownloadButton downloads the outfit as an image!
|
||||
*/
|
||||
function DownloadButton({ outfitState }) {
|
||||
const { visibleLayers } = useOutfitAppearance(outfitState);
|
||||
|
||||
const [downloadImageUrl, prepareDownload] =
|
||||
useDownloadableImage(visibleLayers);
|
||||
|
||||
return (
|
||||
<Tooltip label="Download" placement="left">
|
||||
<Box>
|
||||
<ControlButton
|
||||
icon={<DownloadIcon />}
|
||||
aria-label="Download"
|
||||
as="a"
|
||||
// eslint-disable-next-line no-script-url
|
||||
href={downloadImageUrl || "#"}
|
||||
onClick={(e) => {
|
||||
if (!downloadImageUrl) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
download={(outfitState.name || "Outfit") + ".png"}
|
||||
onMouseEnter={prepareDownload}
|
||||
onFocus={prepareDownload}
|
||||
cursor={!downloadImageUrl && "wait"}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CopyLinkButton copies the outfit URL to the clipboard!
|
||||
*/
|
||||
function CopyLinkButton({ outfitState }) {
|
||||
const { onCopy, hasCopied } = useClipboard(outfitState.url);
|
||||
|
||||
return (
|
||||
<Tooltip label={hasCopied ? "Copied!" : "Copy link"} placement="left">
|
||||
<Box>
|
||||
<ControlButton
|
||||
icon={hasCopied ? <CheckIcon /> : <LinkIcon />}
|
||||
aria-label="Copy link"
|
||||
onClick={onCopy}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayPauseButton() {
|
||||
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
||||
|
||||
// We show an intro animation if this mounts while paused. Whereas if we're
|
||||
// not paused, we initialize as if we had already finished.
|
||||
const [blinkInState, setBlinkInState] = React.useState(
|
||||
isPaused ? { type: "ready" } : { type: "done" }
|
||||
);
|
||||
const buttonRef = React.useRef(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (blinkInState.type === "ready" && buttonRef.current) {
|
||||
setBlinkInState({
|
||||
type: "started",
|
||||
position: {
|
||||
left: buttonRef.current.offsetLeft,
|
||||
top: buttonRef.current.offsetTop,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [blinkInState, setBlinkInState]);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<>
|
||||
<PlayPauseButtonContent
|
||||
isPaused={isPaused}
|
||||
setIsPaused={setIsPaused}
|
||||
ref={buttonRef}
|
||||
/>
|
||||
{blinkInState.type === "started" && (
|
||||
<Portal>
|
||||
<PlayPauseButtonContent
|
||||
isPaused={isPaused}
|
||||
setIsPaused={setIsPaused}
|
||||
position="absolute"
|
||||
left={blinkInState.position.left}
|
||||
top={blinkInState.position.top}
|
||||
backgroundColor="gray.600"
|
||||
borderColor="gray.50"
|
||||
color="gray.50"
|
||||
onAnimationEnd={() => setBlinkInState({ type: "done" })}
|
||||
// Don't disrupt the hover state of the controls! (And the button
|
||||
// doesn't seem to click correctly, not sure why, but instead of
|
||||
// debugging I'm adding this :p)
|
||||
pointerEvents="none"
|
||||
className={css`
|
||||
@keyframes fade-in-out {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
opacity: 0;
|
||||
animation: fade-in-out 2s;
|
||||
`}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
const PlayPauseButtonContent = React.forwardRef(
|
||||
({ isPaused, setIsPaused, ...props }, ref) => {
|
||||
return (
|
||||
<TranslucentButton
|
||||
ref={ref}
|
||||
leftIcon={isPaused ? <MdPause /> : <MdPlayArrow />}
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
{...props}
|
||||
>
|
||||
{isPaused ? <>Paused</> : <>Playing</>}
|
||||
</TranslucentButton>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function SettingsButton({ onLockFocus, onUnlockFocus }) {
|
||||
return (
|
||||
<Popover onOpen={onLockFocus} onClose={onUnlockFocus}>
|
||||
<PopoverTrigger>
|
||||
<TranslucentButton size="xs" aria-label="Settings">
|
||||
<SettingsIcon />
|
||||
<Box width="1" />
|
||||
<ChevronDownIcon />
|
||||
</TranslucentButton>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent width="25ch">
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<HiResModeSetting />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function HiResModeSetting() {
|
||||
const [hiResMode, setHiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const [preferArchive, setPreferArchive] = usePreferArchive();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<FormControl>
|
||||
<Flex>
|
||||
<Box>
|
||||
<FormLabel htmlFor="hi-res-mode-setting" fontSize="sm" margin="0">
|
||||
Hi-res mode (SVG)
|
||||
</FormLabel>
|
||||
<FormHelperText marginTop="0" fontSize="xs">
|
||||
Crisper at higher resolutions, but not always accurate
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
<Box width="2" />
|
||||
<Switch
|
||||
id="hi-res-mode-setting"
|
||||
size="sm"
|
||||
marginTop="0.1rem"
|
||||
isChecked={hiResMode}
|
||||
onChange={(e) => setHiResMode(e.target.checked)}
|
||||
/>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
<Box height="2" />
|
||||
<FormControl>
|
||||
<Flex>
|
||||
<Box>
|
||||
<FormLabel
|
||||
htmlFor="prefer-archive-setting"
|
||||
fontSize="sm"
|
||||
margin="0"
|
||||
>
|
||||
Use DTI's image archive
|
||||
</FormLabel>
|
||||
<FormHelperText marginTop="0" fontSize="xs">
|
||||
Turn this on when images.neopets.com is slow!
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
<Box width="2" />
|
||||
<Switch
|
||||
id="prefer-archive-setting"
|
||||
size="sm"
|
||||
marginTop="0.1rem"
|
||||
isChecked={preferArchive ?? false}
|
||||
onChange={(e) => setPreferArchive(e.target.checked)}
|
||||
/>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const TranslucentButton = React.forwardRef(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
size="sm"
|
||||
color="gray.100"
|
||||
variant="outline"
|
||||
borderColor="gray.200"
|
||||
borderRadius="full"
|
||||
backgroundColor="blackAlpha.600"
|
||||
boxShadow="md"
|
||||
_hover={{
|
||||
backgroundColor: "gray.600",
|
||||
borderColor: "gray.50",
|
||||
color: "gray.50",
|
||||
}}
|
||||
_focus={{
|
||||
backgroundColor: "gray.600",
|
||||
borderColor: "gray.50",
|
||||
color: "gray.50",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ControlButton is a UI helper to render the cute round buttons we use in
|
||||
* OutfitControls!
|
||||
*/
|
||||
function ControlButton({ icon, "aria-label": ariaLabel, ...props }) {
|
||||
return (
|
||||
<IconButton
|
||||
icon={icon}
|
||||
aria-label={ariaLabel}
|
||||
isRound
|
||||
variant="unstyled"
|
||||
backgroundColor="gray.600"
|
||||
color="gray.50"
|
||||
boxShadow="md"
|
||||
d="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
transition="backgroundColor 0.2s"
|
||||
_focus={{ backgroundColor: "gray.500" }}
|
||||
_hover={{ backgroundColor: "gray.500" }}
|
||||
outline="initial"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useDownloadableImage loads the image data and generates the downloadable
|
||||
* image URL.
|
||||
*/
|
||||
function useDownloadableImage(visibleLayers) {
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const [preferArchive] = usePreferArchive();
|
||||
|
||||
const [downloadImageUrl, setDownloadImageUrl] = React.useState(null);
|
||||
const [preparedForLayerIds, setPreparedForLayerIds] = React.useState([]);
|
||||
const toast = useToast();
|
||||
|
||||
const prepareDownload = React.useCallback(async () => {
|
||||
// Skip if the current image URL is already correct for these layers.
|
||||
const layerIds = visibleLayers.map((l) => l.id);
|
||||
if (layerIds.join(",") === preparedForLayerIds.join(",")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if there are no layers. (This probably means we're still loading!)
|
||||
if (layerIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadImageUrl(null);
|
||||
|
||||
// NOTE: You could argue that we may as well just always use PNGs here,
|
||||
// regardless of hi-res mode… but using the same src will help both
|
||||
// performance (can use cached SVG), and predictability (image will
|
||||
// look like what you see here).
|
||||
const imagePromises = visibleLayers.map((layer) =>
|
||||
loadImage(getBestImageUrlForLayer(layer, { hiResMode }), {
|
||||
crossOrigin: "anonymous",
|
||||
preferArchive,
|
||||
})
|
||||
);
|
||||
|
||||
let images;
|
||||
try {
|
||||
images = await Promise.all(imagePromises);
|
||||
} catch (e) {
|
||||
console.error("Error building downloadable image", e);
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Oops, sorry, we couldn't download the image!",
|
||||
description:
|
||||
"Check your connection, then reload the page and try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
canvas.width = 600;
|
||||
canvas.height = 600;
|
||||
|
||||
for (const image of images) {
|
||||
context.drawImage(image, 0, 0);
|
||||
}
|
||||
|
||||
console.debug(
|
||||
"Generated image for download",
|
||||
layerIds,
|
||||
canvas.toDataURL("image/png")
|
||||
);
|
||||
setDownloadImageUrl(canvas.toDataURL("image/png"));
|
||||
setPreparedForLayerIds(layerIds);
|
||||
}, [preparedForLayerIds, visibleLayers, toast, hiResMode, preferArchive]);
|
||||
|
||||
return [downloadImageUrl, prepareDownload];
|
||||
}
|
||||
|
||||
export default OutfitControls;
|
|
@ -0,0 +1,299 @@
|
|||
import React from "react";
|
||||
import { Box, VStack } from "@chakra-ui/react";
|
||||
import { WarningTwoIcon } from "@chakra-ui/icons";
|
||||
import { FaBug } from "react-icons/fa";
|
||||
import { GlitchBadgeLayout, layerUsesHTML5 } from "../components/HTML5Badge";
|
||||
import getVisibleLayers from "../components/getVisibleLayers";
|
||||
import { useLocalStorage } from "../util";
|
||||
|
||||
function OutfitKnownGlitchesBadge({ appearance }) {
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const { petAppearance, items } = appearance;
|
||||
|
||||
const glitchMessages = [];
|
||||
|
||||
// Look for UC/Invisible/etc incompatibilities that we hid, that we should
|
||||
// just mark Incompatible someday instead; or with correctly partially-hidden
|
||||
// art.
|
||||
//
|
||||
// NOTE: This particular glitch is checking for the *absence* of layers, so
|
||||
// we skip it if we're still loading!
|
||||
if (!appearance.loading) {
|
||||
for (const item of items) {
|
||||
// HACK: We use `getVisibleLayers` with just this pet appearance and item
|
||||
// appearance, to run the logic for which layers are compatible with
|
||||
// this pet. But `getVisibleLayers` does other things too, so it's
|
||||
// plausible that this could do not quite what we want in some cases!
|
||||
const allItemLayers = item.appearance.layers;
|
||||
const compatibleItemLayers = getVisibleLayers(petAppearance, [
|
||||
item.appearance,
|
||||
]).filter((l) => l.source === "item");
|
||||
|
||||
if (compatibleItemLayers.length === 0) {
|
||||
glitchMessages.push(
|
||||
<Box key={`total-uc-conflict-for-item-${item.id}`}>
|
||||
<i>{item.name}</i> isn't actually compatible with this special pet.
|
||||
We're hiding the item art, which is outdated behavior, and we should
|
||||
instead be treating it as entirely incompatible. Fixing this is in
|
||||
our todo list, sorry for the confusing UI!
|
||||
</Box>
|
||||
);
|
||||
} else if (compatibleItemLayers.length < allItemLayers.length) {
|
||||
glitchMessages.push(
|
||||
<Box key={`partial-uc-conflict-for-item-${item.id}`}>
|
||||
<i>{item.name}</i>'s compatibility with this pet is complicated, but
|
||||
we believe this is how it looks: some zones are visible, and some
|
||||
zones are hidden. If this isn't quite right, please email me at
|
||||
matchu@openneo.net and let me know!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT")
|
||||
);
|
||||
const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
|
||||
(l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
|
||||
!layerUsesHTML5(l)
|
||||
);
|
||||
if (itemHasBrokenOnNeopetsDotCom) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
|
||||
{itemHasBrokenUnconvertedLayers ? (
|
||||
<>
|
||||
We're aware of a glitch affecting the art for <i>{item.name}</i>.
|
||||
Last time we checked, this glitch affected its appearance on
|
||||
Neopets.com, too. Hopefully this will be fixed once it's converted
|
||||
to HTML5!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
We're aware of a previous glitch affecting the art for{" "}
|
||||
<i>{item.name}</i>, but it might have been resolved during HTML5
|
||||
conversion. Please use the feedback form on the homepage to let us
|
||||
know if it looks right, or still looks wrong! Thank you!
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasGlitch = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT")
|
||||
);
|
||||
if (itemHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i>, and we believe it
|
||||
looks this way on-site, too. But our version might be out of date! If
|
||||
you've seen it look better on-site, please email me at
|
||||
matchu@openneo.net so we can fix it!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this
|
||||
// if hi-res mode is on, because otherwise it doesn't affect the user anyway!
|
||||
if (hiResMode) {
|
||||
for (const item of items) {
|
||||
const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT")
|
||||
);
|
||||
if (itemHasOfficialSvgIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i> that prevents us
|
||||
from showing the SVG image for Hi-Res Mode. Instead, we're showing a
|
||||
PNG, which might look a bit blurry on larger screens.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
|
||||
for (const item of items) {
|
||||
const itemHasGlitch = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN")
|
||||
);
|
||||
if (itemHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i> that causes it to
|
||||
display incorrectly—but we're not sure if it's on our end, or TNT's.
|
||||
If you own this item, please email me at matchu@openneo.net to let us
|
||||
know how it looks in the on-site customizer!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT")
|
||||
);
|
||||
if (itemHasOfficialBodyIdIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
|
||||
Last we checked, <i>{item.name}</i> actually is compatible with this
|
||||
pet, even though it seems like it shouldn't be. But TNT might change
|
||||
this at any time, so be careful!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for Dyeworks items that aren't converted yet.
|
||||
for (const item of items) {
|
||||
const itemIsDyeworks = item.name.includes("Dyeworks");
|
||||
const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
|
||||
|
||||
if (itemIsDyeworks && !itemIsConverted) {
|
||||
glitchMessages.push(
|
||||
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
|
||||
<i>{item.name}</i> isn't converted to HTML5 yet, and our Classic DTI
|
||||
code often shows old Dyeworks items in the wrong color. Once it's
|
||||
converted, we'll display it correctly!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether the pet is Invisible. If so, we'll show a blanket warning.
|
||||
if (petAppearance?.color?.id === "38") {
|
||||
glitchMessages.push(
|
||||
<Box key={`invisible-pet-warning`}>
|
||||
Invisible pets are affected by a number of glitches, including faces
|
||||
sometimes being visible on-site, and errors in the HTML5 conversion. If
|
||||
this pose looks incorrect, you can try another by clicking the emoji
|
||||
face next to the species/color picker. But be aware that Neopets.com
|
||||
might look different!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
|
||||
if (
|
||||
petAppearance?.color?.id === "26" &&
|
||||
petAppearance?.species?.id === "49"
|
||||
) {
|
||||
glitchMessages.push(
|
||||
<Box key={`faerie-uni-dithering-horn-warning`}>
|
||||
The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and
|
||||
sometimes yellow. To help you design for both cases, we show the blue
|
||||
horn with the feminine design, and the yellow horn with the masculine
|
||||
design—but the pet's gender does not actually affect which horn you'll
|
||||
get, and it will often change over time!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Check whether the pet appearance is marked as Glitched.
|
||||
if (petAppearance?.isGlitched) {
|
||||
glitchMessages.push(
|
||||
// NOTE: This message assumes that the current pet appearance is the
|
||||
// best canonical one, but it's _possible_ to view Glitched
|
||||
// appearances even if we _do_ have a better one saved... but
|
||||
// only the Support UI ever takes you there.
|
||||
<Box key={`pet-appearance-is-glitched`}>
|
||||
We know that the art for this pet is incorrect, but we still haven't
|
||||
seen a <em>correct</em> model for this pose yet. Once someone models the
|
||||
correct data, we'll use that instead. For now, you could also try
|
||||
switching to another pose, by clicking the emoji face next to the
|
||||
species/color picker!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const petLayers = petAppearance?.layers || [];
|
||||
|
||||
// Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
|
||||
for (const layer of petLayers) {
|
||||
const layerHasGlitch = (layer.knownGlitches || []).includes(
|
||||
"OFFICIAL_SWF_IS_INCORRECT"
|
||||
);
|
||||
if (layerHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
|
||||
We're aware of a glitch affecting the art for this pet's{" "}
|
||||
<i>{layer.zone.label}</i> zone. Last time we checked, this glitch
|
||||
affected its appearance on Neopets.com, too. But our version might be
|
||||
out of date! If you've seen it look better on-site, please email me at
|
||||
matchu@openneo.net so we can fix it!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
|
||||
if (hiResMode) {
|
||||
for (const layer of petLayers) {
|
||||
const layerHasOfficialSvgIsIncorrect = (
|
||||
layer.knownGlitches || []
|
||||
).includes("OFFICIAL_SVG_IS_INCORRECT");
|
||||
if (layerHasOfficialSvgIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}>
|
||||
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
|
||||
zone that prevents us from showing the SVG image for Hi-Res Mode.
|
||||
Instead, we're showing a PNG, which might look a bit blurry on
|
||||
larger screens.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
|
||||
for (const layer of petLayers) {
|
||||
const layerHasGlitch = (layer.knownGlitches || []).includes(
|
||||
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN"
|
||||
);
|
||||
if (layerHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box
|
||||
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`}
|
||||
>
|
||||
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
|
||||
zone that causes it to display incorrectly—but we're not sure if it's
|
||||
on our end, or TNT's. If you have this pet, please email me at
|
||||
matchu@openneo.net to let us know how it looks in the on-site
|
||||
customizer!
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (glitchMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
aria-label="Has known glitches"
|
||||
tooltipLabel={
|
||||
<Box>
|
||||
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
|
||||
Known glitches
|
||||
</Box>
|
||||
<VStack spacing="1em">{glitchMessages}</VStack>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<WarningTwoIcon fontSize="xs" marginRight="1" />
|
||||
<FaBug />
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default OutfitKnownGlitchesBadge;
|
743
app/javascript/wardrobe-2020/WardrobePage/PosePicker.js
Normal file
|
@ -0,0 +1,743 @@
|
|||
import React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Portal,
|
||||
VisuallyHidden,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { loadable } from "../util";
|
||||
import Image from "next/image";
|
||||
|
||||
import { petAppearanceFragment } from "../components/useOutfitAppearance";
|
||||
import getVisibleLayers from "../components/getVisibleLayers";
|
||||
import { OutfitLayers } from "../components/OutfitPreview";
|
||||
import SupportOnly from "./support/SupportOnly";
|
||||
import useSupport from "./support/useSupport";
|
||||
import { useLocalStorage } from "../util";
|
||||
|
||||
// From https://twemoji.twitter.com/, thank you!
|
||||
import twemojiSmile from "../images/twemoji/smile.svg";
|
||||
import twemojiCry from "../images/twemoji/cry.svg";
|
||||
import twemojiSick from "../images/twemoji/sick.svg";
|
||||
import twemojiSunglasses from "../images/twemoji/sunglasses.svg";
|
||||
import twemojiQuestion from "../images/twemoji/question.svg";
|
||||
import twemojiMasc from "../images/twemoji/masc.svg";
|
||||
import twemojiFem from "../images/twemoji/fem.svg";
|
||||
|
||||
const PosePickerSupport = loadable(() => import("./support/PosePickerSupport"));
|
||||
|
||||
const PosePickerSupportSwitch = loadable(() =>
|
||||
import("./support/PosePickerSupport").then((m) => m.PosePickerSupportSwitch)
|
||||
);
|
||||
|
||||
/**
|
||||
* PosePicker shows the pet poses available on the current species/color, and
|
||||
* lets the user choose which want they want!
|
||||
*
|
||||
* NOTE: This component is memoized with React.memo. It's relatively expensive
|
||||
* to re-render on every outfit change - the contents update even if the
|
||||
* popover is closed! This makes wearing/unwearing items noticeably
|
||||
* slower on lower-power devices.
|
||||
*
|
||||
* So, instead of using `outfitState` like most components, we specify
|
||||
* exactly which props we need, so that `React.memo` can see the changes
|
||||
* that matter, and skip updates that don't.
|
||||
*/
|
||||
function PosePicker({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
dispatchToOutfit,
|
||||
onLockFocus,
|
||||
onUnlockFocus,
|
||||
...props
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const initialFocusRef = React.useRef();
|
||||
const { loading, error, poseInfos } = usePoses(speciesId, colorId, pose);
|
||||
const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
|
||||
"DTIPosePickerIsInSupportMode",
|
||||
false
|
||||
);
|
||||
const { isSupportUser } = useSupport();
|
||||
const toast = useToast();
|
||||
|
||||
// Resize the Popover when we toggle support mode, because it probably will
|
||||
// affect the content size.
|
||||
React.useLayoutEffect(() => {
|
||||
// HACK: To trigger a Popover resize, we simulate a window resize event,
|
||||
// because Popover listens for window resizes to reposition itself.
|
||||
// I've also filed an issue requesting an official API!
|
||||
// https://github.com/chakra-ui/chakra-ui/issues/1853
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}, [isInSupportMode]);
|
||||
|
||||
// Generally, the app tries to never put us in an invalid pose state. But it
|
||||
// can happen with direct URL navigation, or pet loading when modeling isn't
|
||||
// updated! Let's do some recovery.
|
||||
const selectedPoseIsAvailable = Object.values(poseInfos).some(
|
||||
(pi) => pi.isSelected && pi.isAvailable
|
||||
);
|
||||
const firstAvailablePose = Object.values(poseInfos).find(
|
||||
(pi) => pi.isAvailable
|
||||
)?.pose;
|
||||
React.useEffect(() => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPoseIsAvailable) {
|
||||
if (!firstAvailablePose) {
|
||||
// TODO: I suppose this error would fit better in SpeciesColorPicker!
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Oops, we don't have data for this pet color!",
|
||||
description:
|
||||
"If it's new, this might be a modeling issue—try modeling it on " +
|
||||
"Classic DTI first. Sorry!",
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Pose ${pose} not found for speciesId=${speciesId}, ` +
|
||||
`colorId=${colorId}. Redirecting to pose ${firstAvailablePose}.`
|
||||
);
|
||||
dispatchToOutfit({ type: "setPose", pose: firstAvailablePose });
|
||||
}
|
||||
}, [
|
||||
loading,
|
||||
selectedPoseIsAvailable,
|
||||
firstAvailablePose,
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
toast,
|
||||
dispatchToOutfit,
|
||||
]);
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// This is a low-stakes enough control, where enough pairs don't have data
|
||||
// anyway, that I think I want to just not draw attention to failures.
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If there's only one pose anyway, don't bother showing a picker!
|
||||
// (Unless we're Support, in which case we want the ability to pop it open to
|
||||
// inspect and label the Unknown poses!)
|
||||
const numAvailablePoses = Object.values(poseInfos).filter(
|
||||
(p) => p.isAvailable
|
||||
).length;
|
||||
if (numAvailablePoses <= 1 && !isSupportUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onChange = (e) => {
|
||||
dispatchToOutfit({ type: "setPose", pose: e.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="bottom-end"
|
||||
returnFocusOnClose
|
||||
onOpen={onLockFocus}
|
||||
onClose={onUnlockFocus}
|
||||
initialFocusRef={initialFocusRef}
|
||||
isLazy
|
||||
lazyBehavior="keepMounted"
|
||||
>
|
||||
{({ isOpen }) => (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
boxShadow="md"
|
||||
d="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
_focus={{ borderColor: "gray.50" }}
|
||||
_hover={{ borderColor: "gray.50" }}
|
||||
outline="initial"
|
||||
className={cx(
|
||||
css`
|
||||
border: 1px solid transparent !important;
|
||||
transition: border-color 0.2s !important;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&.is-open {
|
||||
border-color: ${theme.colors.gray["50"]} !important;
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
`,
|
||||
isOpen && "is-open"
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<EmojiImage src={getIcon(pose)} alt="Choose a pose" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent>
|
||||
<Box p="4" position="relative">
|
||||
{isInSupportMode ? (
|
||||
<PosePickerSupport
|
||||
speciesId={speciesId}
|
||||
colorId={colorId}
|
||||
pose={pose}
|
||||
appearanceId={appearanceId}
|
||||
initialFocusRef={initialFocusRef}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PosePickerTable
|
||||
poseInfos={poseInfos}
|
||||
onChange={onChange}
|
||||
initialFocusRef={initialFocusRef}
|
||||
/>
|
||||
{numAvailablePoses <= 1 && (
|
||||
<SupportOnly>
|
||||
<Box
|
||||
fontSize="xs"
|
||||
fontStyle="italic"
|
||||
textAlign="center"
|
||||
opacity="0.7"
|
||||
marginTop="2"
|
||||
>
|
||||
The empty picker is hidden for most users!
|
||||
<br />
|
||||
You can see it because you're a Support user.
|
||||
</Box>
|
||||
</SupportOnly>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<SupportOnly>
|
||||
<Box position="absolute" top="5" left="3">
|
||||
<PosePickerSupportSwitch
|
||||
isChecked={isInSupportMode}
|
||||
onChange={(e) => setIsInSupportMode(e.target.checked)}
|
||||
/>
|
||||
</Box>
|
||||
</SupportOnly>
|
||||
</Box>
|
||||
<PopoverArrow />
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</>
|
||||
)}
|
||||
</ClassNames>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function PosePickerTable({ poseInfos, onChange, initialFocusRef }) {
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<table width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<Cell as="th">
|
||||
<EmojiImage src={twemojiSmile} alt="Happy" />
|
||||
</Cell>
|
||||
<Cell as="th">
|
||||
<EmojiImage src={twemojiCry} alt="Sad" />
|
||||
</Cell>
|
||||
<Cell as="th">
|
||||
<EmojiImage src={twemojiSick} alt="Sick" />
|
||||
</Cell>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<Cell as="th">
|
||||
<EmojiImage src={twemojiMasc} alt="Masculine" />
|
||||
</Cell>
|
||||
<Cell as="td">
|
||||
<PoseOption
|
||||
poseInfo={poseInfos.happyMasc}
|
||||
onChange={onChange}
|
||||
inputRef={poseInfos.happyMasc.isSelected && initialFocusRef}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell as="td">
|
||||
<PoseOption
|
||||
poseInfo={poseInfos.sadMasc}
|
||||
onChange={onChange}
|
||||
inputRef={poseInfos.sadMasc.isSelected && initialFocusRef}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell as="td">
|
||||
<PoseOption
|
||||
poseInfo={poseInfos.sickMasc}
|
||||
onChange={onChange}
|
||||
inputRef={poseInfos.sickMasc.isSelected && initialFocusRef}
|
||||
/>
|
||||
</Cell>
|
||||
</tr>
|
||||
<tr>
|
||||
<Cell as="th">
|
||||
<EmojiImage src={twemojiFem} alt="Feminine" />
|
||||
</Cell>
|
||||
<Cell as="td">
|
||||
<PoseOption
|
||||
poseInfo={poseInfos.happyFem}
|
||||
onChange={onChange}
|
||||
inputRef={poseInfos.happyFem.isSelected && initialFocusRef}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell as="td">
|
||||
<PoseOption
|
||||
poseInfo={poseInfos.sadFem}
|
||||
onChange={onChange}
|
||||
inputRef={poseInfos.sadFem.isSelected && initialFocusRef}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell as="td">
|
||||
<PoseOption
|
||||
poseInfo={poseInfos.sickFem}
|
||||
onChange={onChange}
|
||||
inputRef={poseInfos.sickFem.isSelected && initialFocusRef}
|
||||
/>
|
||||
</Cell>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{poseInfos.unconverted.isAvailable && (
|
||||
<PoseOption
|
||||
poseInfo={poseInfos.unconverted}
|
||||
onChange={onChange}
|
||||
inputRef={poseInfos.unconverted.isSelected && initialFocusRef}
|
||||
size="sm"
|
||||
label="Unconverted"
|
||||
marginTop="2"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ children, as }) {
|
||||
const Tag = as;
|
||||
return (
|
||||
<Tag>
|
||||
<Flex justify="center" p="1">
|
||||
{children}
|
||||
</Flex>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const EMOTION_STRINGS = {
|
||||
HAPPY_MASC: "Happy",
|
||||
HAPPY_FEM: "Happy",
|
||||
SAD_MASC: "Sad",
|
||||
SAD_FEM: "Sad",
|
||||
SICK_MASC: "Sick",
|
||||
SICK_FEM: "Sick",
|
||||
};
|
||||
|
||||
const GENDER_PRESENTATION_STRINGS = {
|
||||
HAPPY_MASC: "Masculine",
|
||||
SAD_MASC: "Masculine",
|
||||
SICK_MASC: "Masculine",
|
||||
HAPPY_FEM: "Feminine",
|
||||
SAD_FEM: "Feminine",
|
||||
SICK_FEM: "Feminine",
|
||||
};
|
||||
|
||||
function PoseOption({
|
||||
poseInfo,
|
||||
onChange,
|
||||
inputRef,
|
||||
size = "md",
|
||||
label,
|
||||
...otherProps
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const genderPresentationStr = GENDER_PRESENTATION_STRINGS[poseInfo.pose];
|
||||
const emotionStr = EMOTION_STRINGS[poseInfo.pose];
|
||||
|
||||
let poseName =
|
||||
poseInfo.pose === "UNCONVERTED"
|
||||
? "Unconverted"
|
||||
: `${emotionStr} and ${genderPresentationStr}`;
|
||||
if (!poseInfo.isAvailable) {
|
||||
poseName += ` (not modeled yet)`;
|
||||
}
|
||||
|
||||
const borderColor = useColorModeValue(
|
||||
theme.colors.green["600"],
|
||||
theme.colors.green["300"]
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
as="label"
|
||||
cursor="pointer"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderColor={poseInfo.isSelected ? borderColor : "gray.400"}
|
||||
boxShadow={label ? "md" : "none"}
|
||||
borderWidth={label ? "1px" : "0"}
|
||||
borderRadius={label ? "full" : "0"}
|
||||
paddingRight={label ? "3" : "0"}
|
||||
onClick={(e) => {
|
||||
// HACK: We need the timeout to beat the popover's focus stealing!
|
||||
const input = e.currentTarget.querySelector("input");
|
||||
setTimeout(() => input.focus(), 0);
|
||||
}}
|
||||
{...otherProps}
|
||||
>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="radio"
|
||||
aria-label={poseName}
|
||||
name="pose"
|
||||
value={poseInfo.pose}
|
||||
checked={poseInfo.isSelected}
|
||||
disabled={!poseInfo.isAvailable}
|
||||
onChange={onChange}
|
||||
ref={inputRef || null}
|
||||
/>
|
||||
<Box
|
||||
aria-hidden
|
||||
borderRadius="full"
|
||||
boxShadow="md"
|
||||
overflow="hidden"
|
||||
width={size === "sm" ? "30px" : "50px"}
|
||||
height={size === "sm" ? "30px" : "50px"}
|
||||
title={
|
||||
poseInfo.isAvailable
|
||||
? // A lil debug output, so that we can quickly identify glitched
|
||||
// PetStates and manually mark them as glitched!
|
||||
window.location.hostname.includes("localhost") &&
|
||||
`#${poseInfo.id}`
|
||||
: "Not modeled yet"
|
||||
}
|
||||
position="relative"
|
||||
className={css`
|
||||
transform: scale(0.8);
|
||||
opacity: 0.8;
|
||||
transition: all 0.2s;
|
||||
|
||||
input:checked + & {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
borderRadius="full"
|
||||
position="absolute"
|
||||
top="0"
|
||||
bottom="0"
|
||||
left="0"
|
||||
right="0"
|
||||
zIndex="2"
|
||||
className={cx(
|
||||
css`
|
||||
border: 0px solid ${borderColor};
|
||||
transition: border-width 0.2s;
|
||||
|
||||
&.not-available {
|
||||
border-color: ${theme.colors.gray["500"]};
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
input:checked + * & {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
input:focus + * & {
|
||||
border-width: 3px;
|
||||
}
|
||||
`,
|
||||
!poseInfo.isAvailable && "not-available"
|
||||
)}
|
||||
/>
|
||||
{poseInfo.isAvailable ? (
|
||||
<Box
|
||||
width="100%"
|
||||
height="100%"
|
||||
transform={getTransform(poseInfo)}
|
||||
>
|
||||
<OutfitLayers visibleLayers={getVisibleLayers(poseInfo, [])} />
|
||||
</Box>
|
||||
) : (
|
||||
<Flex align="center" justify="center" width="100%" height="100%">
|
||||
<EmojiImage src={twemojiQuestion} boxSize={24} />
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
{label && (
|
||||
<Box
|
||||
marginLeft="2"
|
||||
fontSize="xs"
|
||||
fontWeight={poseInfo.isSelected ? "bold" : "normal"}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiImage({ src, alt, boxSize = 16 }) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={boxSize}
|
||||
height={boxSize}
|
||||
layout="fixed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function usePoses(speciesId, colorId, selectedPose) {
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query PosePicker($speciesId: ID!, $colorId: ID!) {
|
||||
happyMasc: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: HAPPY_MASC
|
||||
) {
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
sadMasc: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: SAD_MASC
|
||||
) {
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
sickMasc: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: SICK_MASC
|
||||
) {
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
happyFem: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: HAPPY_FEM
|
||||
) {
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
sadFem: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: SAD_FEM
|
||||
) {
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
sickFem: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: SICK_FEM
|
||||
) {
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
unconverted: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: UNCONVERTED
|
||||
) {
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
unknown: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: UNKNOWN
|
||||
) {
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
}
|
||||
|
||||
${petAppearanceForPosePickerFragment}
|
||||
`,
|
||||
{ variables: { speciesId, colorId }, onError: (e) => console.error(e) }
|
||||
);
|
||||
|
||||
const poseInfos = {
|
||||
happyMasc: {
|
||||
...data?.happyMasc,
|
||||
pose: "HAPPY_MASC",
|
||||
isAvailable: Boolean(data?.happyMasc),
|
||||
isSelected: selectedPose === "HAPPY_MASC",
|
||||
},
|
||||
sadMasc: {
|
||||
...data?.sadMasc,
|
||||
pose: "SAD_MASC",
|
||||
isAvailable: Boolean(data?.sadMasc),
|
||||
isSelected: selectedPose === "SAD_MASC",
|
||||
},
|
||||
sickMasc: {
|
||||
...data?.sickMasc,
|
||||
pose: "SICK_MASC",
|
||||
isAvailable: Boolean(data?.sickMasc),
|
||||
isSelected: selectedPose === "SICK_MASC",
|
||||
},
|
||||
happyFem: {
|
||||
...data?.happyFem,
|
||||
pose: "HAPPY_FEM",
|
||||
isAvailable: Boolean(data?.happyFem),
|
||||
isSelected: selectedPose === "HAPPY_FEM",
|
||||
},
|
||||
sadFem: {
|
||||
...data?.sadFem,
|
||||
pose: "SAD_FEM",
|
||||
isAvailable: Boolean(data?.sadFem),
|
||||
isSelected: selectedPose === "SAD_FEM",
|
||||
},
|
||||
sickFem: {
|
||||
...data?.sickFem,
|
||||
pose: "SICK_FEM",
|
||||
isAvailable: Boolean(data?.sickFem),
|
||||
isSelected: selectedPose === "SICK_FEM",
|
||||
},
|
||||
unconverted: {
|
||||
...data?.unconverted,
|
||||
pose: "UNCONVERTED",
|
||||
isAvailable: Boolean(data?.unconverted),
|
||||
isSelected: selectedPose === "UNCONVERTED",
|
||||
},
|
||||
unknown: {
|
||||
...data?.unknown,
|
||||
pose: "UNKNOWN",
|
||||
isAvailable: Boolean(data?.unknown),
|
||||
isSelected: selectedPose === "UNKNOWN",
|
||||
},
|
||||
};
|
||||
|
||||
return { loading, error, poseInfos };
|
||||
}
|
||||
|
||||
function getIcon(pose) {
|
||||
if (["HAPPY_MASC", "HAPPY_FEM"].includes(pose)) {
|
||||
return twemojiSmile;
|
||||
} else if (["SAD_MASC", "SAD_FEM"].includes(pose)) {
|
||||
return twemojiCry;
|
||||
} else if (["SICK_MASC", "SICK_FEM"].includes(pose)) {
|
||||
return twemojiSick;
|
||||
} else if (pose === "UNCONVERTED") {
|
||||
return twemojiSunglasses;
|
||||
} else {
|
||||
return twemojiQuestion;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransform(poseInfo) {
|
||||
const { pose, bodyId } = poseInfo;
|
||||
if (pose === "UNCONVERTED") {
|
||||
return transformsByBodyId.default;
|
||||
}
|
||||
if (bodyId in transformsByBodyId) {
|
||||
return transformsByBodyId[bodyId];
|
||||
}
|
||||
return transformsByBodyId.default;
|
||||
}
|
||||
|
||||
export const petAppearanceForPosePickerFragment = gql`
|
||||
fragment PetAppearanceForPosePicker on PetAppearance {
|
||||
id
|
||||
bodyId
|
||||
pose
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
${petAppearanceFragment}
|
||||
`;
|
||||
|
||||
const transformsByBodyId = {
|
||||
93: "translate(-5px, 10px) scale(2.8)",
|
||||
106: "translate(-8px, 8px) scale(2.9)",
|
||||
47: "translate(-1px, 17px) scale(3)",
|
||||
84: "translate(-21px, 22px) scale(3.2)",
|
||||
146: "translate(2px, 15px) scale(3.3)",
|
||||
250: "translate(-14px, 28px) scale(3.4)",
|
||||
212: "translate(-4px, 8px) scale(2.9)",
|
||||
74: "translate(-26px, 30px) scale(3.0)",
|
||||
94: "translate(-4px, 8px) scale(3.1)",
|
||||
132: "translate(-14px, 18px) scale(3.0)",
|
||||
56: "translate(-7px, 24px) scale(2.9)",
|
||||
90: "translate(-16px, 20px) scale(3.5)",
|
||||
136: "translate(-11px, 18px) scale(3.0)",
|
||||
138: "translate(-14px, 26px) scale(3.5)",
|
||||
166: "translate(-13px, 24px) scale(3.1)",
|
||||
119: "translate(-6px, 29px) scale(3.1)",
|
||||
126: "translate(3px, 13px) scale(3.1)",
|
||||
67: "translate(2px, 27px) scale(3.4)",
|
||||
163: "translate(-7px, 16px) scale(3.1)",
|
||||
147: "translate(-2px, 15px) scale(3.0)",
|
||||
80: "translate(-2px, -17px) scale(3.0)",
|
||||
117: "translate(-14px, 16px) scale(3.6)",
|
||||
201: "translate(-16px, 16px) scale(3.2)",
|
||||
51: "translate(-2px, 6px) scale(3.2)",
|
||||
208: "translate(-3px, 6px) scale(3.7)",
|
||||
196: "translate(-7px, 19px) scale(5.2)",
|
||||
143: "translate(-16px, 20px) scale(3.5)",
|
||||
150: "translate(-3px, 24px) scale(3.2)",
|
||||
175: "translate(-9px, 15px) scale(3.4)",
|
||||
173: "translate(3px, 57px) scale(4.4)",
|
||||
199: "translate(-28px, 35px) scale(3.8)",
|
||||
52: "translate(-8px, 33px) scale(3.5)",
|
||||
109: "translate(-8px, -6px) scale(3.2)",
|
||||
134: "translate(-14px, 14px) scale(3.1)",
|
||||
95: "translate(-12px, 0px) scale(3.4)",
|
||||
96: "translate(6px, 23px) scale(3.3)",
|
||||
154: "translate(-20px, 25px) scale(3.6)",
|
||||
55: "translate(-16px, 28px) scale(4.0)",
|
||||
76: "translate(-8px, 11px) scale(3.0)",
|
||||
156: "translate(2px, 12px) scale(3.5)",
|
||||
78: "translate(-3px, 18px) scale(3.0)",
|
||||
191: "translate(-18px, 46px) scale(4.4)",
|
||||
187: "translate(-6px, 22px) scale(3.2)",
|
||||
46: "translate(-2px, 19px) scale(3.4)",
|
||||
178: "translate(-11px, 32px) scale(3.3)",
|
||||
100: "translate(-13px, 23px) scale(3.3)",
|
||||
130: "translate(-14px, 4px) scale(3.1)",
|
||||
188: "translate(-9px, 24px) scale(3.5)",
|
||||
257: "translate(-14px, 25px) scale(3.4)",
|
||||
206: "translate(-7px, 4px) scale(3.6)",
|
||||
101: "translate(-13px, 16px) scale(3.2)",
|
||||
68: "translate(-2px, 13px) scale(3.2)",
|
||||
182: "translate(-6px, 4px) scale(3.1)",
|
||||
180: "translate(-15px, 22px) scale(3.6)",
|
||||
306: "translate(1px, 14px) scale(3.1)",
|
||||
default: "scale(2.5)",
|
||||
};
|
||||
|
||||
export default React.memo(PosePicker);
|
80
app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js
Normal file
|
@ -0,0 +1,80 @@
|
|||
import React from "react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Box, Flex } from "@chakra-ui/react";
|
||||
import SearchToolbar from "./SearchToolbar";
|
||||
import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
|
||||
import PaginationToolbar from "../components/PaginationToolbar";
|
||||
import { useSearchResults } from "./useSearchResults";
|
||||
|
||||
/**
|
||||
* SearchFooter appears on large screens only, to let you search for new items
|
||||
* while still keeping the rest of the item screen open!
|
||||
*/
|
||||
function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
|
||||
const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage(
|
||||
"DTIFeatureFlagCanUseSearchFooter",
|
||||
false
|
||||
);
|
||||
|
||||
const { items, numTotalPages } = useSearchResults(
|
||||
searchQuery,
|
||||
outfitState,
|
||||
1
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.location.search.includes("feature-flag-can-use-search-footer")) {
|
||||
setCanUseSearchFooter(true);
|
||||
}
|
||||
}, [setCanUseSearchFooter]);
|
||||
|
||||
// TODO: Show the new footer to other users, too!
|
||||
if (!canUseSearchFooter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Box>
|
||||
<Box paddingX="4" paddingY="4">
|
||||
<Flex as="label" align="center">
|
||||
<Box fontWeight="600" flex="0 0 auto">
|
||||
Add new items:
|
||||
</Box>
|
||||
<Box width="8" />
|
||||
<SearchToolbar
|
||||
query={searchQuery}
|
||||
onChange={onChangeSearchQuery}
|
||||
flex="0 1 100%"
|
||||
suggestionsPlacement="top"
|
||||
/>
|
||||
<Box width="8" />
|
||||
{numTotalPages != null && (
|
||||
<Box flex="0 0 auto">
|
||||
<PaginationToolbar
|
||||
numTotalPages={numTotalPages}
|
||||
currentPageNumber={1}
|
||||
goToPageNumber={() => alert("TODO")}
|
||||
buildPageUrl={() => null}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box maxHeight="32" overflow="auto">
|
||||
<Box as="ul" listStyleType="disc" paddingLeft="8">
|
||||
{items.map((item) => (
|
||||
<Box key={item.id} as="li">
|
||||
{item.name}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFooter;
|
284
app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js
Normal file
|
@ -0,0 +1,284 @@
|
|||
import React from "react";
|
||||
import { Box, Text, useColorModeValue, VisuallyHidden } from "@chakra-ui/react";
|
||||
|
||||
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
||||
import PaginationToolbar from "../components/PaginationToolbar";
|
||||
import { useSearchResults } from "./useSearchResults";
|
||||
|
||||
export const SEARCH_PER_PAGE = 30;
|
||||
|
||||
/**
|
||||
* SearchPanel shows item search results to the user, so they can preview them
|
||||
* and add them to their outfit!
|
||||
*
|
||||
* It's tightly coordinated with SearchToolbar, using refs to control special
|
||||
* keyboard and focus interactions.
|
||||
*/
|
||||
function SearchPanel({
|
||||
query,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
scrollContainerRef,
|
||||
searchQueryRef,
|
||||
firstSearchResultRef,
|
||||
}) {
|
||||
const scrollToTop = React.useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
// Sometimes we want to give focus back to the search field!
|
||||
const onMoveFocusUpToQuery = (e) => {
|
||||
if (searchQueryRef.current) {
|
||||
searchQueryRef.current.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
onKeyDown={(e) => {
|
||||
// This will catch any Escape presses when the user's focus is inside
|
||||
// the SearchPanel.
|
||||
if (e.key === "Escape") {
|
||||
onMoveFocusUpToQuery(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchResults
|
||||
// When the query changes, replace the SearchResults component with a
|
||||
// new instance. This resets both `currentPageNumber`, to take us back
|
||||
// to page 1; and also `itemIdsToReconsider`. That way, if you find an
|
||||
// item you like in one search, then immediately do a second search and
|
||||
// try a conflicting item, we'll restore the item you liked from your
|
||||
// first search!
|
||||
key={serializeQuery(query)}
|
||||
query={query}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
scrollToTop={scrollToTop}
|
||||
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchResults loads the search results from the user's query, renders them,
|
||||
* and tracks the scroll container for infinite scrolling.
|
||||
*
|
||||
* For each item, we render a <label> with a visually-hidden checkbox and the
|
||||
* Item component (which will visually reflect the radio's state). This makes
|
||||
* the list screen-reader- and keyboard-accessible!
|
||||
*/
|
||||
function SearchResults({
|
||||
query,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
firstSearchResultRef,
|
||||
scrollToTop,
|
||||
onMoveFocusUpToQuery,
|
||||
}) {
|
||||
const [currentPageNumber, setCurrentPageNumber] = React.useState(1);
|
||||
const { loading, error, items, numTotalPages } = useSearchResults(
|
||||
query,
|
||||
outfitState,
|
||||
currentPageNumber
|
||||
);
|
||||
|
||||
// Preload the previous and next page of search results, with this quick
|
||||
// ~hacky trick: just `useSearchResults` two more times, with some extra
|
||||
// attention to skip the query when we don't know if it will exist!
|
||||
useSearchResults(query, outfitState, currentPageNumber - 1, {
|
||||
skip: currentPageNumber <= 1,
|
||||
});
|
||||
useSearchResults(query, outfitState, currentPageNumber + 1, {
|
||||
skip: numTotalPages == null || currentPageNumber >= numTotalPages,
|
||||
});
|
||||
|
||||
// This will save the `wornItemIds` when the SearchResults first mounts, and
|
||||
// keep it saved even after the outfit changes. We use this to try to restore
|
||||
// these items after the user makes changes, e.g., after they try on another
|
||||
// Background we want to restore the previous one!
|
||||
const [itemIdsToReconsider] = React.useState(outfitState.wornItemIds);
|
||||
|
||||
// Whenever the page number changes, scroll back to the top!
|
||||
React.useEffect(() => scrollToTop(), [currentPageNumber, scrollToTop]);
|
||||
|
||||
// You can use UpArrow/DownArrow to navigate between items, and even back up
|
||||
// to the search field!
|
||||
const goToPrevItem = React.useCallback(
|
||||
(e) => {
|
||||
const prevLabel = e.target.closest("label").previousSibling;
|
||||
if (prevLabel) {
|
||||
prevLabel.querySelector("input[type=checkbox]").focus();
|
||||
prevLabel.scrollIntoView({ block: "center" });
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// If we're at the top of the list, move back up to the search box!
|
||||
onMoveFocusUpToQuery(e);
|
||||
}
|
||||
},
|
||||
[onMoveFocusUpToQuery]
|
||||
);
|
||||
const goToNextItem = React.useCallback((e) => {
|
||||
const nextLabel = e.target.closest("label").nextSibling;
|
||||
if (nextLabel) {
|
||||
nextLabel.querySelector("input[type=checkbox]").focus();
|
||||
nextLabel.scrollIntoView({ block: "center" });
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const searchPanelBackground = useColorModeValue("white", "gray.900");
|
||||
|
||||
// If the results aren't ready, we have some special case UI!
|
||||
if (error) {
|
||||
return (
|
||||
<Text>
|
||||
We hit an error trying to load your search results{" "}
|
||||
<span role="img" aria-label="(sweat emoji)">
|
||||
😓
|
||||
</span>{" "}
|
||||
Try again?
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Finally, render the item list, with checkboxes and Item components!
|
||||
// We also render some extra skeleton items at the bottom during infinite
|
||||
// scroll loading.
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
position="sticky"
|
||||
top="0"
|
||||
background={searchPanelBackground}
|
||||
zIndex="2"
|
||||
paddingX="5"
|
||||
paddingBottom="2"
|
||||
paddingTop="1"
|
||||
>
|
||||
<PaginationToolbar
|
||||
numTotalPages={numTotalPages}
|
||||
currentPageNumber={currentPageNumber}
|
||||
goToPageNumber={setCurrentPageNumber}
|
||||
buildPageUrl={() => null}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
<ItemListContainer paddingX="4" paddingBottom="2">
|
||||
{items.map((item, index) => (
|
||||
<SearchResultItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
itemIdsToReconsider={itemIdsToReconsider}
|
||||
isWorn={outfitState.wornItemIds.includes(item.id)}
|
||||
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
checkboxRef={index === 0 ? firstSearchResultRef : null}
|
||||
goToPrevItem={goToPrevItem}
|
||||
goToNextItem={goToNextItem}
|
||||
/>
|
||||
))}
|
||||
</ItemListContainer>
|
||||
{loading && (
|
||||
<ItemListSkeleton
|
||||
count={SEARCH_PER_PAGE}
|
||||
paddingX="4"
|
||||
paddingBottom="2"
|
||||
/>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<Text paddingX="4">
|
||||
We couldn't find any matching items{" "}
|
||||
<span role="img" aria-label="(thinking emoji)">
|
||||
🤔
|
||||
</span>{" "}
|
||||
Try again?
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultItem({
|
||||
item,
|
||||
itemIdsToReconsider,
|
||||
isWorn,
|
||||
isInOutfit,
|
||||
dispatchToOutfit,
|
||||
checkboxRef,
|
||||
goToPrevItem,
|
||||
goToNextItem,
|
||||
}) {
|
||||
// It's important to use `useCallback` for `onRemove`, to avoid re-rendering
|
||||
// the whole list of <Item>s!
|
||||
const onRemove = React.useCallback(
|
||||
() =>
|
||||
dispatchToOutfit({
|
||||
type: "removeItem",
|
||||
itemId: item.id,
|
||||
itemIdsToReconsider,
|
||||
}),
|
||||
[item.id, itemIdsToReconsider, dispatchToOutfit]
|
||||
);
|
||||
|
||||
return (
|
||||
<label>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="checkbox"
|
||||
aria-label={`Wear "${item.name}"`}
|
||||
value={item.id}
|
||||
checked={isWorn}
|
||||
ref={checkboxRef}
|
||||
onChange={(e) => {
|
||||
const itemId = e.target.value;
|
||||
const willBeWorn = e.target.checked;
|
||||
if (willBeWorn) {
|
||||
dispatchToOutfit({ type: "wearItem", itemId, itemIdsToReconsider });
|
||||
} else {
|
||||
dispatchToOutfit({
|
||||
type: "unwearItem",
|
||||
itemId,
|
||||
itemIdsToReconsider,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.target.click();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
goToPrevItem(e);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
goToNextItem(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Item
|
||||
item={item}
|
||||
isWorn={isWorn}
|
||||
isInOutfit={isInOutfit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* serializeQuery stably converts a search query object to a string, for easier
|
||||
* JS comparison.
|
||||
*/
|
||||
function serializeQuery(query) {
|
||||
return `${JSON.stringify([
|
||||
query.value,
|
||||
query.filterToItemKind,
|
||||
query.filterToZoneLabel,
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
])}`;
|
||||
}
|
||||
|
||||
export default SearchPanel;
|
479
app/javascript/wardrobe-2020/WardrobePage/SearchToolbar.js
Normal file
|
@ -0,0 +1,479 @@
|
|||
import React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftAddon,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CloseIcon,
|
||||
SearchIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import Autosuggest from "react-autosuggest";
|
||||
|
||||
import useCurrentUser from "../components/useCurrentUser";
|
||||
import { logAndCapture } from "../util";
|
||||
|
||||
export const emptySearchQuery = {
|
||||
value: "",
|
||||
filterToZoneLabel: null,
|
||||
filterToItemKind: null,
|
||||
filterToCurrentUserOwnsOrWants: null,
|
||||
};
|
||||
|
||||
export function searchQueryIsEmpty(query) {
|
||||
return Object.values(query).every((value) => !value);
|
||||
}
|
||||
|
||||
const SUGGESTIONS_PLACEMENT_PROPS = {
|
||||
inline: {
|
||||
borderBottomRadius: "md",
|
||||
},
|
||||
top: {
|
||||
position: "absolute",
|
||||
bottom: "100%",
|
||||
borderTopRadius: "md",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* SearchToolbar is rendered above both the ItemsPanel and the SearchPanel,
|
||||
* and contains the search field where the user types their query.
|
||||
*
|
||||
* It has some subtle keyboard interaction support, like DownArrow to go to the
|
||||
* first search result, and Escape to clear the search and go back to the
|
||||
* ItemsPanel. (The SearchPanel can also send focus back to here, with Escape
|
||||
* from anywhere, or UpArrow from the first result!)
|
||||
*/
|
||||
function SearchToolbar({
|
||||
query,
|
||||
searchQueryRef,
|
||||
firstSearchResultRef = null,
|
||||
onChange,
|
||||
autoFocus,
|
||||
showItemsLabel = false,
|
||||
background = null,
|
||||
boxShadow = null,
|
||||
suggestionsPlacement = "inline",
|
||||
...props
|
||||
}) {
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
|
||||
const { isLoggedIn } = useCurrentUser();
|
||||
|
||||
// NOTE: This query should always load ~instantly, from the client cache.
|
||||
const { data } = useQuery(gql`
|
||||
query SearchToolbarZones {
|
||||
allZones {
|
||||
id
|
||||
label
|
||||
depth
|
||||
isCommonlyUsedByItems
|
||||
}
|
||||
}
|
||||
`);
|
||||
const zones = data?.allZones || [];
|
||||
const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);
|
||||
|
||||
let zoneLabels = itemZones.map((z) => z.label);
|
||||
zoneLabels = [...new Set(zoneLabels)];
|
||||
zoneLabels.sort();
|
||||
|
||||
const onMoveFocusDownToResults = (e) => {
|
||||
if (firstSearchResultRef && firstSearchResultRef.current) {
|
||||
firstSearchResultRef.current.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
|
||||
const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");
|
||||
|
||||
const renderSuggestion = React.useCallback(
|
||||
({ text }, { isHighlighted }) => (
|
||||
<Box
|
||||
fontWeight={isHighlighted ? "bold" : "normal"}
|
||||
background={isHighlighted ? highlightedBgColor : suggestionBgColor}
|
||||
padding="2"
|
||||
paddingLeft="2.5rem"
|
||||
fontSize="sm"
|
||||
>
|
||||
{text}
|
||||
</Box>
|
||||
),
|
||||
[suggestionBgColor, highlightedBgColor]
|
||||
);
|
||||
|
||||
const renderSuggestionsContainer = React.useCallback(
|
||||
({ containerProps, children }) => {
|
||||
const { className, ...otherContainerProps } = containerProps;
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
{...otherContainerProps}
|
||||
boxShadow="md"
|
||||
overflow="auto"
|
||||
transition="all 0.4s"
|
||||
maxHeight="48"
|
||||
width="100%"
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
`
|
||||
)}
|
||||
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
|
||||
>
|
||||
{children}
|
||||
{!children && advancedSearchIsOpen && (
|
||||
<Box
|
||||
padding="4"
|
||||
fontSize="sm"
|
||||
fontStyle="italic"
|
||||
textAlign="center"
|
||||
>
|
||||
No more filters available!
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
},
|
||||
[advancedSearchIsOpen, suggestionsPlacement]
|
||||
);
|
||||
|
||||
// When we change the query filters, clear out the suggestions.
|
||||
React.useEffect(() => {
|
||||
setSuggestions([]);
|
||||
}, [
|
||||
query.filterToItemKind,
|
||||
query.filterToZoneLabel,
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
]);
|
||||
|
||||
let queryFilterText = getQueryFilterText(query);
|
||||
if (showItemsLabel) {
|
||||
queryFilterText = queryFilterText ? (
|
||||
<>
|
||||
<Box as="span" fontWeight="600">
|
||||
Items:
|
||||
</Box>{" "}
|
||||
{queryFilterText}
|
||||
</>
|
||||
) : (
|
||||
<Box as="span" fontWeight="600">
|
||||
Items
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
|
||||
showAll: true,
|
||||
});
|
||||
|
||||
// Once you remove the final suggestion available, close Advanced Search. We
|
||||
// have placeholder text available, sure, but this feels more natural!
|
||||
React.useEffect(() => {
|
||||
if (allSuggestions.length === 0) {
|
||||
setAdvancedSearchIsOpen(false);
|
||||
}
|
||||
}, [allSuggestions.length]);
|
||||
|
||||
const focusBorderColor = useColorModeValue("green.600", "green.400");
|
||||
|
||||
return (
|
||||
<Box position="relative" {...props}>
|
||||
<Autosuggest
|
||||
suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
|
||||
onSuggestionsFetchRequested={({ value }) => {
|
||||
// HACK: I'm not sure why, but apparently this gets called with value
|
||||
// set to the _chosen suggestion_ after choosing it? Has that
|
||||
// always happened? Idk? Let's just, gate around it, I guess?
|
||||
if (typeof value === "string") {
|
||||
setSuggestions(
|
||||
getSuggestions(value, query, zoneLabels, isLoggedIn)
|
||||
);
|
||||
}
|
||||
}}
|
||||
onSuggestionSelected={(e, { suggestion }) => {
|
||||
onChange({
|
||||
...query,
|
||||
// If the suggestion was from typing, remove the last word of the
|
||||
// query value. Or, if it was from Advanced Search, leave it alone!
|
||||
value: advancedSearchIsOpen
|
||||
? query.value
|
||||
: removeLastWord(query.value),
|
||||
filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
|
||||
filterToItemKind: suggestion.itemKind || query.filterToItemKind,
|
||||
filterToCurrentUserOwnsOrWants:
|
||||
suggestion.userOwnsOrWants ||
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
});
|
||||
}}
|
||||
getSuggestionValue={(zl) => zl}
|
||||
alwaysRenderSuggestions={true}
|
||||
renderSuggestion={renderSuggestion}
|
||||
renderSuggestionsContainer={renderSuggestionsContainer}
|
||||
renderInputComponent={(inputProps) => (
|
||||
<InputGroup boxShadow={boxShadow} borderRadius="md">
|
||||
{queryFilterText ? (
|
||||
<InputLeftAddon>
|
||||
<SearchIcon color="gray.400" marginRight="3" />
|
||||
<Box fontSize="sm">{queryFilterText}</Box>
|
||||
</InputLeftAddon>
|
||||
) : (
|
||||
<InputLeftElement>
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
)}
|
||||
<Input
|
||||
background={background}
|
||||
autoFocus={autoFocus}
|
||||
{...inputProps}
|
||||
/>
|
||||
<InputRightElement
|
||||
width="auto"
|
||||
justifyContent="flex-end"
|
||||
paddingRight="2px"
|
||||
paddingY="2px"
|
||||
>
|
||||
{!searchQueryIsEmpty(query) && (
|
||||
<Tooltip label="Clear">
|
||||
<IconButton
|
||||
icon={<CloseIcon fontSize="0.6em" />}
|
||||
color="gray.400"
|
||||
variant="ghost"
|
||||
height="100%"
|
||||
marginLeft="1"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSuggestions([]);
|
||||
onChange(emptySearchQuery);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Advanced search">
|
||||
<IconButton
|
||||
icon={
|
||||
advancedSearchIsOpen ? (
|
||||
<ChevronUpIcon fontSize="1.5em" />
|
||||
) : (
|
||||
<ChevronDownIcon fontSize="1.5em" />
|
||||
)
|
||||
}
|
||||
color="gray.400"
|
||||
variant="ghost"
|
||||
height="100%"
|
||||
aria-label="Open advanced search"
|
||||
onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
)}
|
||||
inputProps={{
|
||||
placeholder: "Search all items…",
|
||||
focusBorderColor: focusBorderColor,
|
||||
value: query.value || "",
|
||||
ref: searchQueryRef,
|
||||
minWidth: 0,
|
||||
"data-test-id": "item-search-input",
|
||||
onChange: (e, { newValue, method }) => {
|
||||
// The Autosuggest tries to change the _entire_ value of the element
|
||||
// when navigating suggestions, which isn't actually what we want.
|
||||
// Only accept value changes that are typed by the user!
|
||||
if (method === "type") {
|
||||
onChange({ ...query, value: newValue });
|
||||
}
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (suggestions.length > 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
onChange(emptySearchQuery);
|
||||
e.target.blur();
|
||||
} else if (e.key === "Enter") {
|
||||
// Pressing Enter doesn't actually submit because it's all on
|
||||
// debounce, but it can be a declaration that the query is done, so
|
||||
// filter suggestions should go away!
|
||||
if (suggestions.length > 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
if (suggestions.length > 0) {
|
||||
return;
|
||||
}
|
||||
onMoveFocusDownToResults(e);
|
||||
} else if (e.key === "Backspace" && e.target.selectionStart === 0) {
|
||||
onChange({
|
||||
...query,
|
||||
filterToItemKind: null,
|
||||
filterToZoneLabel: null,
|
||||
filterToCurrentUserOwnsOrWants: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function getSuggestions(
|
||||
value,
|
||||
query,
|
||||
zoneLabels,
|
||||
isLoggedIn,
|
||||
{ showAll = false } = {}
|
||||
) {
|
||||
if (!value && !showAll) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const words = (value || "").split(/\s+/);
|
||||
const lastWord = words[words.length - 1];
|
||||
if (lastWord.length < 2 && !showAll) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const suggestions = [];
|
||||
|
||||
if (query.filterToItemKind == null) {
|
||||
if (
|
||||
wordMatches("NC", lastWord) ||
|
||||
wordMatches("Neocash", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "NC", text: "Neocash items" });
|
||||
}
|
||||
|
||||
if (
|
||||
wordMatches("NP", lastWord) ||
|
||||
wordMatches("Neopoints", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "NP", text: "Neopoint items" });
|
||||
}
|
||||
|
||||
if (
|
||||
wordMatches("PB", lastWord) ||
|
||||
wordMatches("Paintbrush", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
|
||||
if (wordMatches("Items you own", lastWord) || showAll) {
|
||||
suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
|
||||
}
|
||||
|
||||
if (wordMatches("Items you want", lastWord) || showAll) {
|
||||
suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
|
||||
}
|
||||
}
|
||||
|
||||
if (query.filterToZoneLabel == null) {
|
||||
for (const zoneLabel of zoneLabels) {
|
||||
if (wordMatches(zoneLabel, lastWord) || showAll) {
|
||||
suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
function wordMatches(target, word) {
|
||||
return target.toLowerCase().includes(word.toLowerCase());
|
||||
}
|
||||
|
||||
function getQueryFilterText(query) {
|
||||
const textWords = [];
|
||||
|
||||
if (query.filterToItemKind) {
|
||||
textWords.push(query.filterToItemKind);
|
||||
}
|
||||
|
||||
if (query.filterToZoneLabel) {
|
||||
textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
|
||||
}
|
||||
|
||||
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
|
||||
if (!query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("Items");
|
||||
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("items");
|
||||
}
|
||||
textWords.push("you own");
|
||||
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
|
||||
if (!query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("Items");
|
||||
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("items");
|
||||
}
|
||||
textWords.push("you want");
|
||||
}
|
||||
|
||||
return textWords.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* pluralizeZoneLabel hackily tries to convert a zone name to a plural noun!
|
||||
*
|
||||
* HACK: It'd be more reliable and more translatable to do this by just
|
||||
* manually creating the plural for each zone. But, ehh! ¯\_ (ツ)_/¯
|
||||
*/
|
||||
function pluralizeZoneLabel(zoneLabel) {
|
||||
if (zoneLabel.endsWith("ss")) {
|
||||
return zoneLabel + "es";
|
||||
} else if (zoneLabel.endsWith("s")) {
|
||||
return zoneLabel;
|
||||
} else {
|
||||
return zoneLabel + "s";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* removeLastWord returns a copy of the text, with the last word and any
|
||||
* preceding space removed.
|
||||
*/
|
||||
function removeLastWord(text) {
|
||||
// This regex matches the full text, and assigns the last word and any
|
||||
// preceding text to subgroup 2, and all preceding text to subgroup 1. If
|
||||
// there's no last word, we'll still match, and the full string will be in
|
||||
// subgroup 1, including any space - no changes made!
|
||||
const match = text.match(/^(.*?)(\s*\S+)?$/);
|
||||
if (!match) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion failure: pattern should match any input text, ` +
|
||||
`but failed to match ${JSON.stringify(text)}`
|
||||
)
|
||||
);
|
||||
return text;
|
||||
}
|
||||
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export default SearchToolbar;
|
|
@ -0,0 +1,67 @@
|
|||
import React from "react";
|
||||
import { Box, Grid, useColorModeValue, useToken } from "@chakra-ui/react";
|
||||
import { useCommonStyles } from "../util";
|
||||
|
||||
function WardrobePageLayout({
|
||||
previewAndControls = null,
|
||||
itemsAndMaybeSearchPanel = null,
|
||||
searchFooter = null,
|
||||
}) {
|
||||
const itemsAndSearchBackground = useColorModeValue("white", "gray.900");
|
||||
const searchBackground = useCommonStyles().bodyBackground;
|
||||
const searchShadowColorValue = useToken("colors", "gray.400");
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
bottom="0"
|
||||
left="0"
|
||||
right="0"
|
||||
// Create a stacking context, so that our drawers and modals don't fight
|
||||
// with the z-indexes in here!
|
||||
zIndex="0"
|
||||
>
|
||||
<Grid
|
||||
templateAreas={{
|
||||
base: `"previewAndControls"
|
||||
"itemsAndMaybeSearchPanel"`,
|
||||
md: `"previewAndControls itemsAndMaybeSearchPanel"
|
||||
"searchFooter searchFooter"`,
|
||||
}}
|
||||
templateRows={{
|
||||
base: "minmax(100px, 45%) minmax(300px, 55%)",
|
||||
md: "minmax(300px, 1fr) auto",
|
||||
}}
|
||||
templateColumns={{
|
||||
base: "100%",
|
||||
md: "50% 50%",
|
||||
}}
|
||||
height="100%"
|
||||
width="100%"
|
||||
>
|
||||
<Box
|
||||
gridArea="previewAndControls"
|
||||
bg="gray.900"
|
||||
color="gray.50"
|
||||
position="relative"
|
||||
>
|
||||
{previewAndControls}
|
||||
</Box>
|
||||
<Box gridArea="itemsAndMaybeSearchPanel" bg={itemsAndSearchBackground}>
|
||||
{itemsAndMaybeSearchPanel}
|
||||
</Box>
|
||||
<Box
|
||||
gridArea="searchFooter"
|
||||
bg={searchBackground}
|
||||
boxShadow={`0 0 8px ${searchShadowColorValue}`}
|
||||
display={{ base: "none", md: "block" }}
|
||||
>
|
||||
{searchFooter}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default WardrobePageLayout;
|
|
@ -0,0 +1,99 @@
|
|||
import React from "react";
|
||||
import { Box, Center, DarkMode } from "@chakra-ui/react";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import OutfitThumbnail from "../components/OutfitThumbnail";
|
||||
import { useOutfitPreview } from "../components/OutfitPreview";
|
||||
import { loadable, MajorErrorMessage, TestErrorSender } from "../util";
|
||||
|
||||
const OutfitControls = loadable(() => import("./OutfitControls"));
|
||||
|
||||
function WardrobePreviewAndControls({
|
||||
isLoading,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
}) {
|
||||
// Whether the current outfit preview has animations. Determines whether we
|
||||
// show the play/pause button.
|
||||
const [hasAnimations, setHasAnimations] = React.useState(false);
|
||||
|
||||
const { appearance, preview } = useOutfitPreview({
|
||||
isLoading: isLoading,
|
||||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
appearanceId: outfitState.appearanceId,
|
||||
wornItemIds: outfitState.wornItemIds,
|
||||
onChangeHasAnimations: setHasAnimations,
|
||||
placeholder: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
|
||||
"data-test-id": "wardrobe-outfit-preview",
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Center position="absolute" top="0" bottom="0" left="0" right="0">
|
||||
<DarkMode>{preview}</DarkMode>
|
||||
</Center>
|
||||
<Box position="absolute" top="0" bottom="0" left="0" right="0">
|
||||
<OutfitControls
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
showAnimationControls={hasAnimations}
|
||||
appearance={appearance}
|
||||
/>
|
||||
</Box>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OutfitThumbnailIfCached will render an OutfitThumbnail as a placeholder for
|
||||
* the outfit preview... but only if we already have the data to generate the
|
||||
* thumbnail stored in our local Apollo GraphQL cache.
|
||||
*
|
||||
* This means that, when you come from the Your Outfits page, we can show the
|
||||
* outfit thumbnail instantly while everything else loads. But on direct
|
||||
* navigation, this does nothing, and we just wait for the preview to load in
|
||||
* like usual!
|
||||
*/
|
||||
function OutfitThumbnailIfCached({ outfitId }) {
|
||||
const { data } = useQuery(
|
||||
gql`
|
||||
query OutfitThumbnailIfCached($outfitId: ID!) {
|
||||
outfit(id: $outfitId) {
|
||||
id
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
outfitId,
|
||||
},
|
||||
skip: outfitId == null,
|
||||
fetchPolicy: "cache-only",
|
||||
onError: (e) => console.error(e),
|
||||
}
|
||||
);
|
||||
|
||||
if (!data?.outfit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OutfitThumbnail
|
||||
outfitId={data.outfit.id}
|
||||
updatedAt={data.outfit.updatedAt}
|
||||
alt=""
|
||||
objectFit="contain"
|
||||
width="100%"
|
||||
height="100%"
|
||||
filter="blur(2px)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default WardrobePreviewAndControls;
|
198
app/javascript/wardrobe-2020/WardrobePage/index.js
Normal file
|
@ -0,0 +1,198 @@
|
|||
import React from "react";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { emptySearchQuery } from "./SearchToolbar";
|
||||
import ItemsAndSearchPanels from "./ItemsAndSearchPanels";
|
||||
import SearchFooter from "./SearchFooter";
|
||||
import SupportOnly from "./support/SupportOnly";
|
||||
import useOutfitSaving from "./useOutfitSaving";
|
||||
import useOutfitState, { OutfitStateContext } from "./useOutfitState";
|
||||
import WardrobePageLayout from "./WardrobePageLayout";
|
||||
import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
|
||||
|
||||
/**
|
||||
* WardrobePage is the most fun page on the site - it's where you create
|
||||
* outfits!
|
||||
*
|
||||
* This page has two sections: the OutfitPreview, where we show the outfit as a
|
||||
* big image; and the ItemsAndSearchPanels, which let you manage which items
|
||||
* are in the outfit and find new ones.
|
||||
*
|
||||
* This component manages shared outfit state, and the fullscreen responsive
|
||||
* page layout.
|
||||
*/
|
||||
function WardrobePage() {
|
||||
const toast = useToast();
|
||||
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
|
||||
|
||||
// We manage outfit saving up here, rather than at the point of the UI where
|
||||
// "Saving" indicators appear. That way, auto-saving still happens even when
|
||||
// the indicator isn't on the page, e.g. when searching. We also mount a
|
||||
// <Prompt /> in this component to prevent navigating away before saving.
|
||||
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
|
||||
|
||||
// TODO: I haven't found a great place for this error UI yet, and this case
|
||||
// isn't very common, so this lil toast notification seems good enough!
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
title: "We couldn't load this outfit 😖",
|
||||
description: "Please reload the page to try again. Sorry!",
|
||||
status: "error",
|
||||
isClosable: true,
|
||||
duration: 999999999,
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
// For new outfits, we only block navigation while saving. For existing
|
||||
// outfits, we block navigation while there are any unsaved changes.
|
||||
const shouldBlockNavigation =
|
||||
outfitSaving.canSaveOutfit &&
|
||||
((outfitSaving.isNewOutfit && outfitSaving.isSaving) ||
|
||||
(!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved));
|
||||
|
||||
// In addition to a <Prompt /> for client-side nav, we need to block full nav!
|
||||
React.useEffect(() => {
|
||||
if (shouldBlockNavigation) {
|
||||
const onBeforeUnload = (e) => {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}
|
||||
}, [shouldBlockNavigation]);
|
||||
|
||||
const title = `${outfitState.name || "Untitled outfit"} | Dress to Impress`;
|
||||
React.useEffect(() => {
|
||||
document.title = title;
|
||||
}, [title]);
|
||||
|
||||
// NOTE: Most components pass around outfitState directly, to make the data
|
||||
// relationships more explicit... but there are some deep components
|
||||
// that need it, where it's more useful and more performant to access
|
||||
// via context.
|
||||
return (
|
||||
<>
|
||||
<OutfitStateContext.Provider value={outfitState}>
|
||||
<SupportOnly>
|
||||
<WardrobeDevHacks />
|
||||
</SupportOnly>
|
||||
|
||||
{/*
|
||||
* TODO: This might unnecessarily block navigations that we don't
|
||||
* necessarily need to, e.g., navigating back to Your Outfits while the
|
||||
* save request is in flight. We could instead submit the save mutation
|
||||
* immediately on client-side nav, and have each outfit save mutation
|
||||
* install a `beforeunload` handler that ensures that you don't close
|
||||
* the page altogether while it's in flight. But let's start simple and
|
||||
* see how annoying it actually is in practice lol
|
||||
*/}
|
||||
<Prompt
|
||||
when={shouldBlockNavigation}
|
||||
message="Are you sure you want to leave? Your changes might not be saved."
|
||||
/>
|
||||
|
||||
<WardrobePageLayout
|
||||
previewAndControls={
|
||||
<WardrobePreviewAndControls
|
||||
isLoading={loading}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
}
|
||||
itemsAndMaybeSearchPanel={
|
||||
<ItemsAndSearchPanels
|
||||
loading={loading}
|
||||
searchQuery={searchQuery}
|
||||
onChangeSearchQuery={setSearchQuery}
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
}
|
||||
searchFooter={
|
||||
<SearchFooter
|
||||
searchQuery={searchQuery}
|
||||
onChangeSearchQuery={setSearchQuery}
|
||||
outfitState={outfitState}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</OutfitStateContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SavedOutfitMetaTags renders the meta tags that we use to render pretty
|
||||
* share cards for social media for saved outfits!
|
||||
*/
|
||||
function SavedOutfitMetaTags({ outfitState }) {
|
||||
const updatedAtTimestamp = Math.floor(
|
||||
new Date(outfitState.updatedAt).getTime() / 1000
|
||||
);
|
||||
const imageUrl =
|
||||
`https://impress-outfit-images.openneo.net/outfits` +
|
||||
`/${encodeURIComponent(outfitState.id)}` +
|
||||
`/v/${encodeURIComponent(updatedAtTimestamp)}` +
|
||||
`/600.png`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={outfitState.name || "Untitled outfit"}
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta property="og:url" content={outfitState.url} />
|
||||
<meta property="og:site_name" content="Dress to Impress" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A custom Neopets outfit, designed on Dress to Impress!"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt blocks client-side navigation via Next.js when the `when` prop is
|
||||
* true. This is our attempt at a drop-in replacement for the Prompt component
|
||||
* offered by react-router!
|
||||
*
|
||||
* Adapted from https://github.com/vercel/next.js/issues/2694#issuecomment-778225625
|
||||
*/
|
||||
function Prompt({ when, message }) {
|
||||
const router = useRouter();
|
||||
React.useEffect(() => {
|
||||
const handleWindowClose = (e) => {
|
||||
if (!when) return;
|
||||
e.preventDefault();
|
||||
return (e.returnValue = message);
|
||||
};
|
||||
const handleBrowseAway = () => {
|
||||
if (!when) return;
|
||||
if (window.confirm(message)) return;
|
||||
router.events.emit("routeChangeError");
|
||||
throw "routeChange aborted by <Prompt>.";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleWindowClose);
|
||||
router.events.on("routeChangeStart", handleBrowseAway);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleWindowClose);
|
||||
router.events.off("routeChangeStart", handleBrowseAway);
|
||||
};
|
||||
}, [when, message, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default WardrobePage;
|
|
@ -0,0 +1,569 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
Tooltip,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
} from "@chakra-ui/react";
|
||||
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||
import {
|
||||
appearanceLayerFragment,
|
||||
appearanceLayerFragmentForSupport,
|
||||
itemAppearanceFragment,
|
||||
petAppearanceFragment,
|
||||
} from "../../components/useOutfitAppearance";
|
||||
import HangerSpinner from "../../components/HangerSpinner";
|
||||
import { ErrorMessage, useCommonStyles } from "../../util";
|
||||
import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
|
||||
import { EditIcon } from "@chakra-ui/icons";
|
||||
import useSupport from "./useSupport";
|
||||
|
||||
function AllItemLayersSupportModal({ item, isOpen, onClose }) {
|
||||
const [bulkAddProposal, setBulkAddProposal] = React.useState(null);
|
||||
|
||||
const { bodyBackground } = useCommonStyles();
|
||||
|
||||
return (
|
||||
<Modal size="4xl" isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay>
|
||||
<ModalContent background={bodyBackground}>
|
||||
<ModalHeader as="h1" paddingBottom="2">
|
||||
<Box as="span" fontWeight="700">
|
||||
Layers on all pets:
|
||||
</Box>{" "}
|
||||
<Box as="span" fontWeight="normal">
|
||||
{item.name}
|
||||
</Box>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody paddingBottom="12">
|
||||
<BulkAddBodySpecificAssetsForm
|
||||
bulkAddProposal={bulkAddProposal}
|
||||
onSubmit={setBulkAddProposal}
|
||||
/>
|
||||
<Box height="8" />
|
||||
<AllItemLayersSupportModalContent
|
||||
item={item}
|
||||
bulkAddProposal={bulkAddProposal}
|
||||
onBulkAddComplete={() => setBulkAddProposal(null)}
|
||||
/>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</ModalOverlay>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkAddBodySpecificAssetsForm({ bulkAddProposal, onSubmit }) {
|
||||
const [minAssetId, setMinAssetId] = React.useState(
|
||||
bulkAddProposal?.minAssetId
|
||||
);
|
||||
const [assetIdStepValue, setAssetIdStepValue] = React.useState(1);
|
||||
const [numSpecies, setNumSpecies] = React.useState(55);
|
||||
const [colorId, setColorId] = React.useState("8");
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
as="form"
|
||||
fontSize="sm"
|
||||
opacity="0.9"
|
||||
transition="0.2s all"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ minAssetId, numSpecies, assetIdStepValue, colorId });
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
label={
|
||||
<Box textAlign="center" fontSize="xs">
|
||||
<Box as="p" marginBottom="1em">
|
||||
When an item accidentally gets assigned to fit all bodies, this
|
||||
tool can help you recover the original appearances, by assuming
|
||||
the layer IDs are assigned to each species in alphabetical order.
|
||||
</Box>
|
||||
<Box as="p">
|
||||
This will only find layers that have already been modeled!
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Flex align="center" tabIndex="0">
|
||||
<EditIcon marginRight="1" />
|
||||
<Box>Bulk-add:</Box>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<Box width="2" />
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
size="xs"
|
||||
width="9ch"
|
||||
placeholder="Min ID"
|
||||
value={minAssetId || ""}
|
||||
onChange={(e) => setMinAssetId(e.target.value || null)}
|
||||
/>
|
||||
<Box width="1" />
|
||||
<Box>–</Box>
|
||||
<Box width="1" />
|
||||
<Input
|
||||
type="number"
|
||||
min="55"
|
||||
step="1"
|
||||
size="xs"
|
||||
width="9ch"
|
||||
placeholder="Max ID"
|
||||
// Because this is an inclusive range, the offset between the numbers
|
||||
// is one less than the number of entries in the range.
|
||||
value={
|
||||
minAssetId != null
|
||||
? Number(minAssetId) + assetIdStepValue * (numSpecies - 1)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setMinAssetId(
|
||||
e.target.value
|
||||
? Number(e.target.value) - assetIdStepValue * (numSpecies - 1)
|
||||
: null
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Box width="1" />
|
||||
<Select
|
||||
size="xs"
|
||||
width="12ch"
|
||||
value={String(assetIdStepValue)}
|
||||
onChange={(e) => setAssetIdStepValue(Number(e.target.value))}
|
||||
>
|
||||
<option value="1">(All IDs)</option>
|
||||
<option value="2">(Every other ID)</option>
|
||||
<option value="3">(Every 3rd ID)</option>
|
||||
</Select>
|
||||
<Box width="1" />
|
||||
for
|
||||
<Box width="1" />
|
||||
<Select
|
||||
size="xs"
|
||||
width="20ch"
|
||||
value={String(numSpecies)}
|
||||
onChange={(e) => setNumSpecies(Number(e.target.value))}
|
||||
>
|
||||
<option value="55">All 55 species</option>
|
||||
<option value="54">54 species, no Vandagyre</option>
|
||||
</Select>
|
||||
<Box width="1" />
|
||||
<Select
|
||||
size="xs"
|
||||
width="20ch"
|
||||
value={colorId}
|
||||
onChange={(e) => setColorId(e.target.value)}
|
||||
>
|
||||
<option value="8">All standard colors</option>
|
||||
<option value="6">Baby</option>
|
||||
<option value="46">Mutant</option>
|
||||
</Select>
|
||||
<Box width="2" />
|
||||
<Button type="submit" size="xs" isDisabled={minAssetId == null}>
|
||||
Preview
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const allAppearancesFragment = gql`
|
||||
fragment AllAppearancesForItem on Item {
|
||||
allAppearances {
|
||||
id
|
||||
body {
|
||||
id
|
||||
representsAllBodies
|
||||
canonicalAppearance {
|
||||
id
|
||||
species {
|
||||
id
|
||||
name
|
||||
}
|
||||
color {
|
||||
id
|
||||
name
|
||||
isStandard
|
||||
}
|
||||
pose
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
...ItemAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
|
||||
${itemAppearanceFragment}
|
||||
${petAppearanceFragment}
|
||||
`;
|
||||
|
||||
function AllItemLayersSupportModalContent({
|
||||
item,
|
||||
bulkAddProposal,
|
||||
onBulkAddComplete,
|
||||
}) {
|
||||
const { supportSecret } = useSupport();
|
||||
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query AllItemLayersSupportModal($itemId: ID!) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
...AllAppearancesForItem
|
||||
}
|
||||
}
|
||||
|
||||
${allAppearancesFragment}
|
||||
`,
|
||||
{ variables: { itemId: item.id } }
|
||||
);
|
||||
|
||||
const {
|
||||
loading: loading2,
|
||||
error: error2,
|
||||
data: bulkAddProposalData,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query AllItemLayersSupportModal_BulkAddProposal(
|
||||
$layerRemoteIds: [ID!]!
|
||||
$colorId: ID!
|
||||
) {
|
||||
layersToAdd: itemAppearanceLayersByRemoteId(
|
||||
remoteIds: $layerRemoteIds
|
||||
) {
|
||||
id
|
||||
remoteId
|
||||
...AppearanceLayerForOutfitPreview
|
||||
...AppearanceLayerForSupport
|
||||
}
|
||||
|
||||
color(id: $colorId) {
|
||||
id
|
||||
appliedToAllCompatibleSpecies {
|
||||
id
|
||||
species {
|
||||
id
|
||||
name
|
||||
}
|
||||
body {
|
||||
id
|
||||
}
|
||||
canonicalAppearance {
|
||||
# These are a bit redundant, but it's convenient to just reuse
|
||||
# what the other query is already doing.
|
||||
id
|
||||
species {
|
||||
id
|
||||
name
|
||||
}
|
||||
color {
|
||||
id
|
||||
name
|
||||
isStandard
|
||||
}
|
||||
pose
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${appearanceLayerFragment}
|
||||
${appearanceLayerFragmentForSupport}
|
||||
${petAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
layerRemoteIds: bulkAddProposal
|
||||
? Array.from({ length: 54 }, (_, i) =>
|
||||
String(
|
||||
Number(bulkAddProposal.minAssetId) +
|
||||
i * bulkAddProposal.assetIdStepValue
|
||||
)
|
||||
)
|
||||
: [],
|
||||
colorId: bulkAddProposal?.colorId,
|
||||
},
|
||||
skip: bulkAddProposal == null,
|
||||
}
|
||||
);
|
||||
|
||||
const [
|
||||
sendBulkAddMutation,
|
||||
{ loading: mutationLoading, error: mutationError },
|
||||
] = useMutation(gql`
|
||||
mutation AllItemLayersSupportModal_BulkAddMutation(
|
||||
$itemId: ID!
|
||||
$entries: [BulkAddLayersToItemEntry!]!
|
||||
$supportSecret: String!
|
||||
) {
|
||||
bulkAddLayersToItem(
|
||||
itemId: $itemId
|
||||
entries: $entries
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
...AllAppearancesForItem
|
||||
}
|
||||
}
|
||||
|
||||
${allAppearancesFragment}
|
||||
`);
|
||||
|
||||
if (loading || loading2) {
|
||||
return (
|
||||
<Flex align="center" justify="center" minHeight="64">
|
||||
<HangerSpinner />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || error2) {
|
||||
return <ErrorMessage>{(error || error2).message}</ErrorMessage>;
|
||||
}
|
||||
|
||||
let itemAppearances = data.item?.allAppearances || [];
|
||||
itemAppearances = mergeBulkAddProposalIntoItemAppearances(
|
||||
itemAppearances,
|
||||
bulkAddProposal,
|
||||
bulkAddProposalData
|
||||
);
|
||||
itemAppearances = [...itemAppearances].sort((a, b) => {
|
||||
const aKey = getSortKeyForBody(a.body);
|
||||
const bKey = getSortKeyForBody(b.body);
|
||||
return aKey.localeCompare(bKey);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{bulkAddProposalData && (
|
||||
<Flex align="center" marginBottom="6">
|
||||
<Heading size="md">Previewing bulk-add changes</Heading>
|
||||
<Box flex="1 0 auto" width="4" />
|
||||
{mutationError && (
|
||||
<ErrorMessage fontSize="xs" textAlign="right" marginRight="2">
|
||||
{mutationError.message}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
<Button flex="0 0 auto" size="sm" onClick={onBulkAddComplete}>
|
||||
Clear
|
||||
</Button>
|
||||
<Box width="2" />
|
||||
<Button
|
||||
flex="0 0 auto"
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
isLoading={mutationLoading}
|
||||
onClick={() => {
|
||||
if (
|
||||
!window.confirm("Are you sure? Bulk operations are dangerous!")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// HACK: This could pick up not just new layers, but existing layers
|
||||
// that aren't changing. Shouldn't be a problem to save,
|
||||
// though?
|
||||
// NOTE: This API uses actual layer IDs, instead of the remote IDs
|
||||
// that we use for body assignment in most of this tool.
|
||||
const entries = itemAppearances
|
||||
.map((a) =>
|
||||
a.layers.map((l) => ({ layerId: l.id, bodyId: a.body.id }))
|
||||
)
|
||||
.flat();
|
||||
|
||||
sendBulkAddMutation({
|
||||
variables: { itemId: item.id, entries, supportSecret },
|
||||
})
|
||||
.then(onBulkAddComplete)
|
||||
.catch((e) => {
|
||||
/* Handled in UI */
|
||||
});
|
||||
}}
|
||||
>
|
||||
Save {bulkAddProposalData.layersToAdd.length} changes
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
<Wrap justify="center" spacing="4">
|
||||
{itemAppearances.map((itemAppearance) => (
|
||||
<WrapItem key={itemAppearance.id}>
|
||||
<ItemAppearanceCard item={item} itemAppearance={itemAppearance} />
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemAppearanceCard({ item, itemAppearance }) {
|
||||
const petAppearance = itemAppearance.body.canonicalAppearance;
|
||||
const biologyLayers = petAppearance.layers;
|
||||
const itemLayers = [...itemAppearance.layers].sort(
|
||||
(a, b) => a.zone.depth - b.zone.depth
|
||||
);
|
||||
|
||||
const { brightBackground } = useCommonStyles();
|
||||
|
||||
return (
|
||||
<Box
|
||||
background={brightBackground}
|
||||
paddingX="4"
|
||||
paddingY="3"
|
||||
boxShadow="lg"
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Heading as="h2" size="sm" fontWeight="600">
|
||||
{getBodyName(itemAppearance.body)}
|
||||
</Heading>
|
||||
<Box height="3" />
|
||||
<Wrap paddingX="3" spacing="5">
|
||||
{itemLayers.length === 0 && (
|
||||
<Flex
|
||||
minWidth="150px"
|
||||
minHeight="150px"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Box fontSize="sm" fontStyle="italic">
|
||||
(No data)
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{itemLayers.map((itemLayer) => (
|
||||
<WrapItem key={itemLayer.id}>
|
||||
<ItemSupportAppearanceLayer
|
||||
item={item}
|
||||
itemLayer={itemLayer}
|
||||
biologyLayers={biologyLayers}
|
||||
outfitState={{
|
||||
speciesId: petAppearance.species.id,
|
||||
colorId: petAppearance.color.id,
|
||||
pose: petAppearance.pose,
|
||||
}}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function getSortKeyForBody(body) {
|
||||
// "All bodies" sorts first!
|
||||
if (body.representsAllBodies) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const { color, species } = body.canonicalAppearance;
|
||||
// Sort standard colors first, then special colors by name, then by species
|
||||
// within each color.
|
||||
return `${color.isStandard ? "A" : "Z"}-${color.name}-${species.name}`;
|
||||
}
|
||||
|
||||
function getBodyName(body) {
|
||||
if (body.representsAllBodies) {
|
||||
return "All bodies";
|
||||
}
|
||||
|
||||
const { species, color } = body.canonicalAppearance;
|
||||
const speciesName = capitalize(species.name);
|
||||
const colorName = color.isStandard ? "Standard" : capitalize(color.name);
|
||||
return `${colorName} ${speciesName}`;
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
return str[0].toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
function mergeBulkAddProposalIntoItemAppearances(
|
||||
itemAppearances,
|
||||
bulkAddProposal,
|
||||
bulkAddProposalData
|
||||
) {
|
||||
if (!bulkAddProposalData) {
|
||||
return itemAppearances;
|
||||
}
|
||||
|
||||
const { color, layersToAdd } = bulkAddProposalData;
|
||||
|
||||
// Do a deep copy of the existing item appearances, so we can mutate them as
|
||||
// we loop through them in this function!
|
||||
const mergedItemAppearances = JSON.parse(JSON.stringify(itemAppearances));
|
||||
|
||||
// To exclude Vandagyre, we take the first N species by ID - which is
|
||||
// different than the alphabetical sort order we use for assigning layers!
|
||||
const speciesColorPairsToInclude = [...color.appliedToAllCompatibleSpecies]
|
||||
.sort((a, b) => Number(a.species.id) - Number(b.species.id))
|
||||
.slice(0, bulkAddProposal.numSpecies);
|
||||
|
||||
// Set up the incoming data in convenient formats.
|
||||
const sortedSpeciesColorPairs = [...speciesColorPairsToInclude].sort((a, b) =>
|
||||
a.species.name.localeCompare(b.species.name)
|
||||
);
|
||||
const layersToAddByRemoteId = {};
|
||||
for (const layer of layersToAdd) {
|
||||
layersToAddByRemoteId[layer.remoteId] = layer;
|
||||
}
|
||||
|
||||
for (const [index, speciesColorPair] of sortedSpeciesColorPairs.entries()) {
|
||||
const { body, canonicalAppearance } = speciesColorPair;
|
||||
|
||||
// Find the existing item appearance to add to, or create a new one if it
|
||||
// doesn't exist yet.
|
||||
let itemAppearance = mergedItemAppearances.find(
|
||||
(a) => a.body.id === body.id && !a.body.representsAllBodies
|
||||
);
|
||||
if (!itemAppearance) {
|
||||
itemAppearance = {
|
||||
id: `bulk-add-proposal-new-item-appearance-for-body-${body.id}`,
|
||||
layers: [],
|
||||
body: {
|
||||
id: body.id,
|
||||
canonicalAppearance,
|
||||
},
|
||||
};
|
||||
mergedItemAppearances.push(itemAppearance);
|
||||
}
|
||||
|
||||
const layerToAddRemoteId = String(
|
||||
Number(bulkAddProposal.minAssetId) +
|
||||
index * bulkAddProposal.assetIdStepValue
|
||||
);
|
||||
const layerToAdd = layersToAddByRemoteId[layerToAddRemoteId];
|
||||
if (!layerToAdd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete this layer from other appearances (because we're going to
|
||||
// override its body ID), then add it to this new one.
|
||||
for (const otherItemAppearance of mergedItemAppearances) {
|
||||
const indexToDelete = otherItemAppearance.layers.findIndex(
|
||||
(l) => l.remoteId === layerToAddRemoteId
|
||||
);
|
||||
if (indexToDelete >= 0) {
|
||||
otherItemAppearance.layers.splice(indexToDelete, 1);
|
||||
}
|
||||
}
|
||||
itemAppearance.layers.push(layerToAdd);
|
||||
}
|
||||
|
||||
return mergedItemAppearances;
|
||||
}
|
||||
|
||||
export default AllItemLayersSupportModal;
|
|
@ -0,0 +1,644 @@
|
|||
import * as React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Spinner,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
CheckboxGroup,
|
||||
VStack,
|
||||
Checkbox,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChevronRightIcon, ExternalLinkIcon } from "@chakra-ui/icons";
|
||||
|
||||
import AppearanceLayerSupportUploadModal from "./AppearanceLayerSupportUploadModal";
|
||||
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
|
||||
import { OutfitLayers } from "../../components/OutfitPreview";
|
||||
import SpeciesColorPicker from "../../components/SpeciesColorPicker";
|
||||
import useOutfitAppearance, {
|
||||
itemAppearanceFragment,
|
||||
} from "../../components/useOutfitAppearance";
|
||||
import useSupport from "./useSupport";
|
||||
|
||||
/**
|
||||
* AppearanceLayerSupportModal offers Support info and tools for a specific item
|
||||
* appearance layer. Open it by clicking a layer from ItemSupportDrawer.
|
||||
*/
|
||||
function AppearanceLayerSupportModal({
|
||||
item, // Specify this or `petAppearance`
|
||||
petAppearance, // Specify this or `item`
|
||||
layer,
|
||||
outfitState, // speciesId, colorId, pose
|
||||
isOpen,
|
||||
onClose,
|
||||
}) {
|
||||
const [selectedBodyId, setSelectedBodyId] = React.useState(layer.bodyId);
|
||||
const [selectedKnownGlitches, setSelectedKnownGlitches] = React.useState(
|
||||
layer.knownGlitches
|
||||
);
|
||||
|
||||
const [previewBiology, setPreviewBiology] = React.useState({
|
||||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
isValid: true,
|
||||
});
|
||||
const [uploadModalIsOpen, setUploadModalIsOpen] = React.useState(false);
|
||||
const { supportSecret } = useSupport();
|
||||
const toast = useToast();
|
||||
|
||||
const parentName = item
|
||||
? item.name
|
||||
: `${petAppearance.color.name} ${petAppearance.species.name} ${petAppearance.id}`;
|
||||
|
||||
const [mutate, { loading: mutationLoading, error: mutationError }] =
|
||||
useMutation(
|
||||
gql`
|
||||
mutation ApperanceLayerSupportSetLayerBodyId(
|
||||
$layerId: ID!
|
||||
$bodyId: ID!
|
||||
$knownGlitches: [AppearanceLayerKnownGlitch!]!
|
||||
$supportSecret: String!
|
||||
$outfitSpeciesId: ID!
|
||||
$outfitColorId: ID!
|
||||
$formPreviewSpeciesId: ID!
|
||||
$formPreviewColorId: ID!
|
||||
) {
|
||||
setLayerBodyId(
|
||||
layerId: $layerId
|
||||
bodyId: $bodyId
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
# This mutation returns the affected AppearanceLayer. Fetch the
|
||||
# updated fields, including the appearance on the outfit pet and the
|
||||
# form preview pet, to automatically update our cached appearance in
|
||||
# the rest of the app. That means you should be able to see your
|
||||
# changes immediately!
|
||||
id
|
||||
bodyId
|
||||
item {
|
||||
id
|
||||
appearanceOnOutfit: appearanceOn(
|
||||
speciesId: $outfitSpeciesId
|
||||
colorId: $outfitColorId
|
||||
) {
|
||||
...ItemAppearanceForOutfitPreview
|
||||
}
|
||||
|
||||
appearanceOnFormPreviewPet: appearanceOn(
|
||||
speciesId: $formPreviewSpeciesId
|
||||
colorId: $formPreviewColorId
|
||||
) {
|
||||
...ItemAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLayerKnownGlitches(
|
||||
layerId: $layerId
|
||||
knownGlitches: $knownGlitches
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
knownGlitches
|
||||
svgUrl # Affected by OFFICIAL_SVG_IS_INCORRECT
|
||||
}
|
||||
}
|
||||
${itemAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
layerId: layer.id,
|
||||
bodyId: selectedBodyId,
|
||||
knownGlitches: selectedKnownGlitches,
|
||||
supportSecret,
|
||||
outfitSpeciesId: outfitState.speciesId,
|
||||
outfitColorId: outfitState.colorId,
|
||||
formPreviewSpeciesId: previewBiology.speciesId,
|
||||
formPreviewColorId: previewBiology.colorId,
|
||||
},
|
||||
onCompleted: () => {
|
||||
onClose();
|
||||
toast({
|
||||
status: "success",
|
||||
title: `Saved layer ${layer.id}: ${parentName}`,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Would be nicer to just learn the correct URL from the server, but we
|
||||
// don't happen to be saving it, and it would be extra stuff to put on
|
||||
// the GraphQL request for non-Support users. We could also just try
|
||||
// loading them, but, ehhh…
|
||||
const [newManifestUrl, oldManifestUrl] = convertSwfUrlToPossibleManifestUrls(
|
||||
layer.swfUrl
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal size="xl" isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
Layer {layer.id}: {parentName}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Metadata>
|
||||
<MetadataLabel>DTI ID:</MetadataLabel>
|
||||
<MetadataValue>{layer.id}</MetadataValue>
|
||||
<MetadataLabel>Neopets ID:</MetadataLabel>
|
||||
<MetadataValue>{layer.remoteId}</MetadataValue>
|
||||
<MetadataLabel>Zone:</MetadataLabel>
|
||||
<MetadataValue>
|
||||
{layer.zone.label} ({layer.zone.id})
|
||||
</MetadataValue>
|
||||
<MetadataLabel>Assets:</MetadataLabel>
|
||||
<MetadataValue>
|
||||
<HStack spacing="2">
|
||||
<Button
|
||||
as="a"
|
||||
size="xs"
|
||||
target="_blank"
|
||||
href={newManifestUrl}
|
||||
colorScheme="teal"
|
||||
>
|
||||
Manifest (new) <ExternalLinkIcon ml="1" />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
size="xs"
|
||||
target="_blank"
|
||||
href={oldManifestUrl}
|
||||
colorScheme="teal"
|
||||
>
|
||||
Manifest (old) <ExternalLinkIcon ml="1" />
|
||||
</Button>
|
||||
</HStack>
|
||||
<HStack spacing="2" marginTop="1">
|
||||
{layer.canvasMovieLibraryUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
size="xs"
|
||||
target="_blank"
|
||||
href={layer.canvasMovieLibraryUrl}
|
||||
colorScheme="teal"
|
||||
>
|
||||
Movie <ExternalLinkIcon ml="1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" isDisabled>
|
||||
No Movie
|
||||
</Button>
|
||||
)}
|
||||
{layer.svgUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
size="xs"
|
||||
target="_blank"
|
||||
href={layer.svgUrl}
|
||||
colorScheme="teal"
|
||||
>
|
||||
SVG <ExternalLinkIcon ml="1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" isDisabled>
|
||||
No SVG
|
||||
</Button>
|
||||
)}
|
||||
{layer.imageUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
size="xs"
|
||||
target="_blank"
|
||||
href={layer.imageUrl}
|
||||
colorScheme="teal"
|
||||
>
|
||||
PNG <ExternalLinkIcon ml="1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" isDisabled>
|
||||
No PNG
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
as="a"
|
||||
size="xs"
|
||||
target="_blank"
|
||||
href={layer.swfUrl}
|
||||
colorScheme="teal"
|
||||
>
|
||||
SWF <ExternalLinkIcon ml="1" />
|
||||
</Button>
|
||||
<Box flex="1 1 0" />
|
||||
{item && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="gray"
|
||||
onClick={() => setUploadModalIsOpen(true)}
|
||||
>
|
||||
Upload PNG <ChevronRightIcon />
|
||||
</Button>
|
||||
<AppearanceLayerSupportUploadModal
|
||||
item={item}
|
||||
layer={layer}
|
||||
isOpen={uploadModalIsOpen}
|
||||
onClose={() => setUploadModalIsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</MetadataValue>
|
||||
</Metadata>
|
||||
<Box height="8" />
|
||||
{item && (
|
||||
<>
|
||||
<AppearanceLayerSupportPetCompatibilityFields
|
||||
item={item}
|
||||
layer={layer}
|
||||
outfitState={outfitState}
|
||||
selectedBodyId={selectedBodyId}
|
||||
previewBiology={previewBiology}
|
||||
onChangeBodyId={setSelectedBodyId}
|
||||
onChangePreviewBiology={setPreviewBiology}
|
||||
/>
|
||||
<Box height="8" />
|
||||
</>
|
||||
)}
|
||||
<AppearanceLayerSupportKnownGlitchesFields
|
||||
selectedKnownGlitches={selectedKnownGlitches}
|
||||
onChange={setSelectedKnownGlitches}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{item && (
|
||||
<AppearanceLayerSupportModalRemoveButton
|
||||
item={item}
|
||||
layer={layer}
|
||||
outfitState={outfitState}
|
||||
onRemoveSuccess={onClose}
|
||||
/>
|
||||
)}
|
||||
<Box flex="1 0 0" />
|
||||
{mutationError && (
|
||||
<Box
|
||||
color="red.400"
|
||||
fontSize="sm"
|
||||
marginLeft="8"
|
||||
marginRight="2"
|
||||
textAlign="right"
|
||||
>
|
||||
{mutationError.message}
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
isLoading={mutationLoading}
|
||||
colorScheme="green"
|
||||
onClick={() =>
|
||||
mutate().catch((e) => {
|
||||
/* Discard errors here; we'll show them in the UI! */
|
||||
})
|
||||
}
|
||||
flex="0 0 auto"
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</ModalOverlay>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceLayerSupportPetCompatibilityFields({
|
||||
item,
|
||||
layer,
|
||||
outfitState,
|
||||
selectedBodyId,
|
||||
previewBiology,
|
||||
onChangeBodyId,
|
||||
onChangePreviewBiology,
|
||||
}) {
|
||||
const [selectedBiology, setSelectedBiology] = React.useState(previewBiology);
|
||||
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
visibleLayers,
|
||||
bodyId: appearanceBodyId,
|
||||
} = useOutfitAppearance({
|
||||
speciesId: previewBiology.speciesId,
|
||||
colorId: previewBiology.colorId,
|
||||
pose: previewBiology.pose,
|
||||
wornItemIds: [item.id],
|
||||
});
|
||||
|
||||
const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
|
||||
|
||||
// After we touch a species/color selector and null out `bodyId`, when the
|
||||
// appearance body ID loads in, select it as the new body ID.
|
||||
//
|
||||
// This might move the radio button away from "all pets", but I think that's
|
||||
// a _less_ surprising experience: if you're touching the pickers, then
|
||||
// that's probably where you head is.
|
||||
React.useEffect(() => {
|
||||
if (selectedBodyId == null && appearanceBodyId != null) {
|
||||
onChangeBodyId(appearanceBodyId);
|
||||
}
|
||||
}, [selectedBodyId, appearanceBodyId, onChangeBodyId]);
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={error || !selectedBiology.isValid ? true : false}>
|
||||
<FormLabel fontWeight="bold">Pet compatibility</FormLabel>
|
||||
<RadioGroup
|
||||
colorScheme="green"
|
||||
value={selectedBodyId}
|
||||
onChange={(newBodyId) => onChangeBodyId(newBodyId)}
|
||||
marginBottom="4"
|
||||
>
|
||||
<Radio value="0">
|
||||
Fits all pets{" "}
|
||||
<Box display="inline" color="gray.400" fontSize="sm">
|
||||
(Body ID: 0)
|
||||
</Box>
|
||||
</Radio>
|
||||
<Radio as="div" value={appearanceBodyId} marginTop="2">
|
||||
Fits all pets with the same body as:{" "}
|
||||
<Box display="inline" color="gray.400" fontSize="sm">
|
||||
(Body ID:{" "}
|
||||
{appearanceBodyId == null ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
appearanceBodyId
|
||||
)}
|
||||
)
|
||||
</Box>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<Box
|
||||
width="150px"
|
||||
height="150px"
|
||||
marginTop="2"
|
||||
marginBottom="2"
|
||||
boxShadow="md"
|
||||
borderRadius="md"
|
||||
>
|
||||
<OutfitLayers
|
||||
loading={loading}
|
||||
visibleLayers={[...biologyLayers, layer]}
|
||||
/>
|
||||
</Box>
|
||||
<SpeciesColorPicker
|
||||
speciesId={selectedBiology.speciesId}
|
||||
colorId={selectedBiology.colorId}
|
||||
idealPose={outfitState.pose}
|
||||
size="sm"
|
||||
showPlaceholders
|
||||
onChange={(species, color, isValid, pose) => {
|
||||
const speciesId = species.id;
|
||||
const colorId = color.id;
|
||||
|
||||
setSelectedBiology({ speciesId, colorId, isValid, pose });
|
||||
if (isValid) {
|
||||
onChangePreviewBiology({ speciesId, colorId, isValid, pose });
|
||||
|
||||
// Also temporarily null out the body ID. We'll switch to the new
|
||||
// body ID once it's loaded.
|
||||
onChangeBodyId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box height="1" />
|
||||
{!error && (
|
||||
<FormHelperText>
|
||||
If it doesn't look right, try some other options until it does!
|
||||
</FormHelperText>
|
||||
)}
|
||||
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
|
||||
</Box>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceLayerSupportKnownGlitchesFields({
|
||||
selectedKnownGlitches,
|
||||
onChange,
|
||||
}) {
|
||||
return (
|
||||
<FormControl>
|
||||
<FormLabel fontWeight="bold">Known glitches</FormLabel>
|
||||
<CheckboxGroup value={selectedKnownGlitches} onChange={onChange}>
|
||||
<VStack spacing="2" align="flex-start">
|
||||
<Checkbox value="OFFICIAL_SWF_IS_INCORRECT">
|
||||
Official SWF is incorrect{" "}
|
||||
<Box display="inline" color="gray.400" fontSize="sm">
|
||||
(Will display a message)
|
||||
</Box>
|
||||
</Checkbox>
|
||||
<Checkbox value="OFFICIAL_SVG_IS_INCORRECT">
|
||||
Official SVG is incorrect{" "}
|
||||
<Box display="inline" color="gray.400" fontSize="sm">
|
||||
(Will use the PNG instead)
|
||||
</Box>
|
||||
</Checkbox>
|
||||
<Checkbox value="OFFICIAL_MOVIE_IS_INCORRECT">
|
||||
Official Movie is incorrect{" "}
|
||||
<Box display="inline" color="gray.400" fontSize="sm">
|
||||
(Will display a message)
|
||||
</Box>
|
||||
</Checkbox>
|
||||
<Checkbox value="DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN">
|
||||
Displays incorrectly, but cause unknown{" "}
|
||||
<Box display="inline" color="gray.400" fontSize="sm">
|
||||
(Will display a vague message)
|
||||
</Box>
|
||||
</Checkbox>
|
||||
<Checkbox value="OFFICIAL_BODY_ID_IS_INCORRECT">
|
||||
Fits all pets on-site, but should not{" "}
|
||||
<Box display="inline" color="gray.400" fontSize="sm">
|
||||
(TNT's fault. Will show a message, and keep the compatibility
|
||||
settings above.)
|
||||
</Box>
|
||||
</Checkbox>
|
||||
<Checkbox value="REQUIRES_OTHER_BODY_SPECIFIC_ASSETS">
|
||||
Only fits pets with other body-specific assets{" "}
|
||||
<Box display="inline" color="gray.400" fontSize="sm">
|
||||
(DTI's fault: bodyId=0 is a lie! Will mark incompatible for some
|
||||
pets.)
|
||||
</Box>
|
||||
</Checkbox>
|
||||
</VStack>
|
||||
</CheckboxGroup>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceLayerSupportModalRemoveButton({
|
||||
item,
|
||||
layer,
|
||||
outfitState,
|
||||
onRemoveSuccess,
|
||||
}) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
const { supportSecret } = useSupport();
|
||||
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
gql`
|
||||
mutation AppearanceLayerSupportRemoveButton(
|
||||
$layerId: ID!
|
||||
$itemId: ID!
|
||||
$outfitSpeciesId: ID!
|
||||
$outfitColorId: ID!
|
||||
$supportSecret: String!
|
||||
) {
|
||||
removeLayerFromItem(
|
||||
layerId: $layerId
|
||||
itemId: $itemId
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
# This mutation returns the affected layer, and the affected item.
|
||||
# Fetch the updated appearance for the current outfit, which should
|
||||
# no longer include this layer. This means you should be able to see
|
||||
# your changes immediately!
|
||||
item {
|
||||
id
|
||||
appearanceOn(speciesId: $outfitSpeciesId, colorId: $outfitColorId) {
|
||||
...ItemAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
|
||||
# The layer's item should be null now, fetch to confirm and update!
|
||||
layer {
|
||||
id
|
||||
item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${itemAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
layerId: layer.id,
|
||||
itemId: item.id,
|
||||
outfitSpeciesId: outfitState.speciesId,
|
||||
outfitColorId: outfitState.colorId,
|
||||
supportSecret,
|
||||
},
|
||||
onCompleted: () => {
|
||||
onClose();
|
||||
onRemoveSuccess();
|
||||
toast({
|
||||
status: "success",
|
||||
title: `Removed layer ${layer.id} from ${item.name}`,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button colorScheme="red" flex="0 0 auto" onClick={onOpen}>
|
||||
Remove
|
||||
</Button>
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
|
||||
<ModalOverlay>
|
||||
<ModalContent>
|
||||
<ModalCloseButton />
|
||||
<ModalHeader>
|
||||
Remove Layer {layer.id} ({layer.zone.label}) from {item.name}?
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Box as="p" marginBottom="4">
|
||||
This will permanently-ish remove Layer {layer.id} (
|
||||
{layer.zone.label}) from this item.
|
||||
</Box>
|
||||
<Box as="p" marginBottom="4">
|
||||
If you remove a correct layer by mistake, re-modeling should fix
|
||||
it, or Matchu can restore it if you write down the layer ID
|
||||
before proceeding!
|
||||
</Box>
|
||||
<Box as="p" marginBottom="4">
|
||||
Are you sure you want to remove Layer {layer.id} from this item?
|
||||
</Box>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button flex="0 0 auto" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Box flex="1 0 0" />
|
||||
{error && (
|
||||
<Box
|
||||
color="red.400"
|
||||
fontSize="sm"
|
||||
marginLeft="8"
|
||||
marginRight="2"
|
||||
textAlign="right"
|
||||
>
|
||||
{error.message}
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
colorScheme="red"
|
||||
flex="0 0 auto"
|
||||
onClick={() =>
|
||||
mutate().catch((e) => {
|
||||
/* Discard errors here; we'll show them in the UI! */
|
||||
})
|
||||
}
|
||||
isLoading={loading}
|
||||
>
|
||||
Yes, remove permanently
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</ModalOverlay>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SWF_URL_PATTERN =
|
||||
/^https?:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/;
|
||||
|
||||
function convertSwfUrlToPossibleManifestUrls(swfUrl) {
|
||||
const match = new URL(swfUrl, "http://images.neopets.com")
|
||||
.toString()
|
||||
.match(SWF_URL_PATTERN);
|
||||
if (!match) {
|
||||
throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`);
|
||||
}
|
||||
|
||||
const type = match[1];
|
||||
const folders = match[2];
|
||||
const hash = match[3];
|
||||
|
||||
// TODO: There are a few potential manifest URLs in play! Long-term, we
|
||||
// should get this from modeling data. But these are some good guesses!
|
||||
return [
|
||||
`http://images.neopets.com/cp/${type}/data/${folders}/manifest.json`,
|
||||
`http://images.neopets.com/cp/${type}/data/${folders}_${hash}/manifest.json`,
|
||||
];
|
||||
}
|
||||
|
||||
export default AppearanceLayerSupportModal;
|
|
@ -0,0 +1,639 @@
|
|||
import * as React from "react";
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
||||
|
||||
import { safeImageUrl } from "../../util";
|
||||
import useSupport from "./useSupport";
|
||||
|
||||
/**
|
||||
* AppearanceLayerSupportUploadModal helps Support users create and upload PNGs for
|
||||
* broken appearance layers. Useful when the auto-converters are struggling,
|
||||
* e.g. the SWF uses a color filter our server-side Flash player can't support!
|
||||
*/
|
||||
function AppearanceLayerSupportUploadModal({ item, layer, isOpen, onClose }) {
|
||||
const [step, setStep] = React.useState(1);
|
||||
const [imageOnBlackUrl, setImageOnBlackUrl] = React.useState(null);
|
||||
const [imageOnWhiteUrl, setImageOnWhiteUrl] = React.useState(null);
|
||||
|
||||
const [imageWithAlphaUrl, setImageWithAlphaUrl] = React.useState(null);
|
||||
const [imageWithAlphaBlob, setImageWithAlphaBlob] = React.useState(null);
|
||||
const [numWarnings, setNumWarnings] = React.useState(null);
|
||||
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
const [uploadError, setUploadError] = React.useState(null);
|
||||
|
||||
const [conflictMode, setConflictMode] = React.useState("onBlack");
|
||||
|
||||
const { supportSecret } = useSupport();
|
||||
const toast = useToast();
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
// Once both images are ready, merge them!
|
||||
React.useEffect(() => {
|
||||
if (!imageOnBlackUrl || !imageOnWhiteUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImageWithAlphaUrl(null);
|
||||
setNumWarnings(null);
|
||||
setIsUploading(false);
|
||||
|
||||
mergeIntoImageWithAlpha(
|
||||
imageOnBlackUrl,
|
||||
imageOnWhiteUrl,
|
||||
conflictMode
|
||||
).then(([url, blob, numWarnings]) => {
|
||||
setImageWithAlphaUrl(url);
|
||||
setImageWithAlphaBlob(blob);
|
||||
setNumWarnings(numWarnings);
|
||||
});
|
||||
}, [imageOnBlackUrl, imageOnWhiteUrl, conflictMode]);
|
||||
|
||||
const onUpload = React.useCallback(
|
||||
(e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (re) => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
setImageOnBlackUrl(re.target.result);
|
||||
setStep(2);
|
||||
return;
|
||||
case 2:
|
||||
setImageOnWhiteUrl(re.target.result);
|
||||
setStep(3);
|
||||
return;
|
||||
default:
|
||||
throw new Error(`unexpected step ${step}`);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[step]
|
||||
);
|
||||
|
||||
const onSubmitFinalImage = React.useCallback(async () => {
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/uploadLayerImage?layerId=${layer.id}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"DTI-Support-Secret": supportSecret,
|
||||
},
|
||||
body: imageWithAlphaBlob,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setIsUploading(false);
|
||||
setUploadError(
|
||||
new Error(`Network error: ${res.status} ${res.statusText}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(false);
|
||||
onClose();
|
||||
toast({
|
||||
status: "success",
|
||||
title: "Image successfully uploaded",
|
||||
description: "It might take a few seconds to update in the app!",
|
||||
});
|
||||
|
||||
// NOTE: I tried to do this as a cache update, but I couldn't ever get
|
||||
// the fragment with size parameters to work :/ (Other fields would
|
||||
// update, but not these!) Ultimately the eviction is the only
|
||||
// reliable method I found :/
|
||||
apolloClient.cache.evict({
|
||||
id: `AppearanceLayer:${layer.id}`,
|
||||
fieldName: "imageUrl",
|
||||
});
|
||||
apolloClient.cache.evict({
|
||||
id: `AppearanceLayer:${layer.id}`,
|
||||
fieldName: "imageUrlV2",
|
||||
});
|
||||
} catch (e) {
|
||||
setIsUploading(false);
|
||||
setUploadError(e);
|
||||
}
|
||||
}, [
|
||||
imageWithAlphaBlob,
|
||||
supportSecret,
|
||||
layer.id,
|
||||
toast,
|
||||
onClose,
|
||||
apolloClient.cache,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
// HACK: The built-in `full` size also sets 100% height, which I don't
|
||||
// want; and the docs suggest it will accept px values, but it
|
||||
// doesn't. But I discovered that invalid size values are treated
|
||||
// as 100% width and auto height, so, okay! ^_^` Probably a bug,
|
||||
// but I intend to use it for now!
|
||||
size="full-hack"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<ModalOverlay>
|
||||
<ModalContent>
|
||||
<ModalHeader textAlign="center">
|
||||
Upload PNG for {item.name}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody
|
||||
paddingBottom="2"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
textAlign="center"
|
||||
>
|
||||
{(step === 1 || step === 2) && (
|
||||
<AppearanceLayerSupportScreenshotStep
|
||||
layer={layer}
|
||||
step={step}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<AppearanceLayerSupportReviewStep
|
||||
imageWithAlphaUrl={imageWithAlphaUrl}
|
||||
numWarnings={numWarnings}
|
||||
conflictMode={conflictMode}
|
||||
onChangeConflictMode={setConflictMode}
|
||||
/>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="red" onClick={() => setStep(1)}>
|
||||
Restart
|
||||
</Button>
|
||||
<Box flex="1 1 0" />
|
||||
{uploadError && (
|
||||
<Box
|
||||
color="red.400"
|
||||
fontSize="sm"
|
||||
marginRight="2"
|
||||
textAlign="right"
|
||||
>
|
||||
{uploadError.message}
|
||||
</Box>
|
||||
)}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
{step === 3 && (
|
||||
<Button
|
||||
colorScheme="green"
|
||||
marginLeft="2"
|
||||
onClick={onSubmitFinalImage}
|
||||
isLoading={isUploading}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</ModalOverlay>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceLayerSupportScreenshotStep({ layer, step, onUpload }) {
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<b>Step {step}:</b> Take a screenshot of exactly the 600×600 Flash
|
||||
region, then upload it below.
|
||||
<br />
|
||||
The border will turn green once the entire region is in view.
|
||||
</Box>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
maxWidth="600px"
|
||||
width="100%"
|
||||
marginTop="2"
|
||||
>
|
||||
<input key={step} type="file" accept="image/png" onChange={onUpload} />
|
||||
<Box flex="1 1 0" />
|
||||
<Button
|
||||
as="a"
|
||||
href="https://support.mozilla.org/en-US/kb/firefox-screenshots"
|
||||
target="_blank"
|
||||
size="xs"
|
||||
marginLeft="1"
|
||||
colorScheme="gray"
|
||||
>
|
||||
Firefox help <ExternalLinkIcon marginLeft="1" />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://umaar.com/dev-tips/156-element-screenshot/"
|
||||
target="_blank"
|
||||
size="xs"
|
||||
marginLeft="1"
|
||||
colorScheme="gray"
|
||||
>
|
||||
Chrome help <ExternalLinkIcon marginLeft="1" />
|
||||
</Button>
|
||||
</Box>
|
||||
<AppearanceLayerSupportFlashPlayer
|
||||
swfUrl={layer.swfUrl}
|
||||
backgroundColor={step === 1 ? "black" : "white"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceLayerSupportReviewStep({
|
||||
imageWithAlphaUrl,
|
||||
numWarnings,
|
||||
conflictMode,
|
||||
onChangeConflictMode,
|
||||
}) {
|
||||
if (imageWithAlphaUrl == null) {
|
||||
return <Box>Generating image…</Box>;
|
||||
}
|
||||
|
||||
const ratioBad = numWarnings / (600 * 600);
|
||||
const ratioGood = 1 - ratioBad;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<b>Step 3:</b> Does this look correct? If so, let's upload it!
|
||||
</Box>
|
||||
<Box fontSize="sm" color="gray.500">
|
||||
({Math.floor(ratioGood * 10000) / 100}% match,{" "}
|
||||
{Math.floor(ratioBad * 10000) / 100}% mismatch.)
|
||||
</Box>
|
||||
<Box
|
||||
// Checkerboard pattern: https://stackoverflow.com/a/35362074/107415
|
||||
backgroundImage="linear-gradient(45deg, #c0c0c0 25%, transparent 25%), linear-gradient(-45deg, #c0c0c0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c0c0c0 75%), linear-gradient(-45deg, transparent 75%, #c0c0c0 75%)"
|
||||
backgroundSize="20px 20px"
|
||||
backgroundPosition="0 0, 0 10px, 10px -10px, -10px 0px"
|
||||
marginTop="2"
|
||||
>
|
||||
{imageWithAlphaUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imageWithAlphaUrl}
|
||||
width={600}
|
||||
height={600}
|
||||
alt="Generated layer PNG, on a checkered background"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{numWarnings > 0 && (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="600px"
|
||||
marginTop="2"
|
||||
>
|
||||
<Box flex="0 1 auto" marginRight="2">
|
||||
When pixels conflict, we use…
|
||||
</Box>
|
||||
<Select
|
||||
flex="0 0 200px"
|
||||
value={conflictMode}
|
||||
onChange={(e) => onChangeConflictMode(e.target.value)}
|
||||
>
|
||||
<option value="onBlack">the version on black</option>
|
||||
<option value="onWhite">the version on white</option>
|
||||
<option value="transparent">transparent pixels</option>
|
||||
<option value="moreColorful">the more colorful pixels</option>
|
||||
</Select>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceLayerSupportFlashPlayer({ swfUrl, backgroundColor }) {
|
||||
const [isVisible, setIsVisible] = React.useState(null);
|
||||
const regionRef = React.useRef(null);
|
||||
|
||||
// We detect whether the entire SWF region is visible, because Flash only
|
||||
// bothers to render in visible places. So, screenshotting a SWF container
|
||||
// that isn't fully visible will fill the not-visible space with black,
|
||||
// instead of the actual SWF content. We change the border color to hint this
|
||||
// to the user!
|
||||
React.useLayoutEffect(() => {
|
||||
const region = regionRef.current;
|
||||
if (!region) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollParent = region.closest(".chakra-modal__overlay");
|
||||
if (!scrollParent) {
|
||||
throw new Error(`could not find .chakra-modal__overlay scroll parent`);
|
||||
}
|
||||
|
||||
const onMountOrScrollOrResize = () => {
|
||||
const regionBox = region.getBoundingClientRect();
|
||||
const scrollParentBox = scrollParent.getBoundingClientRect();
|
||||
const isVisible =
|
||||
regionBox.left > scrollParentBox.left &&
|
||||
regionBox.right < scrollParentBox.right &&
|
||||
regionBox.top > scrollParentBox.top &&
|
||||
regionBox.bottom < scrollParentBox.bottom;
|
||||
setIsVisible(isVisible);
|
||||
};
|
||||
|
||||
onMountOrScrollOrResize();
|
||||
|
||||
scrollParent.addEventListener("scroll", onMountOrScrollOrResize);
|
||||
window.addEventListener("resize", onMountOrScrollOrResize);
|
||||
|
||||
return () => {
|
||||
scrollParent.removeEventListener("scroll", onMountOrScrollOrResize);
|
||||
window.removeEventListener("resize", onMountOrScrollOrResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
let borderColor;
|
||||
if (isVisible === null) {
|
||||
borderColor = "gray.400";
|
||||
} else if (isVisible === false) {
|
||||
borderColor = "red.400";
|
||||
} else if (isVisible === true) {
|
||||
borderColor = "green.400";
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-hint="No: Don't screenshot this node! Use the one below!"
|
||||
borderWidth="3px"
|
||||
borderStyle="dashed"
|
||||
borderColor={borderColor}
|
||||
marginTop="4"
|
||||
padding="1px"
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
<Box
|
||||
// In Chrome on macOS, I observe that I need to shift the SWF
|
||||
// one pixel to the left in order to capture it correctly.
|
||||
//
|
||||
// So, in Chrome, who are using a DevTools procedure, we add a
|
||||
// hint that this is the node to use.
|
||||
//
|
||||
// In Firefox, the GUI to target the SWF seems to work just
|
||||
// fine. So, the margin hack and these hints don't matter!
|
||||
data-hint="Yes: Screenshot this node! This is the one!"
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
<Box
|
||||
data-hint="No: Don't screenshot this node! Use the one above!"
|
||||
width="600px"
|
||||
height="600px"
|
||||
// In Chrome on macOS, I observe that I need to shift the SWF
|
||||
// one pixel to the left in order to capture it correctly.
|
||||
//
|
||||
// But this disrupts the Firefox capture! So here, we do a cheap
|
||||
// browser detection, to shift left only in Chrome.
|
||||
marginLeft={navigator.userAgent.includes("Chrome") ? "-1px" : "0"}
|
||||
ref={regionRef}
|
||||
>
|
||||
<object
|
||||
type="application/x-shockwave-flash"
|
||||
data={safeImageUrl(swfUrl)}
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<param name="wmode" value="transparent" />
|
||||
</object>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
async function mergeIntoImageWithAlpha(
|
||||
imageOnBlackUrl,
|
||||
imageOnWhiteUrl,
|
||||
conflictMode
|
||||
) {
|
||||
const [imageOnBlack, imageOnWhite] = await Promise.all([
|
||||
readImageDataFromUrl(imageOnBlackUrl),
|
||||
readImageDataFromUrl(imageOnWhiteUrl),
|
||||
]);
|
||||
|
||||
const [imageWithAlphaData, numWarnings] = mergeDataIntoImageWithAlpha(
|
||||
imageOnBlack,
|
||||
imageOnWhite,
|
||||
conflictMode
|
||||
);
|
||||
const [
|
||||
imageWithAlphaUrl,
|
||||
imageWithAlphaBlob,
|
||||
] = await writeImageDataToUrlAndBlob(imageWithAlphaData);
|
||||
|
||||
return [imageWithAlphaUrl, imageWithAlphaBlob, numWarnings];
|
||||
}
|
||||
|
||||
function mergeDataIntoImageWithAlpha(imageOnBlack, imageOnWhite, conflictMode) {
|
||||
const imageWithAlpha = new ImageData(600, 600);
|
||||
let numWarnings = 0;
|
||||
|
||||
for (let x = 0; x < 600; x++) {
|
||||
for (let y = 0; y < 600; y++) {
|
||||
const pixelIndex = (600 * y + x) << 2;
|
||||
|
||||
const rOnBlack = imageOnBlack.data[pixelIndex];
|
||||
const gOnBlack = imageOnBlack.data[pixelIndex + 1];
|
||||
const bOnBlack = imageOnBlack.data[pixelIndex + 2];
|
||||
const rOnWhite = imageOnWhite.data[pixelIndex];
|
||||
const gOnWhite = imageOnWhite.data[pixelIndex + 1];
|
||||
const bOnWhite = imageOnWhite.data[pixelIndex + 2];
|
||||
if (rOnWhite < rOnBlack || gOnWhite < gOnBlack || bOnWhite < bOnBlack) {
|
||||
if (numWarnings < 100) {
|
||||
console.warn(
|
||||
`[${x}x${y}] color on white should be lighter than color on ` +
|
||||
`black, see pixel ${x}x${y}: ` +
|
||||
`#${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
|
||||
`${gOnWhite.toString(16)}` +
|
||||
` vs ` +
|
||||
`#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
|
||||
`${gOnWhite.toString(16)}. ` +
|
||||
`Using conflict mode ${conflictMode} to fall back.`
|
||||
);
|
||||
}
|
||||
|
||||
const [r, g, b, a] = resolveConflict(
|
||||
[rOnBlack, gOnBlack, bOnBlack],
|
||||
[rOnWhite, gOnWhite, bOnWhite],
|
||||
conflictMode
|
||||
);
|
||||
imageWithAlpha.data[pixelIndex] = r;
|
||||
imageWithAlpha.data[pixelIndex + 1] = g;
|
||||
imageWithAlpha.data[pixelIndex + 2] = b;
|
||||
imageWithAlpha.data[pixelIndex + 3] = a;
|
||||
|
||||
numWarnings++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The true alpha is how close together the on-white and on-black colors
|
||||
// are. If they're totally the same, it's 255 opacity. If they're totally
|
||||
// different, it's 0 opacity. In between, it scales linearly with the
|
||||
// difference!
|
||||
const alpha = 255 - (rOnWhite - rOnBlack);
|
||||
|
||||
// Check that the alpha derived from other channels makes sense too.
|
||||
const alphaByB = 255 - (bOnWhite - bOnBlack);
|
||||
const alphaByG = 255 - (gOnWhite - gOnBlack);
|
||||
const highestAlpha = Math.max(Math.max(alpha, alphaByB), alphaByG);
|
||||
const lowestAlpha = Math.min(Math.min(alpha, alphaByB, alphaByG));
|
||||
if (highestAlpha - lowestAlpha > 2) {
|
||||
if (numWarnings < 100) {
|
||||
console.warn(
|
||||
`[${x}x${y}] derived alpha values don't match: ` +
|
||||
`${alpha} vs ${alphaByB} vs ${alphaByG}. ` +
|
||||
`Colors: #${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
|
||||
`${gOnWhite.toString(16)}` +
|
||||
` vs ` +
|
||||
`#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
|
||||
`${gOnWhite.toString(16)}. ` +
|
||||
`Using conflict mode ${conflictMode} to fall back.`
|
||||
);
|
||||
}
|
||||
|
||||
const [r, g, b, a] = resolveConflict(
|
||||
[rOnBlack, gOnBlack, bOnBlack],
|
||||
[rOnWhite, gOnWhite, bOnWhite],
|
||||
conflictMode
|
||||
);
|
||||
imageWithAlpha.data[pixelIndex] = r;
|
||||
imageWithAlpha.data[pixelIndex + 1] = g;
|
||||
imageWithAlpha.data[pixelIndex + 2] = b;
|
||||
imageWithAlpha.data[pixelIndex + 3] = a;
|
||||
|
||||
numWarnings++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// And the true color is the color on black, divided by the true alpha.
|
||||
// We can derive this from the definition of the color on black, which is
|
||||
// simply the true color times the true alpha. Divide to undo!
|
||||
const alphaRatio = alpha / 255;
|
||||
const rOnAlpha = Math.round(rOnBlack / alphaRatio);
|
||||
const gOnAlpha = Math.round(gOnBlack / alphaRatio);
|
||||
const bOnAlpha = Math.round(bOnBlack / alphaRatio);
|
||||
|
||||
imageWithAlpha.data[pixelIndex] = rOnAlpha;
|
||||
imageWithAlpha.data[pixelIndex + 1] = gOnAlpha;
|
||||
imageWithAlpha.data[pixelIndex + 2] = bOnAlpha;
|
||||
imageWithAlpha.data[pixelIndex + 3] = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
return [imageWithAlpha, numWarnings];
|
||||
}
|
||||
|
||||
/**
|
||||
* readImageDataFromUrl reads an image URL to ImageData, by drawing it on a
|
||||
* canvas and reading ImageData back from it.
|
||||
*/
|
||||
async function readImageDataFromUrl(url) {
|
||||
const image = new Image();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 600;
|
||||
canvas.height = 600;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(image, 0, 0, 600, 600);
|
||||
return ctx.getImageData(0, 0, 600, 600);
|
||||
}
|
||||
|
||||
/**
|
||||
* writeImageDataToUrl writes an ImageData to a data URL and Blob, by drawing
|
||||
* it on a canvas and reading the URL and Blob back from it.
|
||||
*/
|
||||
async function writeImageDataToUrlAndBlob(imageData) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 600;
|
||||
canvas.height = 600;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const blob = await new Promise((resolve) =>
|
||||
canvas.toBlob(resolve, "image/png")
|
||||
);
|
||||
return [dataUrl, blob];
|
||||
}
|
||||
|
||||
function resolveConflict(
|
||||
[rOnBlack, gOnBlack, bOnBlack],
|
||||
[rOnWhite, gOnWhite, bOnWhite],
|
||||
conflictMode
|
||||
) {
|
||||
if (conflictMode === "onBlack") {
|
||||
return [rOnBlack, gOnBlack, bOnBlack, 255];
|
||||
} else if (conflictMode === "onWhite") {
|
||||
return [rOnWhite, gOnWhite, bOnWhite, 255];
|
||||
} else if (conflictMode === "transparent") {
|
||||
return [0, 0, 0, 0];
|
||||
} else if (conflictMode === "moreColorful") {
|
||||
const sOnBlack = computeSaturation(rOnBlack, gOnBlack, bOnBlack);
|
||||
const sOnWhite = computeSaturation(rOnWhite, gOnWhite, bOnWhite);
|
||||
if (sOnBlack > sOnWhite) {
|
||||
return [rOnBlack, gOnBlack, bOnBlack, 255];
|
||||
} else {
|
||||
return [rOnWhite, gOnWhite, bOnWhite, 255];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unexpected conflict mode ${conflictMode}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given color's saturation, as a ratio from 0 to 1.
|
||||
* Adapted from https://css-tricks.com/converting-color-spaces-in-javascript/
|
||||
*/
|
||||
function computeSaturation(r, g, b) {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const cmin = Math.min(r, g, b);
|
||||
const cmax = Math.max(r, g, b);
|
||||
const delta = cmax - cmin;
|
||||
|
||||
const l = (cmax + cmin) / 2;
|
||||
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
export default AppearanceLayerSupportUploadModal;
|
|
@ -0,0 +1,99 @@
|
|||
import * as React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import { Box, useColorModeValue, useDisclosure } from "@chakra-ui/react";
|
||||
import { EditIcon } from "@chakra-ui/icons";
|
||||
import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal";
|
||||
import { OutfitLayers } from "../../components/OutfitPreview";
|
||||
|
||||
function ItemSupportAppearanceLayer({
|
||||
item,
|
||||
itemLayer,
|
||||
biologyLayers,
|
||||
outfitState,
|
||||
}) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const iconButtonBgColor = useColorModeValue("green.100", "green.300");
|
||||
const iconButtonColor = useColorModeValue("green.800", "gray.900");
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
as="button"
|
||||
width="150px"
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<Box
|
||||
width="150px"
|
||||
height="150px"
|
||||
marginBottom="1"
|
||||
boxShadow="md"
|
||||
borderRadius="md"
|
||||
position="relative"
|
||||
>
|
||||
<OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} />
|
||||
<Box
|
||||
className={css`
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
button:hover &,
|
||||
button:focus & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* On touch devices, always show the icon, to clarify that this is
|
||||
* an interactable object! (Whereas I expect other devices to
|
||||
* discover things by exploratory hover or focus!) */
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
background={iconButtonBgColor}
|
||||
color={iconButtonColor}
|
||||
borderRadius="full"
|
||||
boxShadow="sm"
|
||||
position="absolute"
|
||||
bottom="2"
|
||||
right="2"
|
||||
padding="2"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="32px"
|
||||
height="32px"
|
||||
>
|
||||
<EditIcon
|
||||
boxSize="16px"
|
||||
position="relative"
|
||||
top="-2px"
|
||||
right="-1px"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box as="span" fontWeight="700">
|
||||
{itemLayer.zone.label}
|
||||
</Box>{" "}
|
||||
<Box as="span" fontWeight="600">
|
||||
(Zone {itemLayer.zone.id})
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>Neopets ID: {itemLayer.remoteId}</Box>
|
||||
<Box>DTI ID: {itemLayer.id}</Box>
|
||||
<AppearanceLayerSupportModal
|
||||
item={item}
|
||||
layer={itemLayer}
|
||||
outfitState={outfitState}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemSupportAppearanceLayer;
|
|
@ -0,0 +1,463 @@
|
|||
import * as React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Link,
|
||||
Select,
|
||||
Spinner,
|
||||
Stack,
|
||||
Text,
|
||||
useBreakpointValue,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
|
||||
import AllItemLayersSupportModal from "./AllItemLayersSupportModal";
|
||||
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
|
||||
import useOutfitAppearance from "../../components/useOutfitAppearance";
|
||||
import { OutfitStateContext } from "../useOutfitState";
|
||||
import useSupport from "./useSupport";
|
||||
import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
|
||||
|
||||
/**
|
||||
* ItemSupportDrawer shows Support UI for the item when open.
|
||||
*
|
||||
* This component controls the drawer element. The actual content is imported
|
||||
* from another lazy-loaded component!
|
||||
*/
|
||||
function ItemSupportDrawer({ item, isOpen, onClose }) {
|
||||
const placement = useBreakpointValue({ base: "bottom", lg: "right" });
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
placement={placement}
|
||||
size="md"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
// blockScrollOnMount doesn't matter on our fullscreen UI, but the
|
||||
// default implementation breaks out layout somehow 🤔 idk, let's not!
|
||||
blockScrollOnMount={false}
|
||||
>
|
||||
<DrawerOverlay>
|
||||
<DrawerContent
|
||||
maxHeight={placement === "bottom" ? "90vh" : undefined}
|
||||
overflow="auto"
|
||||
>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader>
|
||||
{item.name}
|
||||
<Badge colorScheme="pink" marginLeft="3">
|
||||
Support <span aria-hidden="true">💖</span>
|
||||
</Badge>
|
||||
</DrawerHeader>
|
||||
<DrawerBody paddingBottom="5">
|
||||
<Metadata>
|
||||
<MetadataLabel>Item ID:</MetadataLabel>
|
||||
<MetadataValue>{item.id}</MetadataValue>
|
||||
<MetadataLabel>Restricted zones:</MetadataLabel>
|
||||
<MetadataValue>
|
||||
<ItemSupportRestrictedZones item={item} />
|
||||
</MetadataValue>
|
||||
</Metadata>
|
||||
<Stack spacing="8" marginTop="6">
|
||||
<ItemSupportFields item={item} />
|
||||
<ItemSupportAppearanceLayers item={item} />
|
||||
</Stack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</DrawerOverlay>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSupportRestrictedZones({ item }) {
|
||||
const { speciesId, colorId } = React.useContext(OutfitStateContext);
|
||||
|
||||
// NOTE: It would be a better reflection of the data to just query restricted
|
||||
// zones right off the item... but we already have them in cache from
|
||||
// the appearance, so query them that way to be instant in practice!
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query ItemSupportRestrictedZones(
|
||||
$itemId: ID!
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||
restrictedZones {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ variables: { itemId: item.id, speciesId, colorId } }
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <Spinner size="xs" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Text color="red.400">{error.message}</Text>;
|
||||
}
|
||||
|
||||
const restrictedZones = data?.item?.appearanceOn?.restrictedZones || [];
|
||||
if (restrictedZones.length === 0) {
|
||||
return "None";
|
||||
}
|
||||
|
||||
return restrictedZones
|
||||
.map((z) => `${z.label} (${z.id})`)
|
||||
.sort()
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function ItemSupportFields({ item }) {
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query ItemSupportFields($itemId: ID!) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
manualSpecialColor {
|
||||
id
|
||||
}
|
||||
explicitlyBodySpecific
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { itemId: item.id },
|
||||
|
||||
// HACK: I think it's a bug in @apollo/client 3.1.1 that, if the
|
||||
// optimistic response sets `manualSpecialColor` to null, the query
|
||||
// doesn't update, even though its cache has updated :/
|
||||
//
|
||||
// This cheap trick of changing the display name every re-render
|
||||
// persuades Apollo that this is a different query, so it re-checks
|
||||
// its cache and finds the empty `manualSpecialColor`. Weird!
|
||||
displayName: `ItemSupportFields-${new Date()}`,
|
||||
}
|
||||
);
|
||||
|
||||
const errorColor = useColorModeValue("red.500", "red.300");
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <Box color={errorColor}>{error.message}</Box>}
|
||||
<ItemSupportSpecialColorFields
|
||||
loading={loading}
|
||||
error={error}
|
||||
item={item}
|
||||
manualSpecialColor={data?.item?.manualSpecialColor?.id}
|
||||
/>
|
||||
<ItemSupportPetCompatibilityRuleFields
|
||||
loading={loading}
|
||||
error={error}
|
||||
item={item}
|
||||
explicitlyBodySpecific={data?.item?.explicitlyBodySpecific}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSupportSpecialColorFields({
|
||||
loading,
|
||||
error,
|
||||
item,
|
||||
manualSpecialColor,
|
||||
}) {
|
||||
const { supportSecret } = useSupport();
|
||||
|
||||
const {
|
||||
loading: colorsLoading,
|
||||
error: colorsError,
|
||||
data: colorsData,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query ItemSupportDrawerAllColors {
|
||||
allColors {
|
||||
id
|
||||
name
|
||||
isStandard
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const [
|
||||
mutate,
|
||||
{ loading: mutationLoading, error: mutationError, data: mutationData },
|
||||
] = useMutation(gql`
|
||||
mutation ItemSupportDrawerSetManualSpecialColor(
|
||||
$itemId: ID!
|
||||
$colorId: ID
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setManualSpecialColor(
|
||||
itemId: $itemId
|
||||
colorId: $colorId
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
manualSpecialColor {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(e) => {
|
||||
const colorId = e.target.value || null;
|
||||
const color =
|
||||
colorId != null ? { __typename: "Color", id: colorId } : null;
|
||||
mutate({
|
||||
variables: {
|
||||
itemId: item.id,
|
||||
colorId,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
setManualSpecialColor: {
|
||||
__typename: "Item",
|
||||
id: item.id,
|
||||
manualSpecialColor: color,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
// Ignore errors from the promise, because we'll handle them on render!
|
||||
});
|
||||
},
|
||||
[item.id, mutate, supportSecret]
|
||||
);
|
||||
|
||||
const nonStandardColors =
|
||||
colorsData?.allColors?.filter((c) => !c.isStandard) || [];
|
||||
nonStandardColors.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const linkColor = useColorModeValue("green.500", "green.300");
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={Boolean(error || colorsError || mutationError)}>
|
||||
<FormLabel>Special color</FormLabel>
|
||||
<Select
|
||||
placeholder={
|
||||
loading || colorsLoading
|
||||
? "Loading…"
|
||||
: "Default: Auto-detect from item description"
|
||||
}
|
||||
value={manualSpecialColor?.id}
|
||||
isDisabled={mutationLoading}
|
||||
icon={
|
||||
loading || colorsLoading || mutationLoading ? (
|
||||
<Spinner />
|
||||
) : mutationData ? (
|
||||
<CheckCircleIcon />
|
||||
) : undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
>
|
||||
{nonStandardColors.map((color) => (
|
||||
<option key={color.id} value={color.id}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{colorsError && (
|
||||
<FormErrorMessage>{colorsError.message}</FormErrorMessage>
|
||||
)}
|
||||
{mutationError && (
|
||||
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
|
||||
)}
|
||||
{!colorsError && !mutationError && (
|
||||
<FormHelperText>
|
||||
This controls which previews we show on the{" "}
|
||||
<Link
|
||||
href={`https://impress.openneo.net/items/${
|
||||
item.id
|
||||
}-${item.name.replace(/ /g, "-")}`}
|
||||
color={linkColor}
|
||||
isExternal
|
||||
>
|
||||
classic item page <ExternalLinkIcon />
|
||||
</Link>
|
||||
.
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSupportPetCompatibilityRuleFields({
|
||||
loading,
|
||||
error,
|
||||
item,
|
||||
explicitlyBodySpecific,
|
||||
}) {
|
||||
const { supportSecret } = useSupport();
|
||||
|
||||
const [
|
||||
mutate,
|
||||
{ loading: mutationLoading, error: mutationError, data: mutationData },
|
||||
] = useMutation(gql`
|
||||
mutation ItemSupportDrawerSetItemExplicitlyBodySpecific(
|
||||
$itemId: ID!
|
||||
$explicitlyBodySpecific: Boolean!
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setItemExplicitlyBodySpecific(
|
||||
itemId: $itemId
|
||||
explicitlyBodySpecific: $explicitlyBodySpecific
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
explicitlyBodySpecific
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(e) => {
|
||||
const explicitlyBodySpecific = e.target.value === "true";
|
||||
mutate({
|
||||
variables: {
|
||||
itemId: item.id,
|
||||
explicitlyBodySpecific,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
setItemExplicitlyBodySpecific: {
|
||||
__typename: "Item",
|
||||
id: item.id,
|
||||
explicitlyBodySpecific,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
// Ignore errors from the promise, because we'll handle them on render!
|
||||
});
|
||||
},
|
||||
[item.id, mutate, supportSecret]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={Boolean(error || mutationError)}>
|
||||
<FormLabel>Pet compatibility rule</FormLabel>
|
||||
<Select
|
||||
value={explicitlyBodySpecific ? "true" : "false"}
|
||||
isDisabled={mutationLoading}
|
||||
icon={
|
||||
loading || mutationLoading ? (
|
||||
<Spinner />
|
||||
) : mutationData ? (
|
||||
<CheckCircleIcon />
|
||||
) : undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
>
|
||||
{loading ? (
|
||||
<option>Loading…</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="false">
|
||||
Default: Auto-detect whether this fits all pets
|
||||
</option>
|
||||
<option value="true">
|
||||
Body specific: Always different for each pet body
|
||||
</option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
{mutationError && (
|
||||
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
|
||||
)}
|
||||
{!mutationError && (
|
||||
<FormHelperText>
|
||||
By default, we assume Background-y zones fit all pets the same. When
|
||||
items don't follow that rule, we can override it.
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: This component takes `outfitState` from context, rather than as a prop
|
||||
* from its parent, for performance reasons. We want `Item` to memoize
|
||||
* and generally skip re-rendering on `outfitState` changes, and to make
|
||||
* sure the context isn't accessed when the drawer is closed. So we use
|
||||
* it here, only when the drawer is open!
|
||||
*/
|
||||
function ItemSupportAppearanceLayers({ item }) {
|
||||
const outfitState = React.useContext(OutfitStateContext);
|
||||
const { speciesId, colorId, pose, appearanceId } = outfitState;
|
||||
const { error, visibleLayers } = useOutfitAppearance({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
wornItemIds: [item.id],
|
||||
});
|
||||
|
||||
const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
|
||||
const itemLayers = visibleLayers.filter((l) => l.source === "item");
|
||||
itemLayers.sort((a, b) => a.zone.depth - b.zone.depth);
|
||||
|
||||
const modalState = useDisclosure();
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<Flex align="center">
|
||||
<FormLabel>Appearance layers</FormLabel>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
<Button size="xs" onClick={modalState.onOpen}>
|
||||
View on all pets <ChevronRightIcon />
|
||||
</Button>
|
||||
<AllItemLayersSupportModal
|
||||
item={item}
|
||||
isOpen={modalState.isOpen}
|
||||
onClose={modalState.onClose}
|
||||
/>
|
||||
</Flex>
|
||||
<HStack spacing="4" overflow="auto" paddingX="1">
|
||||
{itemLayers.map((itemLayer) => (
|
||||
<ItemSupportAppearanceLayer
|
||||
key={itemLayer.id}
|
||||
item={item}
|
||||
itemLayer={itemLayer}
|
||||
biologyLayers={biologyLayers}
|
||||
outfitState={outfitState}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemSupportDrawer;
|
|
@ -0,0 +1,40 @@
|
|||
import * as React from "react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
|
||||
/**
|
||||
* Metadata is a UI component for showing metadata about something, as labels
|
||||
* and their values.
|
||||
*/
|
||||
function Metadata({ children, ...props }) {
|
||||
return (
|
||||
<Box
|
||||
as="dl"
|
||||
display="grid"
|
||||
gridTemplateColumns="max-content auto"
|
||||
gridRowGap="1"
|
||||
gridColumnGap="2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataLabel({ children, ...props }) {
|
||||
return (
|
||||
<Box as="dt" gridColumn="1" fontWeight="bold" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataValue({ children, ...props }) {
|
||||
return (
|
||||
<Box as="dd" gridColumn="2" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Metadata;
|
||||
export { MetadataLabel, MetadataValue };
|
|
@ -0,0 +1,582 @@
|
|||
import React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Select,
|
||||
Spinner,
|
||||
Switch,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useDisclosure,
|
||||
UnorderedList,
|
||||
ListItem,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ArrowBackIcon,
|
||||
ArrowForwardIcon,
|
||||
CheckCircleIcon,
|
||||
EditIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
|
||||
import HangerSpinner from "../../components/HangerSpinner";
|
||||
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
|
||||
import useSupport from "./useSupport";
|
||||
import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal";
|
||||
import { petAppearanceForPosePickerFragment } from "../PosePicker";
|
||||
|
||||
function PosePickerSupport({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
initialFocusRef,
|
||||
dispatchToOutfit,
|
||||
}) {
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query PosePickerSupport($speciesId: ID!, $colorId: ID!) {
|
||||
petAppearances(speciesId: $speciesId, colorId: $colorId) {
|
||||
id
|
||||
pose
|
||||
isGlitched
|
||||
layers {
|
||||
id
|
||||
zone {
|
||||
id
|
||||
label @client
|
||||
}
|
||||
|
||||
# For AppearanceLayerSupportModal
|
||||
remoteId
|
||||
bodyId
|
||||
swfUrl
|
||||
svgUrl
|
||||
imageUrl: imageUrlV2(idealSize: SIZE_600)
|
||||
canvasMovieLibraryUrl
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
label @client
|
||||
}
|
||||
|
||||
# For AppearanceLayerSupportModal to know the name
|
||||
species {
|
||||
id
|
||||
name
|
||||
}
|
||||
color {
|
||||
id
|
||||
name
|
||||
}
|
||||
|
||||
# Also, anything the PosePicker wants that isn't here, so that we
|
||||
# don't have to refetch anything when we change the canonical poses.
|
||||
...PetAppearanceForPosePicker
|
||||
}
|
||||
|
||||
...CanonicalPetAppearances
|
||||
}
|
||||
${canonicalPetAppearancesFragment}
|
||||
${petAppearanceForPosePickerFragment}
|
||||
`,
|
||||
{ variables: { speciesId, colorId } }
|
||||
);
|
||||
|
||||
// Resize the Popover when we toggle loading state, because it probably will
|
||||
// affect the content size. appearanceId might also affect content size, if
|
||||
// it occupies different zones.
|
||||
//
|
||||
// NOTE: This also triggers an additional necessary resize when the component
|
||||
// first mounts, because PosePicker lazy-loads it, so it actually
|
||||
// mounting affects size too.
|
||||
React.useLayoutEffect(() => {
|
||||
// HACK: To trigger a Popover resize, we simulate a window resize event,
|
||||
// because Popover listens for window resizes to reposition itself.
|
||||
// I've also filed an issue requesting an official API!
|
||||
// https://github.com/chakra-ui/chakra-ui/issues/1853
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}, [loading, appearanceId]);
|
||||
|
||||
const canonicalAppearanceIdsByPose = {
|
||||
HAPPY_MASC: data?.happyMasc?.id,
|
||||
SAD_MASC: data?.sadMasc?.id,
|
||||
SICK_MASC: data?.sickMasc?.id,
|
||||
HAPPY_FEM: data?.happyFem?.id,
|
||||
SAD_FEM: data?.sadFem?.id,
|
||||
SICK_FEM: data?.sickFem?.id,
|
||||
UNCONVERTED: data?.unconverted?.id,
|
||||
UNKNOWN: data?.unknown?.id,
|
||||
};
|
||||
const canonicalAppearanceIds = Object.values(
|
||||
canonicalAppearanceIdsByPose
|
||||
).filter((id) => id);
|
||||
|
||||
const providedAppearanceId = appearanceId;
|
||||
if (!providedAppearanceId) {
|
||||
appearanceId = canonicalAppearanceIdsByPose[pose];
|
||||
}
|
||||
|
||||
// If you don't already have `appearanceId` in the outfit state, opening up
|
||||
// PosePickerSupport adds it! That way, if you make changes that affect the
|
||||
// canonical poses, we'll still stay navigated to this one.
|
||||
React.useEffect(() => {
|
||||
if (!providedAppearanceId && appearanceId) {
|
||||
dispatchToOutfit({
|
||||
type: "setPose",
|
||||
pose,
|
||||
appearanceId,
|
||||
});
|
||||
}
|
||||
}, [providedAppearanceId, appearanceId, pose, dispatchToOutfit]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center">
|
||||
<HangerSpinner size="sm" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box color="red.400" marginTop="8">
|
||||
{error.message}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPetAppearance = data.petAppearances.find(
|
||||
(pa) => pa.id === appearanceId
|
||||
);
|
||||
if (!currentPetAppearance) {
|
||||
return (
|
||||
<Box color="red.400" marginTop="8">
|
||||
Pet appearance with ID {JSON.stringify(appearanceId)} not found
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PosePickerSupportNavigator
|
||||
petAppearances={data.petAppearances}
|
||||
currentPetAppearance={currentPetAppearance}
|
||||
canonicalAppearanceIds={canonicalAppearanceIds}
|
||||
dropdownRef={initialFocusRef}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
<Metadata
|
||||
fontSize="sm"
|
||||
// Build a new copy of this tree when the appearance changes, to reset
|
||||
// things like element focus and mutation state!
|
||||
key={currentPetAppearance.id}
|
||||
>
|
||||
<MetadataLabel>DTI ID:</MetadataLabel>
|
||||
<MetadataValue>{appearanceId}</MetadataValue>
|
||||
<MetadataLabel>Pose:</MetadataLabel>
|
||||
<MetadataValue>
|
||||
<PosePickerSupportPoseFields
|
||||
petAppearance={currentPetAppearance}
|
||||
speciesId={speciesId}
|
||||
colorId={colorId}
|
||||
/>
|
||||
</MetadataValue>
|
||||
<MetadataLabel>Layers:</MetadataLabel>
|
||||
<MetadataValue>
|
||||
<Wrap spacing="1">
|
||||
{currentPetAppearance.layers
|
||||
.map((layer) => [`${layer.zone.label} (${layer.zone.id})`, layer])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([text, layer]) => (
|
||||
<WrapItem key={layer.id}>
|
||||
<PetLayerSupportLink
|
||||
outfitState={{ speciesId, colorId, pose }}
|
||||
petAppearance={currentPetAppearance}
|
||||
layer={layer}
|
||||
>
|
||||
{text}
|
||||
<EditIcon marginLeft="1" />
|
||||
</PetLayerSupportLink>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</MetadataValue>
|
||||
<MetadataLabel>Restricts:</MetadataLabel>
|
||||
<MetadataValue maxHeight="64" overflowY="auto">
|
||||
{currentPetAppearance.restrictedZones.length > 0 ? (
|
||||
<UnorderedList>
|
||||
{currentPetAppearance.restrictedZones
|
||||
.map((zone) => `${zone.label} (${zone.id})`)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map((zoneText) => (
|
||||
<ListItem key={zoneText}>{zoneText}</ListItem>
|
||||
))}
|
||||
</UnorderedList>
|
||||
) : (
|
||||
<Box fontStyle="italic" opacity="0.8">
|
||||
None
|
||||
</Box>
|
||||
)}
|
||||
</MetadataValue>
|
||||
</Metadata>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PetLayerSupportLink({ outfitState, petAppearance, layer, children }) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
return (
|
||||
<>
|
||||
<Button size="xs" onClick={onOpen}>
|
||||
{children}
|
||||
</Button>
|
||||
<AppearanceLayerSupportModal
|
||||
outfitState={outfitState}
|
||||
petAppearance={petAppearance}
|
||||
layer={layer}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PosePickerSupportNavigator({
|
||||
petAppearances,
|
||||
currentPetAppearance,
|
||||
canonicalAppearanceIds,
|
||||
dropdownRef,
|
||||
dispatchToOutfit,
|
||||
}) {
|
||||
const currentIndex = petAppearances.indexOf(currentPetAppearance);
|
||||
const prevPetAppearance = petAppearances[currentIndex - 1];
|
||||
const nextPetAppearance = petAppearances[currentIndex + 1];
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="flex-end"
|
||||
marginBottom="4"
|
||||
// Space for the position-absolute PosePicker mode switcher
|
||||
paddingLeft="12"
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Go to previous appearance"
|
||||
icon={<ArrowBackIcon />}
|
||||
size="sm"
|
||||
marginRight="2"
|
||||
isDisabled={prevPetAppearance == null}
|
||||
onClick={() =>
|
||||
dispatchToOutfit({
|
||||
type: "setPose",
|
||||
pose: prevPetAppearance.pose,
|
||||
appearanceId: prevPetAppearance.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
width="auto"
|
||||
value={currentPetAppearance.id}
|
||||
ref={dropdownRef}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
const petAppearance = petAppearances.find((pa) => pa.id === id);
|
||||
dispatchToOutfit({
|
||||
type: "setPose",
|
||||
pose: petAppearance.pose,
|
||||
appearanceId: petAppearance.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{petAppearances.map((pa) => (
|
||||
<option key={pa.id} value={pa.id}>
|
||||
{POSE_NAMES[pa.pose]}{" "}
|
||||
{canonicalAppearanceIds.includes(pa.id) && "⭐️"}
|
||||
{pa.isGlitched && "👾"} ({pa.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<IconButton
|
||||
aria-label="Go to next appearance"
|
||||
icon={<ArrowForwardIcon />}
|
||||
size="sm"
|
||||
marginLeft="2"
|
||||
isDisabled={nextPetAppearance == null}
|
||||
onClick={() =>
|
||||
dispatchToOutfit({
|
||||
type: "setPose",
|
||||
pose: nextPetAppearance.pose,
|
||||
appearanceId: nextPetAppearance.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PosePickerSupportPoseFields({ petAppearance, speciesId, colorId }) {
|
||||
const { supportSecret } = useSupport();
|
||||
|
||||
const [mutatePose, poseMutation] = useMutation(
|
||||
gql`
|
||||
mutation PosePickerSupportSetPetAppearancePose(
|
||||
$appearanceId: ID!
|
||||
$pose: Pose!
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setPetAppearancePose(
|
||||
appearanceId: $appearanceId
|
||||
pose: $pose
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
pose
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
refetchQueries: [
|
||||
{
|
||||
query: gql`
|
||||
query PosePickerSupportRefetchCanonicalAppearances(
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
) {
|
||||
...CanonicalPetAppearances
|
||||
}
|
||||
${canonicalPetAppearancesFragment}
|
||||
`,
|
||||
variables: { speciesId, colorId },
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const [mutateIsGlitched, isGlitchedMutation] = useMutation(
|
||||
gql`
|
||||
mutation PosePickerSupportSetPetAppearanceIsGlitched(
|
||||
$appearanceId: ID!
|
||||
$isGlitched: Boolean!
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setPetAppearanceIsGlitched(
|
||||
appearanceId: $appearanceId
|
||||
isGlitched: $isGlitched
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
isGlitched
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
refetchQueries: [
|
||||
{
|
||||
query: gql`
|
||||
query PosePickerSupportRefetchCanonicalAppearances(
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
) {
|
||||
...CanonicalPetAppearances
|
||||
}
|
||||
${canonicalPetAppearancesFragment}
|
||||
`,
|
||||
variables: { speciesId, colorId },
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" flexDirection="row" alignItems="center">
|
||||
<Select
|
||||
size="sm"
|
||||
value={petAppearance.pose}
|
||||
flex="0 1 200px"
|
||||
icon={
|
||||
poseMutation.loading ? (
|
||||
<Spinner />
|
||||
) : poseMutation.data ? (
|
||||
<CheckCircleIcon />
|
||||
) : undefined
|
||||
}
|
||||
onChange={(e) => {
|
||||
const pose = e.target.value;
|
||||
mutatePose({
|
||||
variables: {
|
||||
appearanceId: petAppearance.id,
|
||||
pose,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
setPetAppearancePose: {
|
||||
__typename: "PetAppearance",
|
||||
id: petAppearance.id,
|
||||
pose,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
/* Discard errors here; we'll show them in the UI! */
|
||||
});
|
||||
}}
|
||||
isInvalid={poseMutation.error != null}
|
||||
>
|
||||
{Object.entries(POSE_NAMES).map(([pose, name]) => (
|
||||
<option key={pose} value={pose}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="sm"
|
||||
marginLeft="2"
|
||||
flex="0 1 150px"
|
||||
value={petAppearance.isGlitched}
|
||||
icon={
|
||||
isGlitchedMutation.loading ? (
|
||||
<Spinner />
|
||||
) : isGlitchedMutation.data ? (
|
||||
<CheckCircleIcon />
|
||||
) : undefined
|
||||
}
|
||||
onChange={(e) => {
|
||||
const isGlitched = e.target.value === "true";
|
||||
mutateIsGlitched({
|
||||
variables: {
|
||||
appearanceId: petAppearance.id,
|
||||
isGlitched,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
setPetAppearanceIsGlitched: {
|
||||
__typename: "PetAppearance",
|
||||
id: petAppearance.id,
|
||||
isGlitched,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
/* Discard errors here; we'll show them in the UI! */
|
||||
});
|
||||
}}
|
||||
isInvalid={isGlitchedMutation.error != null}
|
||||
>
|
||||
<option value="false">Valid</option>
|
||||
<option value="true">Glitched</option>
|
||||
</Select>
|
||||
</Box>
|
||||
{poseMutation.error && (
|
||||
<Box color="red.400">{poseMutation.error.message}</Box>
|
||||
)}
|
||||
{isGlitchedMutation.error && (
|
||||
<Box color="red.400">{isGlitchedMutation.error.message}</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function PosePickerSupportSwitch({ isChecked, onChange }) {
|
||||
return (
|
||||
<Box as="label" display="flex" flexDirection="row" alignItems="center">
|
||||
<Box fontSize="sm">
|
||||
<span role="img" aria-label="Support">
|
||||
💖
|
||||
</span>
|
||||
</Box>
|
||||
<Switch
|
||||
colorScheme="pink"
|
||||
marginLeft="1"
|
||||
size="sm"
|
||||
isChecked={isChecked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const POSE_NAMES = {
|
||||
HAPPY_MASC: "Happy Masc",
|
||||
HAPPY_FEM: "Happy Fem",
|
||||
SAD_MASC: "Sad Masc",
|
||||
SAD_FEM: "Sad Fem",
|
||||
SICK_MASC: "Sick Masc",
|
||||
SICK_FEM: "Sick Fem",
|
||||
UNCONVERTED: "Unconverted",
|
||||
UNKNOWN: "Unknown",
|
||||
};
|
||||
|
||||
const canonicalPetAppearancesFragment = gql`
|
||||
fragment CanonicalPetAppearances on Query {
|
||||
happyMasc: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: HAPPY_MASC
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
sadMasc: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: SAD_MASC
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
sickMasc: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: SICK_MASC
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
happyFem: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: HAPPY_FEM
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
sadFem: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: SAD_FEM
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
sickFem: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: SICK_FEM
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
unconverted: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: UNCONVERTED
|
||||
) {
|
||||
id
|
||||
}
|
||||
|
||||
unknown: petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: UNKNOWN
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default PosePickerSupport;
|
|
@ -0,0 +1,19 @@
|
|||
import useSupport from "./useSupport";
|
||||
|
||||
/**
|
||||
* SupportOnly only shows its contents to Support users. For most users, the
|
||||
* content will be hidden!
|
||||
*
|
||||
* To become a Support user, you visit /?supportSecret=..., which saves the
|
||||
* secret to your device.
|
||||
*
|
||||
* Note that this component doesn't check that the secret is *correct*, so it's
|
||||
* possible to view this UI by faking an invalid secret. That's okay, because
|
||||
* the server checks the provided secret for each Support request.
|
||||
*/
|
||||
function SupportOnly({ children }) {
|
||||
const { isSupportUser } = useSupport();
|
||||
return isSupportUser ? children : null;
|
||||
}
|
||||
|
||||
export default SupportOnly;
|
|
@ -0,0 +1,32 @@
|
|||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* useSupport returns the Support secret that the server requires for Support
|
||||
* actions... if the user has it set. For most users, this returns nothing!
|
||||
*
|
||||
* Specifically, we return an object of:
|
||||
* - isSupportUser: true iff the `supportSecret` is set
|
||||
* - supportSecret: the secret saved to this device, or null if not set
|
||||
*
|
||||
* To become a Support user, you visit /?supportSecret=..., which saves the
|
||||
* secret to your device.
|
||||
*
|
||||
* Note that this hook doesn't check that the secret is *correct*, so it's
|
||||
* possible that it will return an invalid secret. That's okay, because
|
||||
* the server checks the provided secret for each Support request.
|
||||
*/
|
||||
function useSupport() {
|
||||
const supportSecret = React.useMemo(
|
||||
() =>
|
||||
typeof localStorage !== "undefined"
|
||||
? localStorage.getItem("supportSecret")
|
||||
: null,
|
||||
[]
|
||||
);
|
||||
|
||||
const isSupportUser = supportSecret != null;
|
||||
|
||||
return { isSupportUser, supportSecret };
|
||||
}
|
||||
|
||||
export default useSupport;
|
249
app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js
Normal file
|
@ -0,0 +1,249 @@
|
|||
import React from "react";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useDebounce } from "../util";
|
||||
import useCurrentUser from "../components/useCurrentUser";
|
||||
import gql from "graphql-tag";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { outfitStatesAreEqual } from "./useOutfitState";
|
||||
|
||||
function useOutfitSaving(outfitState, dispatchToOutfit) {
|
||||
const { isLoggedIn, id: currentUserId } = useCurrentUser();
|
||||
const { pathname, push: pushHistory } = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
// There's not a way to reset an Apollo mutation state to clear out the error
|
||||
// when the outfit changes… so we track the error state ourselves!
|
||||
const [saveError, setSaveError] = React.useState(null);
|
||||
|
||||
// Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
|
||||
// to the server.
|
||||
const isNewOutfit = outfitState.id == null;
|
||||
|
||||
// Whether this outfit's latest local changes have been saved to the server.
|
||||
// And log it to the console!
|
||||
const latestVersionIsSaved =
|
||||
outfitState.savedOutfitState &&
|
||||
outfitStatesAreEqual(
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState
|
||||
);
|
||||
React.useEffect(() => {
|
||||
console.debug(
|
||||
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
|
||||
latestVersionIsSaved,
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState
|
||||
);
|
||||
}, [
|
||||
latestVersionIsSaved,
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState,
|
||||
]);
|
||||
|
||||
// Only logged-in users can save outfits - and they can only save new outfits,
|
||||
// or outfits they created.
|
||||
const canSaveOutfit =
|
||||
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
|
||||
|
||||
// Users can delete their own outfits too. The logic is slightly different
|
||||
// than for saving, because you can save an outfit that hasn't been saved
|
||||
// yet, but you can't delete it.
|
||||
const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
|
||||
|
||||
const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation(
|
||||
gql`
|
||||
mutation UseOutfitSaving_SaveOutfit(
|
||||
$id: ID # Optional, is null when saving new outfits.
|
||||
$name: String # Optional, server may fill in a placeholder.
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$pose: Pose!
|
||||
$wornItemIds: [ID!]!
|
||||
$closetedItemIds: [ID!]!
|
||||
) {
|
||||
outfit: saveOutfit(
|
||||
id: $id
|
||||
name: $name
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: $pose
|
||||
wornItemIds: $wornItemIds
|
||||
closetedItemIds: $closetedItemIds
|
||||
) {
|
||||
id
|
||||
name
|
||||
petAppearance {
|
||||
id
|
||||
species {
|
||||
id
|
||||
}
|
||||
color {
|
||||
id
|
||||
}
|
||||
pose
|
||||
}
|
||||
wornItems {
|
||||
id
|
||||
}
|
||||
closetedItems {
|
||||
id
|
||||
}
|
||||
creator {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
context: { sendAuth: true },
|
||||
update: (cache, { data: { outfit } }) => {
|
||||
// After save, add this outfit to the current user's outfit list. This
|
||||
// will help when navigating back to Your Outfits, to force a refresh.
|
||||
// https://www.apollographql.com/docs/react/caching/cache-interaction/#example-updating-the-cache-after-a-mutation
|
||||
cache.modify({
|
||||
id: cache.identify(outfit.creator),
|
||||
fields: {
|
||||
outfits: (existingOutfitRefs = [], { readField }) => {
|
||||
const isAlreadyInList = existingOutfitRefs.some(
|
||||
(ref) => readField("id", ref) === outfit.id
|
||||
);
|
||||
if (isAlreadyInList) {
|
||||
return existingOutfitRefs;
|
||||
}
|
||||
|
||||
const newOutfitRef = cache.writeFragment({
|
||||
data: outfit,
|
||||
fragment: gql`
|
||||
fragment NewOutfit on Outfit {
|
||||
id
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
return [...existingOutfitRefs, newOutfitRef];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Also, send a `rename` action, if this is still the current outfit,
|
||||
// and the server renamed it (e.g. "Untitled outfit (1)"). (It's
|
||||
// tempting to do a full reset, in case the server knows something we
|
||||
// don't, but we don't want to clobber changes the user made since
|
||||
// starting the save!)
|
||||
if (outfit.id === outfitState.id && outfit.name !== outfitState.name) {
|
||||
dispatchToOutfit({
|
||||
type: "rename",
|
||||
outfitName: outfit.name,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const saveOutfitFromProvidedState = React.useCallback(
|
||||
(outfitState) => {
|
||||
sendSaveOutfitMutation({
|
||||
variables: {
|
||||
id: outfitState.id,
|
||||
name: outfitState.name,
|
||||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
wornItemIds: [...outfitState.wornItemIds],
|
||||
closetedItemIds: [...outfitState.closetedItemIds],
|
||||
},
|
||||
})
|
||||
.then(({ data: { outfit } }) => {
|
||||
// Navigate to the new saved outfit URL. Our Apollo cache should pick
|
||||
// up the data from this mutation response, and combine it with the
|
||||
// existing cached data, to make this smooth without any loading UI.
|
||||
if (pathname !== `/outfits/[outfitId]`) {
|
||||
pushHistory(`/outfits/${outfit.id}`);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setSaveError(e);
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Sorry, there was an error saving this outfit!",
|
||||
description: "Maybe check your connection and try again.",
|
||||
});
|
||||
});
|
||||
},
|
||||
// It's important that this callback _doesn't_ change when the outfit
|
||||
// changes, so that the auto-save effect is only responding to the
|
||||
// debounced state!
|
||||
[sendSaveOutfitMutation, pathname, pushHistory, toast]
|
||||
);
|
||||
|
||||
const saveOutfit = React.useCallback(
|
||||
() => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
|
||||
[saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras]
|
||||
);
|
||||
|
||||
// Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
|
||||
// which only contains the basic fields, and will keep a stable object
|
||||
// identity until actual changes occur. Then, save the outfit after the user
|
||||
// has left it alone for long enough, so long as it's actually different
|
||||
// than the saved state.
|
||||
const debouncedOutfitState = useDebounce(
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
2000,
|
||||
{
|
||||
// When the outfit ID changes, update the debounced state immediately!
|
||||
forceReset: (debouncedOutfitState, newOutfitState) =>
|
||||
debouncedOutfitState.id !== newOutfitState.id,
|
||||
}
|
||||
);
|
||||
// HACK: This prevents us from auto-saving the outfit state that's still
|
||||
// loading. I worry that this might not catch other loading scenarios
|
||||
// though, like if the species/color/pose is in the GQL cache, but the
|
||||
// items are still loading in... not sure where this would happen tho!
|
||||
const debouncedOutfitStateIsSaveable =
|
||||
debouncedOutfitState.speciesId &&
|
||||
debouncedOutfitState.colorId &&
|
||||
debouncedOutfitState.pose;
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!isNewOutfit &&
|
||||
canSaveOutfit &&
|
||||
debouncedOutfitStateIsSaveable &&
|
||||
!outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
|
||||
) {
|
||||
console.info(
|
||||
"[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
|
||||
outfitState.savedOutfitState,
|
||||
debouncedOutfitState
|
||||
);
|
||||
saveOutfitFromProvidedState(debouncedOutfitState);
|
||||
}
|
||||
}, [
|
||||
isNewOutfit,
|
||||
canSaveOutfit,
|
||||
debouncedOutfitState,
|
||||
debouncedOutfitStateIsSaveable,
|
||||
outfitState.savedOutfitState,
|
||||
saveOutfitFromProvidedState,
|
||||
]);
|
||||
|
||||
// When the outfit changes, clear out the error state from previous saves.
|
||||
// We'll send the mutation again after the debounce, and we don't want to
|
||||
// show the error UI in the meantime!
|
||||
React.useEffect(() => {
|
||||
setSaveError(null);
|
||||
}, [outfitState.outfitStateWithoutExtras]);
|
||||
|
||||
return {
|
||||
canSaveOutfit,
|
||||
canDeleteOutfit,
|
||||
isNewOutfit,
|
||||
isSaving,
|
||||
latestVersionIsSaved,
|
||||
saveError,
|
||||
saveOutfit,
|
||||
};
|
||||
}
|
||||
|
||||
export default useOutfitSaving;
|
708
app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js
Normal file
|
@ -0,0 +1,708 @@
|
|||
import React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import produce, { enableMapSet } from "immer";
|
||||
import { useQuery, useApolloClient } from "@apollo/client";
|
||||
|
||||
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
enableMapSet();
|
||||
|
||||
export const OutfitStateContext = React.createContext(null);
|
||||
|
||||
function useOutfitState() {
|
||||
const apolloClient = useApolloClient();
|
||||
const urlOutfitState = useParseOutfitUrl();
|
||||
const [localOutfitState, dispatchToOutfit] = React.useReducer(
|
||||
outfitStateReducer(apolloClient),
|
||||
urlOutfitState
|
||||
);
|
||||
|
||||
// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
|
||||
// about the outfit. We'll use it to initialize the local state.
|
||||
const {
|
||||
loading: outfitLoading,
|
||||
error: outfitError,
|
||||
data: outfitData,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query OutfitStateSavedOutfit($id: ID!) {
|
||||
outfit(id: $id) {
|
||||
id
|
||||
name
|
||||
updatedAt
|
||||
creator {
|
||||
id
|
||||
}
|
||||
petAppearance {
|
||||
id
|
||||
species {
|
||||
id
|
||||
}
|
||||
color {
|
||||
id
|
||||
}
|
||||
pose
|
||||
}
|
||||
wornItems {
|
||||
id
|
||||
}
|
||||
closetedItems {
|
||||
id
|
||||
}
|
||||
|
||||
# TODO: Consider pre-loading some fields, instead of doing them in
|
||||
# follow-up queries?
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { id: urlOutfitState.id },
|
||||
skip: urlOutfitState.id == null,
|
||||
returnPartialData: true,
|
||||
onCompleted: (outfitData) => {
|
||||
dispatchToOutfit({
|
||||
type: "resetToSavedOutfitData",
|
||||
savedOutfitData: outfitData.outfit,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const creator = outfitData?.outfit?.creator;
|
||||
const updatedAt = outfitData?.outfit?.updatedAt;
|
||||
|
||||
// We memoize this to make `outfitStateWithoutExtras` an even more reliable
|
||||
// stable object!
|
||||
const savedOutfitState = React.useMemo(
|
||||
() => getOutfitStateFromOutfitData(outfitData?.outfit),
|
||||
[outfitData?.outfit]
|
||||
);
|
||||
|
||||
// Choose which customization state to use. We want it to match the outfit in
|
||||
// the URL immediately, without having to wait for any effects, to avoid race
|
||||
// conditions!
|
||||
//
|
||||
// The reducer is generally the main source of truth for live changes!
|
||||
//
|
||||
// But if:
|
||||
// - it's not initialized yet (e.g. the first frame of navigating to an
|
||||
// outfit from Your Outfits), or
|
||||
// - it's for a different outfit than the URL says (e.g. clicking Back
|
||||
// or Forward to switch between saved outfits),
|
||||
//
|
||||
// Then use saved outfit data or the URL query string instead, because that's
|
||||
// a better representation of the outfit in the URL. (If the saved outfit
|
||||
// data isn't loaded yet, then this will be a customization state with
|
||||
// partial data, and that's okay.)
|
||||
let outfitState;
|
||||
if (
|
||||
urlOutfitState.id === localOutfitState.id &&
|
||||
localOutfitState.speciesId != null &&
|
||||
localOutfitState.colorId != null
|
||||
) {
|
||||
// Use the reducer state: they're both for the same saved outfit, or both
|
||||
// for an unsaved outfit (null === null). But we don't use it when it's
|
||||
// *only* got the ID, and no other fields yet.
|
||||
console.debug("[useOutfitState] Choosing local outfit state");
|
||||
outfitState = localOutfitState;
|
||||
} else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
|
||||
// Use the saved outfit state: it's for the saved outfit the URL points to.
|
||||
console.debug("[useOutfitState] Choosing saved outfit state");
|
||||
outfitState = savedOutfitState;
|
||||
} else {
|
||||
// Use the URL state: it's more up-to-date than any of the others. (Worst
|
||||
// case, it's empty except for ID, which is fine while the saved outfit
|
||||
// data loads!)
|
||||
console.debug("[useOutfitState] Choosing URL outfit state");
|
||||
outfitState = urlOutfitState;
|
||||
}
|
||||
|
||||
// When unpacking the customization state, we call `Array.from` on our item
|
||||
// IDs. It's more convenient to manage them as a Set in state, but most
|
||||
// callers will find it more convenient to access them as arrays! e.g. for
|
||||
// `.map()`.
|
||||
const { id, name, speciesId, colorId, pose, appearanceId } = outfitState;
|
||||
const wornItemIds = Array.from(outfitState.wornItemIds);
|
||||
const closetedItemIds = Array.from(outfitState.closetedItemIds);
|
||||
const allItemIds = [...wornItemIds, ...closetedItemIds];
|
||||
|
||||
const {
|
||||
loading: itemsLoading,
|
||||
error: itemsError,
|
||||
data: itemsData,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query OutfitStateItems(
|
||||
$allItemIds: [ID!]!
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
) {
|
||||
items(ids: $allItemIds) {
|
||||
# TODO: De-dupe this from SearchPanel?
|
||||
id
|
||||
name
|
||||
thumbnailUrl
|
||||
isNc
|
||||
isPb
|
||||
currentUserOwnsThis
|
||||
currentUserWantsThis
|
||||
|
||||
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||
# This enables us to quickly show the item when the user clicks it!
|
||||
...ItemAppearanceForOutfitPreview
|
||||
|
||||
# This is used to group items by zone, and to detect conflicts when
|
||||
# wearing a new item.
|
||||
layers {
|
||||
zone {
|
||||
id
|
||||
label @client
|
||||
}
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
label @client
|
||||
isCommonlyUsedByItems @client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# NOTE: We skip this query if items is empty for perf reasons. If
|
||||
# you're adding more fields, consider changing that condition!
|
||||
}
|
||||
${itemAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: { allItemIds, speciesId, colorId },
|
||||
context: { sendAuth: true },
|
||||
// Skip if this outfit has no items, as an optimization; or if we don't
|
||||
// have the species/color ID loaded yet because we're waiting on the
|
||||
// saved outfit to load.
|
||||
skip: allItemIds.length === 0 || speciesId == null || colorId == null,
|
||||
}
|
||||
);
|
||||
|
||||
const resultItems = itemsData?.items || [];
|
||||
|
||||
// Okay, time for some big perf hacks! Lower down in the app, we use
|
||||
// React.memo to avoid re-rendering Item components if the items haven't
|
||||
// updated. In simpler cases, we just make the component take the individual
|
||||
// item fields as props... but items are complex and that makes it annoying
|
||||
// :p Instead, we do these tricks to reuse physical item objects if they're
|
||||
// still deep-equal to the previous version. This is because React.memo uses
|
||||
// object identity to compare its props, so now when it checks whether
|
||||
// `oldItem === newItem`, the answer will be `true`, unless the item really
|
||||
// _did_ change!
|
||||
const [cachedItemObjects, setCachedItemObjects] = React.useState([]);
|
||||
let items = resultItems.map((item) => {
|
||||
const cachedItemObject = cachedItemObjects.find((i) => i.id === item.id);
|
||||
if (
|
||||
cachedItemObject &&
|
||||
JSON.stringify(cachedItemObject) === JSON.stringify(item)
|
||||
) {
|
||||
return cachedItemObject;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (
|
||||
items.length === cachedItemObjects.length &&
|
||||
items.every((_, index) => items[index] === cachedItemObjects[index])
|
||||
) {
|
||||
// Even reuse the entire array if none of the items changed!
|
||||
items = cachedItemObjects;
|
||||
}
|
||||
React.useEffect(() => {
|
||||
setCachedItemObjects(items);
|
||||
}, [items, setCachedItemObjects]);
|
||||
|
||||
const itemsById = {};
|
||||
for (const item of items) {
|
||||
itemsById[item.id] = item;
|
||||
}
|
||||
|
||||
const zonesAndItems = getZonesAndItems(
|
||||
itemsById,
|
||||
wornItemIds,
|
||||
closetedItemIds
|
||||
);
|
||||
const incompatibleItems = items
|
||||
.filter((i) => i.appearanceOn.layers.length === 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const url = buildOutfitUrl(outfitState);
|
||||
|
||||
const outfitStateWithExtras = {
|
||||
id,
|
||||
creator,
|
||||
updatedAt,
|
||||
zonesAndItems,
|
||||
incompatibleItems,
|
||||
name,
|
||||
wornItemIds,
|
||||
closetedItemIds,
|
||||
allItemIds,
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
url,
|
||||
|
||||
// We use this plain outfit state objects in `useOutfitSaving`! Unlike the
|
||||
// full `outfitState` object, which we rebuild each render,
|
||||
// `outfitStateWithoutExtras` will mostly only change when there is an
|
||||
// actual change to outfit state.
|
||||
outfitStateWithoutExtras: outfitState,
|
||||
savedOutfitState,
|
||||
};
|
||||
|
||||
// Keep the URL up-to-date. (We don't listen to it, though 😅)
|
||||
// TODO: Seems like we should hook this in with the actual router... I'm
|
||||
// avoiding it rn, but I'm worried Next.js won't necessarily play nice
|
||||
// with this hack, even though react-router did. Hard to predict!
|
||||
React.useEffect(() => {
|
||||
if (typeof history !== "undefined") {
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
return {
|
||||
loading: outfitLoading || itemsLoading,
|
||||
error: outfitError || itemsError,
|
||||
outfitState: outfitStateWithExtras,
|
||||
dispatchToOutfit,
|
||||
};
|
||||
}
|
||||
|
||||
const outfitStateReducer = (apolloClient) => (baseState, action) => {
|
||||
console.info("[useOutfitState] Action:", action);
|
||||
switch (action.type) {
|
||||
case "rename":
|
||||
return produce(baseState, (state) => {
|
||||
state.name = action.outfitName;
|
||||
});
|
||||
case "setSpeciesAndColor":
|
||||
return produce(baseState, (state) => {
|
||||
state.speciesId = action.speciesId;
|
||||
state.colorId = action.colorId;
|
||||
state.pose = action.pose;
|
||||
state.appearanceId = null;
|
||||
});
|
||||
case "wearItem":
|
||||
return produce(baseState, (state) => {
|
||||
const { wornItemIds, closetedItemIds } = state;
|
||||
const { itemId, itemIdsToReconsider = [] } = action;
|
||||
|
||||
// Move conflicting items to the closet.
|
||||
//
|
||||
// We do this by looking them up in the Apollo Cache, which is going to
|
||||
// include the relevant item data because the `useOutfitState` hook
|
||||
// queries for it!
|
||||
//
|
||||
// (It could be possible to mess up the timing by taking an action
|
||||
// while worn items are still partially loading, but I think it would
|
||||
// require a pretty weird action sequence to make that happen... like,
|
||||
// doing a search and it loads before the worn item data does? Anyway,
|
||||
// Apollo will throw in that case, which should just essentially reject
|
||||
// the action.)
|
||||
let conflictingIds;
|
||||
try {
|
||||
conflictingIds = findItemConflicts(itemId, state, apolloClient);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
for (const conflictingId of conflictingIds) {
|
||||
wornItemIds.delete(conflictingId);
|
||||
closetedItemIds.add(conflictingId);
|
||||
}
|
||||
|
||||
// Move this item from the closet to the worn set.
|
||||
closetedItemIds.delete(itemId);
|
||||
wornItemIds.add(itemId);
|
||||
|
||||
reconsiderItems(itemIdsToReconsider, state, apolloClient);
|
||||
});
|
||||
case "unwearItem":
|
||||
return produce(baseState, (state) => {
|
||||
const { wornItemIds, closetedItemIds } = state;
|
||||
const { itemId, itemIdsToReconsider = [] } = action;
|
||||
|
||||
// Move this item from the worn set to the closet.
|
||||
wornItemIds.delete(itemId);
|
||||
closetedItemIds.add(itemId);
|
||||
|
||||
reconsiderItems(
|
||||
// Don't include the unworn item in items to reconsider!
|
||||
itemIdsToReconsider.filter((x) => x !== itemId),
|
||||
state,
|
||||
apolloClient
|
||||
);
|
||||
});
|
||||
case "removeItem":
|
||||
return produce(baseState, (state) => {
|
||||
const { wornItemIds, closetedItemIds } = state;
|
||||
const { itemId, itemIdsToReconsider = [] } = action;
|
||||
|
||||
// Remove this item from both the worn set and the closet.
|
||||
wornItemIds.delete(itemId);
|
||||
closetedItemIds.delete(itemId);
|
||||
|
||||
reconsiderItems(
|
||||
// Don't include the removed item in items to reconsider!
|
||||
itemIdsToReconsider.filter((x) => x !== itemId),
|
||||
state,
|
||||
apolloClient
|
||||
);
|
||||
});
|
||||
case "setPose":
|
||||
return produce(baseState, (state) => {
|
||||
state.pose = action.pose;
|
||||
// Usually only the `pose` is specified, but `PosePickerSupport` can
|
||||
// also specify a corresponding `appearanceId`, to get even more
|
||||
// particular about which version of the pose to show if more than one.
|
||||
state.appearanceId = action.appearanceId || null;
|
||||
});
|
||||
case "resetToSavedOutfitData":
|
||||
return getOutfitStateFromOutfitData(action.savedOutfitData);
|
||||
default:
|
||||
throw new Error(`unexpected action ${JSON.stringify(action)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const EMPTY_CUSTOMIZATION_STATE = {
|
||||
id: null,
|
||||
name: null,
|
||||
speciesId: null,
|
||||
colorId: null,
|
||||
pose: null,
|
||||
appearanceId: null,
|
||||
wornItemIds: [],
|
||||
closetedItemIds: [],
|
||||
};
|
||||
|
||||
function useParseOutfitUrl() {
|
||||
const { query } = useRouter();
|
||||
|
||||
// We memoize this to make `outfitStateWithoutExtras` an even more reliable
|
||||
// stable object!
|
||||
const memoizedOutfitState = React.useMemo(
|
||||
() => readOutfitStateFromQuery(query),
|
||||
[query]
|
||||
);
|
||||
|
||||
return memoizedOutfitState;
|
||||
}
|
||||
|
||||
export function readOutfitStateFromQuery(query) {
|
||||
// For the /outfits/:id page, ignore the query string, and just wait for the
|
||||
// outfit data to load in!
|
||||
if (query.outfitId != null) {
|
||||
return {
|
||||
...EMPTY_CUSTOMIZATION_STATE,
|
||||
id: query.outfitId,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, parse the query string, and fill in default values for anything
|
||||
// not specified.
|
||||
return {
|
||||
id: null,
|
||||
name: getValueFromQuery(query.name),
|
||||
speciesId: getValueFromQuery(query.species) || "1",
|
||||
colorId: getValueFromQuery(query.color) || "8",
|
||||
pose: getValueFromQuery(query.pose) || "HAPPY_FEM",
|
||||
appearanceId: getValueFromQuery(query.state) || null,
|
||||
wornItemIds: new Set(getListFromQuery(query["objects[]"])),
|
||||
closetedItemIds: new Set(getListFromQuery(query["closet[]"])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* getValueFromQuery reads the given value from Next's `router.query` as a
|
||||
* single value. For example:
|
||||
*
|
||||
* ?foo=bar -> "bar" -> "bar"
|
||||
* ?foo=bar&foo=baz -> ["bar", "baz"] -> "bar"
|
||||
* ?lol=huh -> undefined -> null
|
||||
*/
|
||||
function getValueFromQuery(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
} else if (value != null) {
|
||||
return value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getListFromQuery reads the given value from Next's `router.query` as a list
|
||||
* of values. For example:
|
||||
*
|
||||
* ?foo=bar -> "bar" -> ["bar"]
|
||||
* ?foo=bar&foo=baz -> ["bar", "baz"] -> ["bar", "baz"]
|
||||
* ?lol=huh -> undefined -> []
|
||||
*/
|
||||
function getListFromQuery(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
} else if (value != null) {
|
||||
return [value];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getOutfitStateFromOutfitData(outfit) {
|
||||
if (!outfit) {
|
||||
return EMPTY_CUSTOMIZATION_STATE;
|
||||
}
|
||||
|
||||
return {
|
||||
id: outfit.id,
|
||||
name: outfit.name,
|
||||
// Note that these fields are intentionally null if loading, rather than
|
||||
// falling back to a default appearance like Blue Acara.
|
||||
speciesId: outfit.petAppearance?.species?.id,
|
||||
colorId: outfit.petAppearance?.color?.id,
|
||||
pose: outfit.petAppearance?.pose,
|
||||
// Whereas the items are more convenient to just leave as empty lists!
|
||||
wornItemIds: new Set((outfit.wornItems || []).map((item) => item.id)),
|
||||
closetedItemIds: new Set(
|
||||
(outfit.closetedItems || []).map((item) => item.id)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function findItemConflicts(itemIdToAdd, state, apolloClient) {
|
||||
const { wornItemIds, speciesId, colorId } = state;
|
||||
|
||||
const { items } = apolloClient.readQuery({
|
||||
query: gql`
|
||||
query OutfitStateItemConflicts(
|
||||
$itemIds: [ID!]!
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
) {
|
||||
items(ids: $itemIds) {
|
||||
id
|
||||
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||
layers {
|
||||
zone {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
restrictedZones {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
itemIds: [itemIdToAdd, ...wornItemIds],
|
||||
speciesId,
|
||||
colorId,
|
||||
},
|
||||
});
|
||||
const itemToAdd = items.find((i) => i.id === itemIdToAdd);
|
||||
if (!itemToAdd.appearanceOn) {
|
||||
return [];
|
||||
}
|
||||
const wornItems = Array.from(wornItemIds).map((id) =>
|
||||
items.find((i) => i.id === id)
|
||||
);
|
||||
|
||||
const itemToAddZoneSets = getItemZones(itemToAdd);
|
||||
|
||||
const conflictingIds = [];
|
||||
for (const wornItem of wornItems) {
|
||||
if (!wornItem.appearanceOn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wornItemZoneSets = getItemZones(wornItem);
|
||||
|
||||
const itemsConflict =
|
||||
setsIntersect(
|
||||
itemToAddZoneSets.occupies,
|
||||
wornItemZoneSets.occupiesOrRestricts
|
||||
) ||
|
||||
setsIntersect(
|
||||
wornItemZoneSets.occupies,
|
||||
itemToAddZoneSets.occupiesOrRestricts
|
||||
);
|
||||
|
||||
if (itemsConflict) {
|
||||
conflictingIds.push(wornItem.id);
|
||||
}
|
||||
}
|
||||
|
||||
return conflictingIds;
|
||||
}
|
||||
|
||||
function getItemZones(item) {
|
||||
const occupies = new Set(item.appearanceOn.layers.map((l) => l.zone.id));
|
||||
const restricts = new Set(item.appearanceOn.restrictedZones.map((z) => z.id));
|
||||
const occupiesOrRestricts = new Set([...occupies, ...restricts]);
|
||||
return { occupies, occupiesOrRestricts };
|
||||
}
|
||||
|
||||
function setsIntersect(a, b) {
|
||||
for (const el of a) {
|
||||
if (b.has(el)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to add these items back to the outfit, if there would be no conflicts.
|
||||
* We use this in Search to try to restore these items after the user makes
|
||||
* changes, e.g., after they try on another Background we want to restore the
|
||||
* previous one!
|
||||
*
|
||||
* This mutates state.wornItemIds directly, on the assumption that we're in an
|
||||
* immer block, in which case mutation is the simplest API!
|
||||
*/
|
||||
function reconsiderItems(itemIdsToReconsider, state, apolloClient) {
|
||||
for (const itemIdToReconsider of itemIdsToReconsider) {
|
||||
const conflictingIds = findItemConflicts(
|
||||
itemIdToReconsider,
|
||||
state,
|
||||
apolloClient
|
||||
);
|
||||
if (conflictingIds.length === 0) {
|
||||
state.wornItemIds.add(itemIdToReconsider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Get this out of here, tbh...
|
||||
function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
|
||||
const wornItems = wornItemIds.map((id) => itemsById[id]).filter((i) => i);
|
||||
const closetedItems = closetedItemIds
|
||||
.map((id) => itemsById[id])
|
||||
.filter((i) => i);
|
||||
|
||||
// We use zone label here, rather than ID, because some zones have the same
|
||||
// label and we *want* to over-simplify that in this UI. (e.g. there are
|
||||
// multiple Hat zones, and some items occupy different ones, but mostly let's
|
||||
// just group them and if they don't conflict then all the better!)
|
||||
const allItems = [...wornItems, ...closetedItems];
|
||||
const itemsByZoneLabel = new Map();
|
||||
for (const item of allItems) {
|
||||
if (!item.appearanceOn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const layer of item.appearanceOn.layers) {
|
||||
const zoneLabel = layer.zone.label;
|
||||
|
||||
if (!itemsByZoneLabel.has(zoneLabel)) {
|
||||
itemsByZoneLabel.set(zoneLabel, []);
|
||||
}
|
||||
itemsByZoneLabel.get(zoneLabel).push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let zonesAndItems = Array.from(itemsByZoneLabel.entries()).map(
|
||||
([zoneLabel, items]) => ({
|
||||
zoneLabel: zoneLabel,
|
||||
items: [...items].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
})
|
||||
);
|
||||
zonesAndItems.sort((a, b) => a.zoneLabel.localeCompare(b.zoneLabel));
|
||||
|
||||
// As one last step, try to remove zone groups that aren't helpful.
|
||||
const groupsWithConflicts = zonesAndItems.filter(
|
||||
({ items }) => items.length > 1
|
||||
);
|
||||
const itemIdsWithConflicts = new Set(
|
||||
groupsWithConflicts
|
||||
.map(({ items }) => items)
|
||||
.flat()
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const itemIdsWeHaveSeen = new Set();
|
||||
zonesAndItems = zonesAndItems.filter(({ items }) => {
|
||||
// We need all groups with more than one item. If there's only one, we get
|
||||
// to think harder :)
|
||||
if (items.length > 1) {
|
||||
items.forEach((item) => itemIdsWeHaveSeen.add(item.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
const item = items[0];
|
||||
|
||||
// Has the item been seen a group we kept, or an upcoming group with
|
||||
// multiple conflicting items? If so, skip this group. If not, keep it.
|
||||
if (itemIdsWeHaveSeen.has(item.id) || itemIdsWithConflicts.has(item.id)) {
|
||||
return false;
|
||||
} else {
|
||||
itemIdsWeHaveSeen.add(item.id);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return zonesAndItems;
|
||||
}
|
||||
|
||||
export function buildOutfitUrl(outfitState, { withoutOutfitId = false } = {}) {
|
||||
const { id } = outfitState;
|
||||
|
||||
const origin =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://impress-2020.openneo.net";
|
||||
|
||||
if (id && !withoutOutfitId) {
|
||||
return origin + `/outfits/${id}`;
|
||||
}
|
||||
|
||||
return origin + "/outfits/new?" + buildOutfitQueryString(outfitState);
|
||||
}
|
||||
|
||||
function buildOutfitQueryString(outfitState) {
|
||||
const {
|
||||
name,
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
wornItemIds,
|
||||
closetedItemIds,
|
||||
} = outfitState;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
name: name || "",
|
||||
species: speciesId || "",
|
||||
color: colorId || "",
|
||||
pose: pose || "",
|
||||
});
|
||||
for (const itemId of wornItemIds) {
|
||||
params.append("objects[]", itemId);
|
||||
}
|
||||
for (const itemId of closetedItemIds) {
|
||||
params.append("closet[]", itemId);
|
||||
}
|
||||
if (appearanceId != null) {
|
||||
// `state` is an old name for compatibility with old-style DTI URLs. It
|
||||
// refers to "PetState", the database table name for pet appearances.
|
||||
params.append("state", appearanceId);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the two given outfit states represent identical customizations.
|
||||
*/
|
||||
export function outfitStatesAreEqual(a, b) {
|
||||
return buildOutfitQueryString(a) === buildOutfitQueryString(b);
|
||||
}
|
||||
|
||||
export default useOutfitState;
|
137
app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js
Normal file
|
@ -0,0 +1,137 @@
|
|||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useDebounce } from "../util";
|
||||
import { emptySearchQuery } from "./SearchToolbar";
|
||||
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
|
||||
import { SEARCH_PER_PAGE } from "./SearchPanel";
|
||||
|
||||
/**
|
||||
* useSearchResults manages the actual querying and state management of search!
|
||||
*/
|
||||
export function useSearchResults(
|
||||
query,
|
||||
outfitState,
|
||||
currentPageNumber,
|
||||
{ skip = false } = {}
|
||||
) {
|
||||
const { speciesId, colorId } = outfitState;
|
||||
|
||||
// We debounce the search query, so that we don't resend a new query whenever
|
||||
// the user types anything.
|
||||
const debouncedQuery = useDebounce(query, 300, {
|
||||
waitForFirstPause: true,
|
||||
initialValue: emptySearchQuery,
|
||||
});
|
||||
|
||||
// NOTE: This query should always load ~instantly, from the client cache.
|
||||
const { data: zoneData } = useQuery(gql`
|
||||
query SearchPanelZones {
|
||||
allZones {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
`);
|
||||
const allZones = zoneData?.allZones || [];
|
||||
const filterToZones = query.filterToZoneLabel
|
||||
? allZones.filter((z) => z.label === query.filterToZoneLabel)
|
||||
: [];
|
||||
const filterToZoneIds = filterToZones.map((z) => z.id);
|
||||
|
||||
const currentPageIndex = currentPageNumber - 1;
|
||||
const offset = currentPageIndex * SEARCH_PER_PAGE;
|
||||
|
||||
// Here's the actual GQL query! At the bottom we have more config than usual!
|
||||
const {
|
||||
loading: loadingGQL,
|
||||
error,
|
||||
data,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query SearchPanel(
|
||||
$query: String!
|
||||
$fitsPet: FitsPetSearchFilter
|
||||
$itemKind: ItemKindSearchFilter
|
||||
$currentUserOwnsOrWants: OwnsOrWants
|
||||
$zoneIds: [ID!]!
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$offset: Int!
|
||||
$perPage: Int!
|
||||
) {
|
||||
itemSearch: itemSearchV2(
|
||||
query: $query
|
||||
fitsPet: $fitsPet
|
||||
itemKind: $itemKind
|
||||
currentUserOwnsOrWants: $currentUserOwnsOrWants
|
||||
zoneIds: $zoneIds
|
||||
) {
|
||||
id
|
||||
numTotalItems
|
||||
items(offset: $offset, limit: $perPage) {
|
||||
# TODO: De-dupe this from useOutfitState?
|
||||
id
|
||||
name
|
||||
thumbnailUrl
|
||||
isNc
|
||||
isPb
|
||||
currentUserOwnsThis
|
||||
currentUserWantsThis
|
||||
|
||||
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||
# This enables us to quickly show the item when the user clicks it!
|
||||
...ItemAppearanceForOutfitPreview
|
||||
|
||||
# This is used to group items by zone, and to detect conflicts when
|
||||
# wearing a new item.
|
||||
layers {
|
||||
zone {
|
||||
id
|
||||
label @client
|
||||
}
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
label @client
|
||||
isCommonlyUsedByItems @client
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${itemAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
query: debouncedQuery.value,
|
||||
fitsPet: { speciesId, colorId },
|
||||
itemKind: debouncedQuery.filterToItemKind,
|
||||
currentUserOwnsOrWants: debouncedQuery.filterToCurrentUserOwnsOrWants,
|
||||
zoneIds: filterToZoneIds,
|
||||
speciesId,
|
||||
colorId,
|
||||
offset,
|
||||
perPage: SEARCH_PER_PAGE,
|
||||
},
|
||||
context: { sendAuth: true },
|
||||
skip:
|
||||
skip ||
|
||||
(!debouncedQuery.value &&
|
||||
!debouncedQuery.filterToItemKind &&
|
||||
!debouncedQuery.filterToZoneLabel &&
|
||||
!debouncedQuery.filterToCurrentUserOwnsOrWants),
|
||||
onError: (e) => {
|
||||
console.error("Error loading search results", e);
|
||||
},
|
||||
// Return `numTotalItems` from the GQL cache while waiting for next page!
|
||||
returnPartialData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const loading = debouncedQuery !== query || loadingGQL;
|
||||
const items = data?.itemSearch?.items ?? [];
|
||||
const numTotalItems = data?.itemSearch?.numTotalItems ?? null;
|
||||
const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE);
|
||||
|
||||
return { loading, error, items, numTotalPages };
|
||||
}
|
147
app/javascript/wardrobe-2020/components/HTML5Badge.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
import React from "react";
|
||||
import { Tooltip, useColorModeValue, Flex, Icon } from "@chakra-ui/react";
|
||||
import { CheckCircleIcon, WarningTwoIcon } from "@chakra-ui/icons";
|
||||
|
||||
function HTML5Badge({ usesHTML5, isLoading, tooltipLabel }) {
|
||||
// `delayedUsesHTML5` stores the last known value of `usesHTML5`, when
|
||||
// `isLoading` was `false`. This enables us to keep showing the badge, even
|
||||
// when loading a new appearance - because it's unlikely the badge will
|
||||
// change between different appearances for the same item, and the flicker is
|
||||
// annoying!
|
||||
const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setDelayedUsesHTML5(usesHTML5);
|
||||
}
|
||||
}, [usesHTML5, isLoading]);
|
||||
|
||||
if (delayedUsesHTML5 === true) {
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
hasGlitches={false}
|
||||
aria-label="HTML5 supported!"
|
||||
tooltipLabel={
|
||||
tooltipLabel ||
|
||||
"This item is converted to HTML5, and ready to use on Neopets.com!"
|
||||
}
|
||||
>
|
||||
<CheckCircleIcon fontSize="xs" />
|
||||
<Icon
|
||||
viewBox="0 0 36 36"
|
||||
fontSize="xl"
|
||||
// Visual re-balancing, there's too much visual right-padding here!
|
||||
marginRight="-1"
|
||||
>
|
||||
{/* From Twemoji Keycap 5 */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
|
||||
/>
|
||||
</Icon>
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
} else if (delayedUsesHTML5 === false) {
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
hasGlitches={true}
|
||||
aria-label="HTML5 not supported"
|
||||
tooltipLabel={
|
||||
tooltipLabel || (
|
||||
<>
|
||||
This item isn't converted to HTML5 yet, so it might not appear in
|
||||
Neopets.com customization yet. Once it's ready, it could look a
|
||||
bit different than our temporary preview here. It might even be
|
||||
animated!
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<WarningTwoIcon fontSize="xs" marginRight="1" />
|
||||
<Icon viewBox="0 0 36 36" fontSize="xl">
|
||||
{/* From Twemoji Keycap 5 */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
|
||||
/>
|
||||
|
||||
{/* From Twemoji Not Allowed */}
|
||||
<path
|
||||
fill="#DD2E44"
|
||||
opacity="0.75"
|
||||
d="M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zm13 18c0 2.565-.753 4.95-2.035 6.965L11.036 7.036C13.05 5.753 15.435 5 18 5c7.18 0 13 5.821 13 13zM5 18c0-2.565.753-4.95 2.036-6.964l17.929 17.929C22.95 30.247 20.565 31 18 31c-7.179 0-13-5.82-13-13z"
|
||||
/>
|
||||
</Icon>
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
} else {
|
||||
// If no `usesHTML5` value has been provided yet, we're empty for now!
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function GlitchBadgeLayout({
|
||||
hasGlitches = true,
|
||||
children,
|
||||
tooltipLabel,
|
||||
...props
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
|
||||
const greenBackground = useColorModeValue("green.100", "green.900");
|
||||
const greenBorderColor = useColorModeValue("green.600", "green.500");
|
||||
const greenTextColor = useColorModeValue("green.700", "white");
|
||||
|
||||
const yellowBackground = useColorModeValue("yellow.100", "yellow.900");
|
||||
const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500");
|
||||
const yellowTextColor = useColorModeValue("yellow.700", "white");
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
placement="bottom"
|
||||
label={tooltipLabel}
|
||||
// HACK: Chakra tooltips seem inconsistent about staying open when focus
|
||||
// comes from touch events. But I really want this one to work on
|
||||
// mobile!
|
||||
isOpen={isHovered || isFocused}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
backgroundColor={hasGlitches ? yellowBackground : greenBackground}
|
||||
borderColor={hasGlitches ? yellowBorderColor : greenBorderColor}
|
||||
color={hasGlitches ? yellowTextColor : greenTextColor}
|
||||
border="1px solid"
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
paddingX="2"
|
||||
paddingY="1"
|
||||
transition="all 0.2s"
|
||||
tabIndex="0"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
// For consistency between the HTML5Badge & OutfitKnownGlitchesBadge
|
||||
minHeight="30px"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function layerUsesHTML5(layer) {
|
||||
return Boolean(
|
||||
layer.svgUrl ||
|
||||
layer.canvasMovieLibraryUrl ||
|
||||
// If this glitch is applied, then `svgUrl` will be null, but there's still
|
||||
// an HTML5 manifest that the official player can render.
|
||||
(layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT")
|
||||
);
|
||||
}
|
||||
|
||||
export default HTML5Badge;
|
97
app/javascript/wardrobe-2020/components/HangerSpinner.js
Normal file
|
@ -0,0 +1,97 @@
|
|||
import * as React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import { Box, useColorModeValue } from "@chakra-ui/react";
|
||||
import { createIcon } from "@chakra-ui/icons";
|
||||
|
||||
const HangerIcon = createIcon({
|
||||
displayName: "HangerIcon",
|
||||
|
||||
// https://www.svgrepo.com/svg/108090/clothes-hanger
|
||||
viewBox: "0 0 473 473",
|
||||
path: (
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M451.426,315.003c-0.517-0.344-1.855-0.641-2.41-0.889l-201.09-88.884v-28.879c38.25-4.6,57.136-29.835,57.136-62.28c0-35.926-25.283-63.026-59.345-63.026c-35.763,0-65.771,29.481-65.771,64.384c0,6.005,4.973,10.882,10.978,10.882c1.788,0,3.452-0.535,4.934-1.291c3.519-1.808,6.024-5.365,6.024-9.591c0-22.702,20.674-42.62,44.217-42.62c22.003,0,37.982,17.356,37.982,41.262c0,23.523-19.011,41.262-44.925,41.262c-6.005,0-10.356,4.877-10.356,10.882v21.267v21.353c0,0.21-0.421,0.383-0.401,0.593L35.61,320.55C7.181,330.792-2.554,354.095,0.554,371.881c3.194,18.293,18.704,30.074,38.795,30.074H422.26c23.782,0,42.438-12.307,48.683-32.942C477.11,348.683,469.078,326.766,451.426,315.003z M450.115,364.031c-3.452,11.427-13.607,18.8-27.846,18.8H39.349c-9.725,0-16.104-5.394-17.5-13.368c-1.587-9.104,4.265-22.032,21.831-28.42l199.531-94.583l196.844,87.65C449.303,340.717,453.434,353.072,450.115,364.031z"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
function HangerSpinner({ size = "md", ...props }) {
|
||||
const boxSize = { sm: "32px", md: "48px" }[size];
|
||||
const color = useColorModeValue("green.500", "green.300");
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
className={css`
|
||||
/*
|
||||
Adapted from animate.css "swing". We spend 75% of the time swinging,
|
||||
then 25% of the time pausing before the next loop.
|
||||
|
||||
We use this animation for folks who are okay with dizzy-ish motion.
|
||||
For reduced motion, we use a pulse-fade instead.
|
||||
*/
|
||||
@keyframes swing {
|
||||
15% {
|
||||
transform: rotate3d(0, 0, 1, 15deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: rotate3d(0, 0, 1, -10deg);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: rotate3d(0, 0, 1, 5deg);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: rotate3d(0, 0, 1, -5deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate3d(0, 0, 1, 0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate3d(0, 0, 1, 0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A homebrew fade-pulse animation. We use this for folks who don't
|
||||
like motion. It's an important accessibility thing!
|
||||
*/
|
||||
@keyframes fade-pulse {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: 1.2s infinite swing;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: 1.6s infinite fade-pulse;
|
||||
}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<HangerIcon boxSize={boxSize} color={color} transition="color 0.2s" />
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
export default HangerSpinner;
|
432
app/javascript/wardrobe-2020/components/ItemCard.js
Normal file
|
@ -0,0 +1,432 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
CheckIcon,
|
||||
EditIcon,
|
||||
NotAllowedIcon,
|
||||
StarIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { HiSparkles } from "react-icons/hi";
|
||||
import Link from "next/link";
|
||||
|
||||
import SquareItemCard from "./SquareItemCard";
|
||||
import { safeImageUrl, useCommonStyles } from "../util";
|
||||
import usePreferArchive from "./usePreferArchive";
|
||||
|
||||
function ItemCard({ item, badges, variant = "list", ...props }) {
|
||||
const { brightBackground } = useCommonStyles();
|
||||
|
||||
switch (variant) {
|
||||
case "grid":
|
||||
return <SquareItemCard item={item} {...props} />;
|
||||
case "list":
|
||||
return (
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<Box
|
||||
as="a"
|
||||
display="block"
|
||||
p="2"
|
||||
boxShadow="lg"
|
||||
borderRadius="lg"
|
||||
background={brightBackground}
|
||||
transition="all 0.2s"
|
||||
className="item-card"
|
||||
width="100%"
|
||||
minWidth="0"
|
||||
{...props}
|
||||
>
|
||||
<ItemCardContent
|
||||
item={item}
|
||||
badges={badges}
|
||||
focusSelector=".item-card:hover &, .item-card:focus &"
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unexpected ItemCard variant: ${variant}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ItemCardContent({
|
||||
item,
|
||||
badges,
|
||||
isWorn,
|
||||
isDisabled,
|
||||
itemNameId,
|
||||
focusSelector,
|
||||
}) {
|
||||
return (
|
||||
<Box display="flex">
|
||||
<Box>
|
||||
<Box flex="0 0 auto" marginRight="3">
|
||||
<ItemThumbnail
|
||||
item={item}
|
||||
isActive={isWorn}
|
||||
isDisabled={isDisabled}
|
||||
focusSelector={focusSelector}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flex="1 1 0" minWidth="0" marginTop="1px">
|
||||
<ItemName
|
||||
id={itemNameId}
|
||||
isWorn={isWorn}
|
||||
isDisabled={isDisabled}
|
||||
focusSelector={focusSelector}
|
||||
>
|
||||
{item.name}
|
||||
</ItemName>
|
||||
|
||||
{badges}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemThumbnail shows a small preview image for the item, including some
|
||||
* hover/focus and worn/unworn states.
|
||||
*/
|
||||
export function ItemThumbnail({
|
||||
item,
|
||||
size = "md",
|
||||
isActive,
|
||||
isDisabled,
|
||||
focusSelector,
|
||||
...props
|
||||
}) {
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const theme = useTheme();
|
||||
|
||||
const borderColor = useColorModeValue(
|
||||
theme.colors.green["700"],
|
||||
"transparent"
|
||||
);
|
||||
|
||||
const focusBorderColor = useColorModeValue(
|
||||
theme.colors.green["600"],
|
||||
"transparent"
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
width={size === "lg" ? "80px" : "50px"}
|
||||
height={size === "lg" ? "80px" : "50px"}
|
||||
transition="all 0.15s"
|
||||
transformOrigin="center"
|
||||
position="relative"
|
||||
className={css([
|
||||
{
|
||||
transform: "scale(0.8)",
|
||||
},
|
||||
!isDisabled &&
|
||||
!isActive && {
|
||||
[focusSelector]: {
|
||||
opacity: "0.9",
|
||||
transform: "scale(0.9)",
|
||||
},
|
||||
},
|
||||
!isDisabled &&
|
||||
isActive && {
|
||||
opacity: 1,
|
||||
transform: "none",
|
||||
},
|
||||
])}
|
||||
{...props}
|
||||
>
|
||||
<Box
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
overflow="hidden"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className={css([
|
||||
{
|
||||
borderColor: `${borderColor} !important`,
|
||||
},
|
||||
!isDisabled &&
|
||||
!isActive && {
|
||||
[focusSelector]: {
|
||||
borderColor: `${focusBorderColor} !important`,
|
||||
},
|
||||
},
|
||||
])}
|
||||
>
|
||||
{/* If the item is still loading, wait with an empty box. */}
|
||||
{item && (
|
||||
<Box
|
||||
as="img"
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
|
||||
alt={`Thumbnail art for ${item.name}`}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemName shows the item's name, including some hover/focus and worn/unworn
|
||||
* states.
|
||||
*/
|
||||
function ItemName({ children, isDisabled, focusSelector, ...props }) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
fontSize="md"
|
||||
transition="all 0.15s"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
className={
|
||||
!isDisabled &&
|
||||
css`
|
||||
${focusSelector} {
|
||||
opacity: 0.9;
|
||||
font-weight: ${theme.fontWeights.medium};
|
||||
}
|
||||
|
||||
input:checked + .item-container & {
|
||||
opacity: 1;
|
||||
font-weight: ${theme.fontWeights.bold};
|
||||
}
|
||||
`
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemCardList({ children }) {
|
||||
return (
|
||||
<SimpleGrid columns={{ sm: 1, md: 2, lg: 3 }} spacing="6">
|
||||
{children}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemBadgeList({ children, ...props }) {
|
||||
return (
|
||||
<Wrap spacing="2" opacity="0.7" {...props}>
|
||||
{React.Children.map(
|
||||
children,
|
||||
(badge) => badge && <WrapItem>{badge}</WrapItem>
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemBadgeTooltip({ label, children }) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={<Box textAlign="center">{label}</Box>}
|
||||
placement="top"
|
||||
openDelay={400}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const NcBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
|
||||
return (
|
||||
<ItemBadgeTooltip label="Neocash">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
colorScheme="purple"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
NC
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const NpBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
|
||||
return (
|
||||
<ItemBadgeTooltip label="Neopoints">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
NP
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const PbBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
|
||||
return (
|
||||
<ItemBadgeTooltip label="This item is only obtainable via paintbrush">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
colorScheme="orange"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
PB
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const ItemKindBadge = React.forwardRef(
|
||||
({ isNc, isPb, isEditButton, ...props }, ref) => {
|
||||
if (isNc) {
|
||||
return <NcBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
} else if (isPb) {
|
||||
return <PbBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
} else {
|
||||
return <NpBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function YouOwnThisBadge({ variant = "long" }) {
|
||||
let badge = (
|
||||
<Badge
|
||||
colorScheme="green"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<CheckIcon aria-label="Check" />
|
||||
{variant === "medium" && <Box marginLeft="1">Own</Box>}
|
||||
{variant === "long" && <Box marginLeft="1">You own this!</Box>}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (variant === "short" || variant === "medium") {
|
||||
badge = (
|
||||
<ItemBadgeTooltip label="You own this item">{badge}</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
|
||||
export function YouWantThisBadge({ variant = "long" }) {
|
||||
let badge = (
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<StarIcon aria-label="Star" />
|
||||
{variant === "medium" && <Box marginLeft="1">Want</Box>}
|
||||
{variant === "long" && <Box marginLeft="1">You want this!</Box>}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (variant === "short" || variant === "medium") {
|
||||
badge = (
|
||||
<ItemBadgeTooltip label="You want this item">{badge}</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
|
||||
function ZoneBadge({ variant, zoneLabel }) {
|
||||
// Shorten the label when necessary, to make the badges less bulky
|
||||
const shorthand = zoneLabel
|
||||
.replace("Background Item", "BG Item")
|
||||
.replace("Foreground Item", "FG Item")
|
||||
.replace("Lower-body", "Lower")
|
||||
.replace("Upper-body", "Upper")
|
||||
.replace("Transient", "Trans")
|
||||
.replace("Biology", "Bio");
|
||||
|
||||
if (variant === "restricts") {
|
||||
return (
|
||||
<ItemBadgeTooltip
|
||||
label={`Restricted: This item can't be worn with ${zoneLabel} items`}
|
||||
>
|
||||
<Badge>
|
||||
<Box display="flex" alignItems="center">
|
||||
{shorthand} <NotAllowedIcon marginLeft="1" />
|
||||
</Box>
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (shorthand !== zoneLabel) {
|
||||
return (
|
||||
<ItemBadgeTooltip label={zoneLabel}>
|
||||
<Badge>{shorthand}</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <Badge>{shorthand}</Badge>;
|
||||
}
|
||||
|
||||
export function getZoneBadges(zones, propsForAllBadges) {
|
||||
// Get the sorted zone labels. Sometimes an item occupies multiple zones of
|
||||
// the same name, so it's important to de-duplicate them!
|
||||
let labels = zones.map((z) => z.label);
|
||||
labels = new Set(labels);
|
||||
labels = [...labels].sort();
|
||||
|
||||
return labels.map((label) => (
|
||||
<ZoneBadge key={label} zoneLabel={label} {...propsForAllBadges} />
|
||||
));
|
||||
}
|
||||
|
||||
export function MaybeAnimatedBadge() {
|
||||
return (
|
||||
<ItemBadgeTooltip label="Maybe animated? (Support only)">
|
||||
<Badge
|
||||
colorScheme="orange"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<Box as={HiSparkles} aria-label="Sparkles" />
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemCard;
|
471
app/javascript/wardrobe-2020/components/OutfitMovieLayer.js
Normal file
|
@ -0,0 +1,471 @@
|
|||
import React from "react";
|
||||
import LRU from "lru-cache";
|
||||
import { Box, Grid, useToast } from "@chakra-ui/react";
|
||||
|
||||
import { loadImage, logAndCapture, safeImageUrl } from "../util";
|
||||
import usePreferArchive from "./usePreferArchive";
|
||||
|
||||
// Importing EaselJS and TweenJS puts them directly into the `window` object!
|
||||
// The bundled scripts are built to attach themselves to `window.createjs`, and
|
||||
// `window.createjs` is where the Neopets movie libraries expects to find them!
|
||||
import "easeljs/lib/easeljs";
|
||||
import "tweenjs/lib/tweenjs";
|
||||
|
||||
function OutfitMovieLayer({
|
||||
libraryUrl,
|
||||
width,
|
||||
height,
|
||||
placeholderImageUrl = null,
|
||||
isPaused = false,
|
||||
onLoad = null,
|
||||
onError = null,
|
||||
onLowFps = null,
|
||||
canvasProps = {},
|
||||
}) {
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const [stage, setStage] = React.useState(null);
|
||||
const [library, setLibrary] = React.useState(null);
|
||||
const [movieClip, setMovieClip] = React.useState(null);
|
||||
const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
|
||||
const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
|
||||
const canvasRef = React.useRef(null);
|
||||
const hasShownErrorMessageRef = React.useRef(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Set the canvas's internal dimensions to be higher, if the device has high
|
||||
// DPI like retina. But we'll keep the layout width/height as expected!
|
||||
const internalWidth = width * window.devicePixelRatio;
|
||||
const internalHeight = height * window.devicePixelRatio;
|
||||
|
||||
const callOnLoadIfNotYetCalled = React.useCallback(() => {
|
||||
setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
|
||||
if (!alreadyHasCalledOnLoad && onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [onLoad]);
|
||||
|
||||
const updateStage = React.useCallback(() => {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
stage.update();
|
||||
} catch (e) {
|
||||
// If rendering the frame fails, log it and proceed. If it's an
|
||||
// animation, then maybe the next frame will work? Also alert the user,
|
||||
// just as an FYI. (This is pretty uncommon, so I'm not worried about
|
||||
// being noisy!)
|
||||
if (!hasShownErrorMessageRef.current) {
|
||||
console.error(`Error rendering movie clip ${libraryUrl}`);
|
||||
logAndCapture(e);
|
||||
toast({
|
||||
status: "warning",
|
||||
title:
|
||||
"Hmm, we're maybe having trouble playing one of these animations.",
|
||||
description:
|
||||
"If it looks wrong, try pausing and playing, or reloading the " +
|
||||
"page. Sorry!",
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
});
|
||||
// We do this via a ref, not state, because I want to guarantee that
|
||||
// future calls see the new value. With state, React's effects might
|
||||
// not happen in the right order for it to work!
|
||||
hasShownErrorMessageRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [stage, toast, libraryUrl]);
|
||||
|
||||
// This effect gives us a `stage` corresponding to the canvas element.
|
||||
React.useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvas.getContext("2d") == null) {
|
||||
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
||||
toast({
|
||||
status: "warning",
|
||||
title: "Oops, too many animations!",
|
||||
description:
|
||||
`Your device is out of memory, so we can't show any more ` +
|
||||
`animations. Try removing some items, or using another device.`,
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStage((stage) => {
|
||||
if (stage && stage.canvas === canvas) {
|
||||
return stage;
|
||||
}
|
||||
|
||||
return new window.createjs.Stage(canvas);
|
||||
});
|
||||
|
||||
return () => {
|
||||
setStage(null);
|
||||
|
||||
if (canvas) {
|
||||
// There's a Safari bug where it doesn't reliably garbage-collect
|
||||
// canvas data. Clean it up ourselves, rather than leaking memory over
|
||||
// time! https://stackoverflow.com/a/52586606/107415
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=195325
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
};
|
||||
}, [libraryUrl, toast]);
|
||||
|
||||
// This effect gives us the `library` and `movieClip`, based on the incoming
|
||||
// `libraryUrl`.
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
|
||||
movieLibraryPromise
|
||||
.then((library) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLibrary(library);
|
||||
|
||||
const movieClip = buildMovieClip(library, libraryUrl);
|
||||
setMovieClip(movieClip);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
|
||||
if (onError) {
|
||||
onError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
movieLibraryPromise.cancel();
|
||||
setLibrary(null);
|
||||
setMovieClip(null);
|
||||
};
|
||||
}, [libraryUrl, preferArchive, onError]);
|
||||
|
||||
// This effect puts the `movieClip` on the `stage`, when both are ready.
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip) {
|
||||
return;
|
||||
}
|
||||
|
||||
stage.addChild(movieClip);
|
||||
|
||||
// Render the movie's first frame. If it's animated and we're not paused,
|
||||
// then another effect will perform subsequent updates.
|
||||
updateStage();
|
||||
|
||||
// This is when we trigger `onLoad`: once we're actually showing it!
|
||||
callOnLoadIfNotYetCalled();
|
||||
setMovieIsLoaded(true);
|
||||
|
||||
return () => stage.removeChild(movieClip);
|
||||
}, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
|
||||
|
||||
// This effect updates the `stage` according to the `library`'s framerate,
|
||||
// but only if there's actual animation to do - i.e., there's more than one
|
||||
// frame to show, and we're not paused.
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip || !library) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaused || !hasAnimations(movieClip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFps = library.properties.fps;
|
||||
|
||||
let lastFpsLoggedAtInMs = performance.now();
|
||||
let numFramesSinceLastLogged = 0;
|
||||
const intervalId = setInterval(() => {
|
||||
updateStage();
|
||||
|
||||
numFramesSinceLastLogged++;
|
||||
|
||||
const now = performance.now();
|
||||
const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
|
||||
const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
|
||||
|
||||
if (timeSinceLastFpsLoggedAtInSec > 2) {
|
||||
const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
|
||||
const roundedFps = Math.round(fps * 100) / 100;
|
||||
|
||||
console.debug(
|
||||
`[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`
|
||||
);
|
||||
|
||||
if (onLowFps && fps < 2) {
|
||||
onLowFps(fps);
|
||||
}
|
||||
|
||||
lastFpsLoggedAtInMs = now;
|
||||
numFramesSinceLastLogged = 0;
|
||||
}
|
||||
}, 1000 / targetFps);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);
|
||||
|
||||
// This effect keeps the `movieClip` scaled correctly, based on the canvas
|
||||
// size and the `library`'s natural size declaration. (If the canvas size
|
||||
// changes on window resize, then this will keep us responsive, so long as
|
||||
// the parent updates our width/height props on window resize!)
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip || !library) {
|
||||
return;
|
||||
}
|
||||
|
||||
movieClip.scaleX = internalWidth / library.properties.width;
|
||||
movieClip.scaleY = internalHeight / library.properties.height;
|
||||
|
||||
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
|
||||
// to `false`, so that we don't advance by a frame. This keeps us
|
||||
// really-paused if we're paused, and avoids skipping ahead by a frame if
|
||||
// we're playing.
|
||||
stage.tickOnUpdate = false;
|
||||
updateStage();
|
||||
stage.tickOnUpdate = true;
|
||||
}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
|
||||
|
||||
return (
|
||||
<Grid templateAreas="single-shared-area">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={internalWidth}
|
||||
height={internalHeight}
|
||||
style={{
|
||||
width: width,
|
||||
height: height,
|
||||
gridArea: "single-shared-area",
|
||||
}}
|
||||
data-is-loaded={movieIsLoaded}
|
||||
{...canvasProps}
|
||||
/>
|
||||
{/* While the movie is loading, we show our image version as a
|
||||
* placeholder, because it generally loads much faster.
|
||||
* TODO: Show a loading indicator for this partially-loaded state? */}
|
||||
{placeholderImageUrl && (
|
||||
<Box
|
||||
as="img"
|
||||
src={safeImageUrl(placeholderImageUrl)}
|
||||
width={width}
|
||||
height={height}
|
||||
gridArea="single-shared-area"
|
||||
opacity={movieIsLoaded ? 0 : 1}
|
||||
transition="opacity 0.2s"
|
||||
onLoad={callOnLoadIfNotYetCalled}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function loadScriptTag(src) {
|
||||
let script;
|
||||
let canceled = false;
|
||||
let resolved = false;
|
||||
|
||||
const scriptTagPromise = new Promise((resolve, reject) => {
|
||||
script = document.createElement("script");
|
||||
script.onload = () => {
|
||||
if (canceled) return;
|
||||
resolved = true;
|
||||
resolve(script);
|
||||
};
|
||||
script.onerror = (e) => {
|
||||
if (canceled) return;
|
||||
reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
|
||||
};
|
||||
script.src = src;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
scriptTagPromise.cancel = () => {
|
||||
if (resolved) return;
|
||||
script.src = "";
|
||||
canceled = true;
|
||||
};
|
||||
|
||||
return scriptTagPromise;
|
||||
}
|
||||
|
||||
const MOVIE_LIBRARY_CACHE = new LRU(10);
|
||||
|
||||
export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
|
||||
const cancelableResourcePromises = [];
|
||||
const cancelAllResources = () =>
|
||||
cancelableResourcePromises.forEach((p) => p.cancel());
|
||||
|
||||
// Most of the logic for `loadMovieLibrary` is inside this async function.
|
||||
// But we want to attach more fields to the promise before returning it; so
|
||||
// we declare this async function separately, then call it, then edit the
|
||||
// returned promise!
|
||||
const createMovieLibraryPromise = async () => {
|
||||
// First, check the LRU cache. This will enable us to quickly return movie
|
||||
// libraries, without re-loading and re-parsing and re-executing.
|
||||
const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
|
||||
if (cachedLibrary) {
|
||||
return cachedLibrary;
|
||||
}
|
||||
|
||||
// Then, load the script tag. (Make sure we set it up to be cancelable!)
|
||||
const scriptPromise = loadScriptTag(
|
||||
safeImageUrl(librarySrc, { preferArchive })
|
||||
);
|
||||
cancelableResourcePromises.push(scriptPromise);
|
||||
await scriptPromise;
|
||||
|
||||
// These library JS files are interesting in their operation. It seems like
|
||||
// the idea is, it pushes an object to a global array, and you need to snap
|
||||
// it up and see it at the end of the array! And I don't really see a way to
|
||||
// like, get by a name or ID that we know by this point. So, here we go, just
|
||||
// try to grab it once it arrives!
|
||||
//
|
||||
// I'm not _sure_ this method is reliable, but it seems to be stable so far
|
||||
// in Firefox for me. The things I think I'm observing are:
|
||||
// - Script execution order should match insert order,
|
||||
// - Onload execution order should match insert order,
|
||||
// - BUT, script executions might be batched before onloads.
|
||||
// - So, each script grabs the _first_ composition from the list, and
|
||||
// deletes it after grabbing. That way, it serves as a FIFO queue!
|
||||
// I'm not suuure this is happening as I'm expecting, vs I'm just not seeing
|
||||
// the race anymore? But fingers crossed!
|
||||
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
|
||||
throw new Error(
|
||||
`Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`
|
||||
);
|
||||
}
|
||||
const [compositionId, composition] = Object.entries(
|
||||
window.AdobeAn.compositions
|
||||
)[0];
|
||||
if (Object.keys(window.AdobeAn.compositions).length > 1) {
|
||||
console.warn(
|
||||
`Grabbing composition ${compositionId}, but there are >1 here: `,
|
||||
Object.keys(window.AdobeAn.compositions).length
|
||||
);
|
||||
}
|
||||
delete window.AdobeAn.compositions[compositionId];
|
||||
const library = composition.getLibrary();
|
||||
|
||||
// One more loading step as part of loading this library is loading the
|
||||
// images it uses for sprites.
|
||||
//
|
||||
// TODO: I guess the manifest has these too, so if we could use our DB cache
|
||||
// to get the manifest to us faster, then we could avoid a network RTT
|
||||
// on the critical path by preloading these images before the JS file
|
||||
// even gets to us?
|
||||
const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
|
||||
const manifestImages = new Map(
|
||||
library.properties.manifest.map(({ id, src }) => [
|
||||
id,
|
||||
loadImage(librarySrcDir + "/" + src, {
|
||||
crossOrigin: "anonymous",
|
||||
preferArchive,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// Wait for the images, and make sure they're cancelable while we do.
|
||||
const manifestImagePromises = manifestImages.values();
|
||||
cancelableResourcePromises.push(...manifestImagePromises);
|
||||
await Promise.all(manifestImagePromises);
|
||||
|
||||
// Finally, once we have the images loaded, the library object expects us to
|
||||
// mutate it (!) to give it the actual image and sprite sheet objects from
|
||||
// the loaded images. That's how the MovieClip's internal JS objects will
|
||||
// access the loaded data!
|
||||
const images = composition.getImages();
|
||||
for (const [id, image] of manifestImages.entries()) {
|
||||
images[id] = await image;
|
||||
}
|
||||
const spriteSheets = composition.getSpriteSheet();
|
||||
for (const { name, frames } of library.ssMetadata) {
|
||||
const image = await manifestImages.get(name);
|
||||
spriteSheets[name] = new window.createjs.SpriteSheet({
|
||||
images: [image],
|
||||
frames,
|
||||
});
|
||||
}
|
||||
|
||||
MOVIE_LIBRARY_CACHE.set(librarySrc, library);
|
||||
|
||||
return library;
|
||||
};
|
||||
|
||||
const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
|
||||
// When any part of the movie library fails, we also cancel the other
|
||||
// resources ourselves, to avoid stray throws for resources that fail after
|
||||
// the parent catches the initial failure. We re-throw the initial failure
|
||||
// for the parent to handle, though!
|
||||
cancelAllResources();
|
||||
throw e;
|
||||
});
|
||||
|
||||
// To cancel a `loadMovieLibrary`, cancel all of the resource promises we
|
||||
// load as part of it. That should effectively halt the async function above
|
||||
// (anything not yet loaded will stop loading), and ensure that stray
|
||||
// failures don't trigger uncaught promise rejection warnings.
|
||||
movieLibraryPromise.cancel = cancelAllResources;
|
||||
|
||||
return movieLibraryPromise;
|
||||
}
|
||||
|
||||
export function buildMovieClip(library, libraryUrl) {
|
||||
let constructorName;
|
||||
try {
|
||||
const fileName = decodeURI(libraryUrl).split("/").pop();
|
||||
const fileNameWithoutExtension = fileName.split(".")[0];
|
||||
constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
|
||||
if (constructorName.match(/^[0-9]/)) {
|
||||
constructorName = "_" + constructorName;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Movie libraryUrl ${JSON.stringify(
|
||||
libraryUrl
|
||||
)} did not match expected format: ${e.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const LibraryMovieClipConstructor = library[constructorName];
|
||||
if (!LibraryMovieClipConstructor) {
|
||||
throw new Error(
|
||||
`Expected JS movie library ${libraryUrl} to contain a constructor ` +
|
||||
`named ${constructorName}, but it did not: ${Object.keys(library)}`
|
||||
);
|
||||
}
|
||||
const movieClip = new LibraryMovieClipConstructor();
|
||||
|
||||
return movieClip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scans the given MovieClip (or child createjs node), to see if
|
||||
* there are any animated areas.
|
||||
*/
|
||||
export function hasAnimations(createjsNode) {
|
||||
return (
|
||||
// Some nodes have simple animation frames.
|
||||
createjsNode.totalFrames > 1 ||
|
||||
// Tweens are a form of animation that can happen separately from frames.
|
||||
// They expect timer ticks to happen, and they change the scene accordingly.
|
||||
createjsNode?.timeline?.tweens?.length >= 1 ||
|
||||
// And some nodes have _children_ that are animated.
|
||||
(createjsNode.children || []).some(hasAnimations)
|
||||
);
|
||||
}
|
||||
|
||||
export default OutfitMovieLayer;
|
541
app/javascript/wardrobe-2020/components/OutfitPreview.js
Normal file
|
@ -0,0 +1,541 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
DarkMode,
|
||||
Flex,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import LRU from "lru-cache";
|
||||
import { WarningIcon } from "@chakra-ui/icons";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
||||
import OutfitMovieLayer, {
|
||||
buildMovieClip,
|
||||
hasAnimations,
|
||||
loadMovieLibrary,
|
||||
} from "./OutfitMovieLayer";
|
||||
import HangerSpinner from "./HangerSpinner";
|
||||
import { loadImage, safeImageUrl, useLocalStorage } from "../util";
|
||||
import useOutfitAppearance from "./useOutfitAppearance";
|
||||
import usePreferArchive from "./usePreferArchive";
|
||||
|
||||
/**
|
||||
* OutfitPreview is for rendering a full outfit! It accepts outfit data,
|
||||
* fetches the appearance data for it, and preloads and renders the layers
|
||||
* together.
|
||||
*
|
||||
* If the species/color/pose fields are null and a `placeholder` node is
|
||||
* provided instead, we'll render the placeholder. And then, once those props
|
||||
* become non-null, we'll keep showing the placeholder below the loading
|
||||
* overlay until loading completes. (We use this on the homepage to show the
|
||||
* beach splash until outfit data arrives!)
|
||||
*
|
||||
* TODO: There's some duplicate work happening in useOutfitAppearance and
|
||||
* useOutfitState both getting appearance data on first load...
|
||||
*/
|
||||
function OutfitPreview(props) {
|
||||
const { preview } = useOutfitPreview(props);
|
||||
return preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* useOutfitPreview is like `<OutfitPreview />`, but a bit more power!
|
||||
*
|
||||
* It takes the same props and returns a `preview` field, which is just like
|
||||
* `<OutfitPreview />` - but it also returns `appearance` data too, in case you
|
||||
* want to show some additional UI that uses the appearance data we loaded!
|
||||
*/
|
||||
export function useOutfitPreview({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
wornItemIds,
|
||||
appearanceId = null,
|
||||
isLoading = false,
|
||||
placeholder = null,
|
||||
loadingDelayMs,
|
||||
spinnerVariant,
|
||||
onChangeHasAnimations = null,
|
||||
...props
|
||||
}) {
|
||||
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
||||
const toast = useToast();
|
||||
|
||||
const appearance = useOutfitAppearance({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
wornItemIds,
|
||||
});
|
||||
const { loading, error, visibleLayers } = appearance;
|
||||
|
||||
const {
|
||||
loading: loading2,
|
||||
error: error2,
|
||||
loadedLayers,
|
||||
layersHaveAnimations,
|
||||
} = usePreloadLayers(visibleLayers);
|
||||
|
||||
const onMovieError = React.useCallback(() => {
|
||||
if (!toast.isActive("outfit-preview-on-movie-error")) {
|
||||
toast({
|
||||
id: "outfit-preview-on-movie-error",
|
||||
status: "warning",
|
||||
title: "Oops, we couldn't load one of these animations.",
|
||||
description: "We'll show a static image version instead.",
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const onLowFps = React.useCallback(
|
||||
(fps) => {
|
||||
setIsPaused(true);
|
||||
console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`);
|
||||
|
||||
if (!toast.isActive("outfit-preview-on-low-fps")) {
|
||||
toast({
|
||||
id: "outfit-preview-on-low-fps",
|
||||
status: "warning",
|
||||
title: "Sorry, the animation was lagging, so we paused it! 😖",
|
||||
description:
|
||||
"We do this to help make sure your machine doesn't lag too much! " +
|
||||
"You can unpause the preview to try again.",
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setIsPaused, toast]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (onChangeHasAnimations) {
|
||||
onChangeHasAnimations(layersHaveAnimations);
|
||||
}
|
||||
}, [layersHaveAnimations, onChangeHasAnimations]);
|
||||
|
||||
const textColor = useColorModeValue("green.700", "white");
|
||||
|
||||
let preview;
|
||||
if (error || error2) {
|
||||
preview = (
|
||||
<FullScreenCenter>
|
||||
<Text color={textColor} d="flex" alignItems="center">
|
||||
<WarningIcon />
|
||||
<Box width={2} />
|
||||
Could not load preview. Try again?
|
||||
</Text>
|
||||
</FullScreenCenter>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<OutfitLayers
|
||||
loading={isLoading || loading || loading2}
|
||||
visibleLayers={loadedLayers}
|
||||
placeholder={placeholder}
|
||||
loadingDelayMs={loadingDelayMs}
|
||||
spinnerVariant={spinnerVariant}
|
||||
onMovieError={onMovieError}
|
||||
onLowFps={onLowFps}
|
||||
doTransitions
|
||||
isPaused={isPaused}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return { appearance, preview };
|
||||
}
|
||||
|
||||
/**
|
||||
* OutfitLayers is the raw UI component for rendering outfit layers. It's
|
||||
* used both in the main outfit preview, and in other minor UIs!
|
||||
*/
|
||||
export function OutfitLayers({
|
||||
loading,
|
||||
visibleLayers,
|
||||
placeholder = null,
|
||||
loadingDelayMs = 500,
|
||||
spinnerVariant = "overlay",
|
||||
doTransitions = false,
|
||||
isPaused = true,
|
||||
onMovieError = null,
|
||||
onLowFps = null,
|
||||
...props
|
||||
}) {
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const [preferArchive] = usePreferArchive();
|
||||
|
||||
const containerRef = React.useRef(null);
|
||||
const [canvasSize, setCanvasSize] = React.useState(0);
|
||||
const [loadingDelayHasPassed, setLoadingDelayHasPassed] =
|
||||
React.useState(false);
|
||||
|
||||
// When we start in a loading state, or re-enter a loading state, start the
|
||||
// loading delay timer.
|
||||
React.useEffect(() => {
|
||||
if (loading) {
|
||||
setLoadingDelayHasPassed(false);
|
||||
const t = setTimeout(
|
||||
() => setLoadingDelayHasPassed(true),
|
||||
loadingDelayMs
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [loadingDelayMs, loading]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
function computeAndSaveCanvasSize() {
|
||||
setCanvasSize(
|
||||
// Follow an algorithm similar to the <img> sizing: a square that
|
||||
// covers the available space, without exceeding the natural image size
|
||||
// (which is 600px).
|
||||
//
|
||||
// TODO: Once we're entirely off PNGs, we could drop the 600
|
||||
// requirement, and let SVGs and movies scale up as far as they
|
||||
// want...
|
||||
Math.min(
|
||||
containerRef.current.offsetWidth,
|
||||
containerRef.current.offsetHeight,
|
||||
600
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
computeAndSaveCanvasSize();
|
||||
window.addEventListener("resize", computeAndSaveCanvasSize);
|
||||
return () => window.removeEventListener("resize", computeAndSaveCanvasSize);
|
||||
}, [setCanvasSize]);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
pos="relative"
|
||||
height="100%"
|
||||
width="100%"
|
||||
maxWidth="600px"
|
||||
maxHeight="600px"
|
||||
// Create a stacking context, so the z-indexed layers don't escape!
|
||||
zIndex="0"
|
||||
ref={containerRef}
|
||||
data-loading={loading ? true : undefined}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<FullScreenCenter>
|
||||
<Box
|
||||
// We show the placeholder until there are visible layers, at which
|
||||
// point we fade it out.
|
||||
opacity={visibleLayers.length === 0 ? 1 : 0}
|
||||
transition="opacity 0.2s"
|
||||
width="100%"
|
||||
height="100%"
|
||||
maxWidth="600px"
|
||||
maxHeight="600px"
|
||||
>
|
||||
{placeholder}
|
||||
</Box>
|
||||
</FullScreenCenter>
|
||||
)}
|
||||
<TransitionGroup enter={false} exit={doTransitions}>
|
||||
{visibleLayers.map((layer) => (
|
||||
<CSSTransition
|
||||
// We manage the fade-in and fade-out separately! The fade-out
|
||||
// happens here, when the layer exits the DOM.
|
||||
key={layer.id}
|
||||
timeout={200}
|
||||
>
|
||||
<FadeInOnLoad
|
||||
as={FullScreenCenter}
|
||||
zIndex={layer.zone.depth}
|
||||
className={css`
|
||||
&.exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{layer.canvasMovieLibraryUrl ? (
|
||||
<OutfitMovieLayer
|
||||
libraryUrl={layer.canvasMovieLibraryUrl}
|
||||
placeholderImageUrl={getBestImageUrlForLayer(layer, {
|
||||
hiResMode,
|
||||
})}
|
||||
width={canvasSize}
|
||||
height={canvasSize}
|
||||
isPaused={isPaused}
|
||||
onError={onMovieError}
|
||||
onLowFps={onLowFps}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
as="img"
|
||||
src={safeImageUrl(
|
||||
getBestImageUrlForLayer(layer, { hiResMode }),
|
||||
{ preferArchive }
|
||||
)}
|
||||
alt=""
|
||||
objectFit="contain"
|
||||
maxWidth="100%"
|
||||
maxHeight="100%"
|
||||
/>
|
||||
)}
|
||||
</FadeInOnLoad>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
<FullScreenCenter
|
||||
zIndex="9000"
|
||||
// This is similar to our Delay util component, but Delay disappears
|
||||
// immediately on load, whereas we want this to fade out smoothly. We
|
||||
// also use a timeout to delay the fade-in by 0.5s, but don't delay the
|
||||
// fade-out at all. (The timeout was an awkward choice, it was hard to
|
||||
// find a good CSS way to specify this delay well!)
|
||||
opacity={loading && loadingDelayHasPassed ? 1 : 0}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
{spinnerVariant === "overlay" && (
|
||||
<>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
backgroundColor="gray.900"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{/* Against the dark overlay, use the Dark Mode spinner. */}
|
||||
<DarkMode>
|
||||
<HangerSpinner />
|
||||
</DarkMode>
|
||||
</>
|
||||
)}
|
||||
{spinnerVariant === "corner" && (
|
||||
<HangerSpinner
|
||||
size="sm"
|
||||
position="absolute"
|
||||
bottom="2"
|
||||
right="2"
|
||||
/>
|
||||
)}
|
||||
</FullScreenCenter>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullScreenCenter({ children, ...otherProps }) {
|
||||
return (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
left="0"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function getBestImageUrlForLayer(layer, { hiResMode = false } = {}) {
|
||||
if (hiResMode && layer.svgUrl) {
|
||||
return layer.svgUrl;
|
||||
} else {
|
||||
return layer.imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* usePreloadLayers preloads the images for the given layers, and yields them
|
||||
* when done. This enables us to keep the old outfit preview on screen until
|
||||
* all the new layers are ready, then show them all at once!
|
||||
*/
|
||||
export function usePreloadLayers(layers) {
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const [preferArchive] = usePreferArchive();
|
||||
|
||||
const [error, setError] = React.useState(null);
|
||||
const [loadedLayers, setLoadedLayers] = React.useState([]);
|
||||
const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
|
||||
|
||||
// NOTE: This condition would need to change if we started loading one at a
|
||||
// time, or if the error case would need to show a partial state!
|
||||
const loading = layers.length > 0 && loadedLayers !== layers;
|
||||
|
||||
React.useEffect(() => {
|
||||
// HACK: Don't clear the preview when we have zero layers, because it
|
||||
// usually means the parent is still loading data. I feel like this isn't
|
||||
// the right abstraction, though...
|
||||
if (layers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
setError(null);
|
||||
setLayersHaveAnimations(false);
|
||||
|
||||
const minimalAssetPromises = [];
|
||||
const imageAssetPromises = [];
|
||||
const movieAssetPromises = [];
|
||||
for (const layer of layers) {
|
||||
const imageAssetPromise = loadImage(
|
||||
getBestImageUrlForLayer(layer, { hiResMode }),
|
||||
{ preferArchive }
|
||||
);
|
||||
imageAssetPromises.push(imageAssetPromise);
|
||||
|
||||
if (layer.canvasMovieLibraryUrl) {
|
||||
// Start preloading the movie. But we won't block on it! The blocking
|
||||
// request will still be the image, which we'll show as a
|
||||
// placeholder, which should usually be noticeably faster!
|
||||
const movieLibraryPromise = loadMovieLibrary(
|
||||
layer.canvasMovieLibraryUrl,
|
||||
{ preferArchive }
|
||||
);
|
||||
const movieAssetPromise = movieLibraryPromise.then((library) => ({
|
||||
library,
|
||||
libraryUrl: layer.canvasMovieLibraryUrl,
|
||||
}));
|
||||
movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl;
|
||||
movieAssetPromise.cancel = () => movieLibraryPromise.cancel();
|
||||
movieAssetPromises.push(movieAssetPromise);
|
||||
|
||||
// The minimal asset for the movie case is *either* the image *or*
|
||||
// the movie, because we can start rendering when either is ready.
|
||||
minimalAssetPromises.push(
|
||||
Promise.any([imageAssetPromise, movieAssetPromise])
|
||||
);
|
||||
} else {
|
||||
minimalAssetPromises.push(imageAssetPromise);
|
||||
}
|
||||
}
|
||||
|
||||
// When the minimal assets have loaded, we can say the layers have
|
||||
// loaded, and allow the UI to start showing them!
|
||||
Promise.all(minimalAssetPromises)
|
||||
.then(() => {
|
||||
if (canceled) return;
|
||||
setLoadedLayers(layers);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (canceled) return;
|
||||
console.error("Error preloading outfit layers", e);
|
||||
setError(e);
|
||||
|
||||
// Cancel any remaining promises, if cancelable.
|
||||
imageAssetPromises.forEach((p) => p.cancel && p.cancel());
|
||||
movieAssetPromises.forEach((p) => p.cancel && p.cancel());
|
||||
});
|
||||
|
||||
// As the movie assets come in, check them for animations, to decide
|
||||
// whether to show the Play/Pause button.
|
||||
const checkHasAnimations = (asset) => {
|
||||
if (canceled) return;
|
||||
let assetHasAnimations;
|
||||
try {
|
||||
assetHasAnimations = getHasAnimationsForMovieAsset(asset);
|
||||
} catch (e) {
|
||||
console.error("Error testing layers for animations", e);
|
||||
setError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
setLayersHaveAnimations(
|
||||
(alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations
|
||||
);
|
||||
};
|
||||
movieAssetPromises.forEach((p) =>
|
||||
p.then(checkHasAnimations).catch((e) => {
|
||||
console.error(`Error preloading movie library ${p.libraryUrl}:`, e);
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [layers, hiResMode, preferArchive]);
|
||||
|
||||
return { loading, error, loadedLayers, layersHaveAnimations };
|
||||
}
|
||||
|
||||
// This cache is large because it's only storing booleans; mostly just capping
|
||||
// it to put *some* upper bound on memory growth.
|
||||
const HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE = new LRU(50);
|
||||
|
||||
function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
|
||||
// This operation can be pretty expensive! We store a cache to only do it
|
||||
// once per layer per session ish, instead of on each outfit change.
|
||||
const cachedHasAnimations =
|
||||
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl);
|
||||
if (cachedHasAnimations) {
|
||||
return cachedHasAnimations;
|
||||
}
|
||||
|
||||
const movieClip = buildMovieClip(library, libraryUrl);
|
||||
|
||||
// Some movie clips require you to tick to the first frame of the movie
|
||||
// before the children mount onto the stage. If we detect animations
|
||||
// without doing this, we'll incorrectly say no, because we see no children!
|
||||
// Example: http://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js
|
||||
movieClip.advance();
|
||||
|
||||
const movieClipHasAnimations = hasAnimations(movieClip);
|
||||
|
||||
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations);
|
||||
return movieClipHasAnimations;
|
||||
}
|
||||
|
||||
/**
|
||||
* FadeInOnLoad attaches an `onLoad` handler to its single child, and fades in
|
||||
* the container element once it triggers.
|
||||
*/
|
||||
function FadeInOnLoad({ children, ...props }) {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
const onLoad = React.useCallback(() => setIsLoaded(true), []);
|
||||
|
||||
const child = React.Children.only(children);
|
||||
const wrappedChild = React.cloneElement(child, { onLoad });
|
||||
|
||||
return (
|
||||
<Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}>
|
||||
{wrappedChild}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Polyfill Promise.any for older browsers: https://github.com/ungap/promise-any
|
||||
// NOTE: Normally I would've considered Promise.any within our support browser
|
||||
// range… but it's affected 25 users in the past two months, which is
|
||||
// surprisingly high. And the polyfill is small, so let's do it! (11/2021)
|
||||
Promise.any =
|
||||
Promise.any ||
|
||||
function ($) {
|
||||
return new Promise(function (D, E, A, L) {
|
||||
A = [];
|
||||
L = $.map(function ($, i) {
|
||||
return Promise.resolve($).then(D, function (O) {
|
||||
return ((A[i] = O), --L) || E({ errors: A });
|
||||
});
|
||||
}).length;
|
||||
});
|
||||
};
|
||||
|
||||
export default OutfitPreview;
|
21
app/javascript/wardrobe-2020/components/OutfitThumbnail.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { Box } from "@chakra-ui/react";
|
||||
|
||||
function OutfitThumbnail({ outfitId, updatedAt, ...props }) {
|
||||
const versionTimestamp = new Date(updatedAt).getTime();
|
||||
|
||||
// NOTE: It'd be more reliable for testing to use a relative path, but
|
||||
// generating these on dev is SO SLOW, that I'd rather just not.
|
||||
const thumbnailUrl150 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/150.png`;
|
||||
const thumbnailUrl300 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/300.png`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="img"
|
||||
src={thumbnailUrl150}
|
||||
srcSet={`${thumbnailUrl150} 1x, ${thumbnailUrl300} 2x`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OutfitThumbnail;
|
153
app/javascript/wardrobe-2020/components/PaginationToolbar.js
Normal file
|
@ -0,0 +1,153 @@
|
|||
import React from "react";
|
||||
import { Box, Button, Flex, Select } from "@chakra-ui/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
function PaginationToolbar({
|
||||
isLoading,
|
||||
numTotalPages,
|
||||
currentPageNumber,
|
||||
goToPageNumber,
|
||||
buildPageUrl,
|
||||
size = "md",
|
||||
...props
|
||||
}) {
|
||||
const pagesAreLoaded = currentPageNumber != null && numTotalPages != null;
|
||||
const hasPrevPage = pagesAreLoaded && currentPageNumber > 1;
|
||||
const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages;
|
||||
|
||||
const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null;
|
||||
const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null;
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between" {...props}>
|
||||
<LinkOrButton
|
||||
href={prevPageUrl}
|
||||
onClick={
|
||||
prevPageUrl == null
|
||||
? () => goToPageNumber(currentPageNumber - 1)
|
||||
: undefined
|
||||
}
|
||||
_disabled={{
|
||||
cursor: isLoading ? "wait" : "not-allowed",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
isDisabled={!hasPrevPage}
|
||||
size={size}
|
||||
>
|
||||
← Prev
|
||||
</LinkOrButton>
|
||||
{numTotalPages > 0 && (
|
||||
<Flex align="center" paddingX="4" fontSize={size}>
|
||||
<Box flex="0 0 auto">Page</Box>
|
||||
<Box width="1" />
|
||||
<PageNumberSelect
|
||||
currentPageNumber={currentPageNumber}
|
||||
numTotalPages={numTotalPages}
|
||||
onChange={goToPageNumber}
|
||||
marginBottom="-2px"
|
||||
size={size}
|
||||
/>
|
||||
<Box width="1" />
|
||||
<Box flex="0 0 auto">of {numTotalPages}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
<LinkOrButton
|
||||
href={nextPageUrl}
|
||||
onClick={
|
||||
nextPageUrl == null
|
||||
? () => goToPageNumber(currentPageNumber + 1)
|
||||
: undefined
|
||||
}
|
||||
_disabled={{
|
||||
cursor: isLoading ? "wait" : "not-allowed",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
isDisabled={!hasNextPage}
|
||||
size={size}
|
||||
>
|
||||
Next →
|
||||
</LinkOrButton>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRouterPagination(totalCount, numPerPage) {
|
||||
const { query, push: pushHistory } = useRouter();
|
||||
|
||||
const currentOffset = parseInt(query.offset) || 0;
|
||||
|
||||
const currentPageIndex = Math.floor(currentOffset / numPerPage);
|
||||
const currentPageNumber = currentPageIndex + 1;
|
||||
const numTotalPages = totalCount ? Math.ceil(totalCount / numPerPage) : null;
|
||||
|
||||
const buildPageUrl = React.useCallback(
|
||||
(newPageNumber) => {
|
||||
const newParams = new URLSearchParams(query);
|
||||
const newPageIndex = newPageNumber - 1;
|
||||
const newOffset = newPageIndex * numPerPage;
|
||||
newParams.set("offset", newOffset);
|
||||
return "?" + newParams.toString();
|
||||
},
|
||||
[query, numPerPage]
|
||||
);
|
||||
|
||||
const goToPageNumber = React.useCallback(
|
||||
(newPageNumber) => {
|
||||
pushHistory(buildPageUrl(newPageNumber));
|
||||
},
|
||||
[buildPageUrl, pushHistory]
|
||||
);
|
||||
|
||||
return {
|
||||
numTotalPages,
|
||||
currentPageNumber,
|
||||
goToPageNumber,
|
||||
buildPageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function LinkOrButton({ href, ...props }) {
|
||||
if (href != null) {
|
||||
return (
|
||||
<Link href={href} passHref>
|
||||
<Button as="a" {...props} />
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function PageNumberSelect({
|
||||
currentPageNumber,
|
||||
numTotalPages,
|
||||
onChange,
|
||||
...props
|
||||
}) {
|
||||
const allPageNumbers = Array.from({ length: numTotalPages }, (_, i) => i + 1);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(e) => onChange(Number(e.target.value)),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentPageNumber}
|
||||
onChange={handleChange}
|
||||
width="7ch"
|
||||
variant="flushed"
|
||||
textAlign="center"
|
||||
{...props}
|
||||
>
|
||||
{allPageNumbers.map((pageNumber) => (
|
||||
<option key={pageNumber} value={pageNumber}>
|
||||
{pageNumber}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaginationToolbar;
|
507
app/javascript/wardrobe-2020/components/SpeciesColorPicker.js
Normal file
|
@ -0,0 +1,507 @@
|
|||
import React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { Box, Flex, Select, Text, useColorModeValue } from "@chakra-ui/react";
|
||||
|
||||
import { Delay, logAndCapture, useFetch } from "../util";
|
||||
|
||||
/**
|
||||
* SpeciesColorPicker lets the user pick the species/color of their pet.
|
||||
*
|
||||
* It preloads all species, colors, and valid species/color pairs; and then
|
||||
* ensures that the outfit is always in a valid state.
|
||||
*
|
||||
* NOTE: This component is memoized with React.memo. It's not the cheapest to
|
||||
* re-render on every outfit change. This contributes to
|
||||
* wearing/unwearing items being noticeably slower on lower-power
|
||||
* devices.
|
||||
*/
|
||||
function SpeciesColorPicker({
|
||||
speciesId,
|
||||
colorId,
|
||||
idealPose,
|
||||
showPlaceholders = false,
|
||||
colorPlaceholderText = "",
|
||||
speciesPlaceholderText = "",
|
||||
stateMustAlwaysBeValid = false,
|
||||
isDisabled = false,
|
||||
speciesIsDisabled = false,
|
||||
size = "md",
|
||||
speciesTestId = null,
|
||||
colorTestId = null,
|
||||
onChange,
|
||||
}) {
|
||||
const { loading: loadingMeta, error: errorMeta, data: meta } = useQuery(gql`
|
||||
query SpeciesColorPicker {
|
||||
allSpecies {
|
||||
id
|
||||
name
|
||||
standardBodyId # Used for keeping items on during standard color changes
|
||||
}
|
||||
|
||||
allColors {
|
||||
id
|
||||
name
|
||||
isStandard # Used for keeping items on during standard color changes
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const {
|
||||
loading: loadingValids,
|
||||
error: errorValids,
|
||||
valids,
|
||||
} = useAllValidPetPoses();
|
||||
|
||||
const allColors = (meta && [...meta.allColors]) || [];
|
||||
allColors.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const allSpecies = (meta && [...meta.allSpecies]) || [];
|
||||
allSpecies.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const textColor = useColorModeValue("inherit", "green.50");
|
||||
|
||||
if ((loadingMeta || loadingValids) && !showPlaceholders) {
|
||||
return (
|
||||
<Delay ms={5000}>
|
||||
<Text color={textColor} textShadow="md">
|
||||
Loading species/color data…
|
||||
</Text>
|
||||
</Delay>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMeta || errorValids) {
|
||||
return (
|
||||
<Text color={textColor} textShadow="md">
|
||||
Error loading species/color data.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// When the color changes, check if the new pair is valid, and update the
|
||||
// outfit if so!
|
||||
const onChangeColor = (e) => {
|
||||
const newColorId = e.target.value;
|
||||
console.debug(`SpeciesColorPicker.onChangeColor`, {
|
||||
// for IMPRESS-2020-1H
|
||||
speciesId,
|
||||
colorId,
|
||||
newColorId,
|
||||
});
|
||||
|
||||
// Ignore switching to the placeholder option. It shouldn't generally be
|
||||
// doable once real options exist, and it doesn't represent a valid or
|
||||
// meaningful transition in the case where it could happen.
|
||||
if (newColorId === "SpeciesColorPicker-color-loading-placeholder") {
|
||||
return;
|
||||
}
|
||||
|
||||
const species = allSpecies.find((s) => s.id === speciesId);
|
||||
const newColor = allColors.find((c) => c.id === newColorId);
|
||||
const validPoses = getValidPoses(valids, speciesId, newColorId);
|
||||
const isValid = validPoses.size > 0;
|
||||
if (stateMustAlwaysBeValid && !isValid) {
|
||||
// NOTE: This shouldn't happen, because we should hide invalid colors.
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
|
||||
`with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` +
|
||||
`colorId=${newColorId}.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const closestPose = getClosestPose(validPoses, idealPose);
|
||||
onChange(species, newColor, isValid, closestPose);
|
||||
};
|
||||
|
||||
// When the species changes, check if the new pair is valid, and update the
|
||||
// outfit if so!
|
||||
const onChangeSpecies = (e) => {
|
||||
const newSpeciesId = e.target.value;
|
||||
console.debug(`SpeciesColorPicker.onChangeSpecies`, {
|
||||
// for IMPRESS-2020-1H
|
||||
speciesId,
|
||||
newSpeciesId,
|
||||
colorId,
|
||||
});
|
||||
|
||||
// Ignore switching to the placeholder option. It shouldn't generally be
|
||||
// doable once real options exist, and it doesn't represent a valid or
|
||||
// meaningful transition in the case where it could happen.
|
||||
if (newSpeciesId === "SpeciesColorPicker-species-loading-placeholder") {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
|
||||
if (!newSpecies) {
|
||||
// Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species
|
||||
// ends up coming out of `onChange`!
|
||||
console.debug({ allSpecies, loadingMeta, errorMeta, meta });
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion error in SpeciesColorPicker: species not found. ` +
|
||||
`speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` +
|
||||
`colorId=${colorId}.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let color = allColors.find((c) => c.id === colorId);
|
||||
let validPoses = getValidPoses(valids, newSpeciesId, colorId);
|
||||
let isValid = validPoses.size > 0;
|
||||
|
||||
if (stateMustAlwaysBeValid && !isValid) {
|
||||
// If `stateMustAlwaysBeValid`, but the user switches to a species that
|
||||
// doesn't support this color, that's okay and normal! We'll just switch
|
||||
// to one of the four basic colors instead.
|
||||
const basicColorId = ["8", "34", "61", "84"][
|
||||
Math.floor(Math.random() * 4)
|
||||
];
|
||||
const basicColor = allColors.find((c) => c.id === basicColorId);
|
||||
color = basicColor;
|
||||
validPoses = getValidPoses(valids, newSpeciesId, color.id);
|
||||
isValid = true;
|
||||
}
|
||||
|
||||
const closestPose = getClosestPose(validPoses, idealPose);
|
||||
onChange(newSpecies, color, isValid, closestPose);
|
||||
};
|
||||
|
||||
// In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this
|
||||
// species, so the user can't switch. (We handle species differently: if you
|
||||
// switch to a new species and the color is invalid, we reset the color. We
|
||||
// think this matches users' mental hierarchy of species -> color: showing
|
||||
// supported colors for a species makes sense, but the other way around feels
|
||||
// confusing and restrictive.)
|
||||
//
|
||||
// Also, if a color is provided that wouldn't normally be visible, we still
|
||||
// show it. This can happen when someone models a new species/color combo for
|
||||
// the first time - the boxes will still be red as if it were invalid, but
|
||||
// this still smooths out the experience a lot.
|
||||
let visibleColors = allColors;
|
||||
if (stateMustAlwaysBeValid && valids && speciesId) {
|
||||
visibleColors = visibleColors.filter(
|
||||
(c) => getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="row">
|
||||
<SpeciesColorSelect
|
||||
aria-label="Pet color"
|
||||
value={colorId || "SpeciesColorPicker-color-loading-placeholder"}
|
||||
// We also wait for the valid pairs before enabling, so users can't
|
||||
// trigger change events we're not ready for. Also, if the caller
|
||||
// hasn't provided species and color yet, assume it's still loading.
|
||||
isLoading={
|
||||
allColors.length === 0 || loadingValids || !speciesId || !colorId
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
onChange={onChangeColor}
|
||||
size={size}
|
||||
valids={valids}
|
||||
speciesId={speciesId}
|
||||
colorId={colorId}
|
||||
data-test-id={colorTestId}
|
||||
>
|
||||
{
|
||||
// If the selected color isn't in the set we have here, show the
|
||||
// placeholder. (Can happen during loading, or if an invalid color ID
|
||||
// like null is intentionally provided while the real value loads.)
|
||||
!visibleColors.some((c) => c.id === colorId) && (
|
||||
<option value="SpeciesColorPicker-color-loading-placeholder">
|
||||
{colorPlaceholderText}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
{
|
||||
// A long name for sizing! Should appear below the placeholder, out
|
||||
// of view.
|
||||
visibleColors.length === 0 && <option>Dimensional</option>
|
||||
}
|
||||
{visibleColors.map((color) => (
|
||||
<option key={color.id} value={color.id}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
</SpeciesColorSelect>
|
||||
<Box width={size === "sm" ? 2 : 4} />
|
||||
<SpeciesColorSelect
|
||||
aria-label="Pet species"
|
||||
value={speciesId || "SpeciesColorPicker-species-loading-placeholder"}
|
||||
// We also wait for the valid pairs before enabling, so users can't
|
||||
// trigger change events we're not ready for. Also, if the caller
|
||||
// hasn't provided species and color yet, assume it's still loading.
|
||||
isLoading={
|
||||
allColors.length === 0 || loadingValids || !speciesId || !colorId
|
||||
}
|
||||
isDisabled={isDisabled || speciesIsDisabled}
|
||||
// Don't fade out in the speciesIsDisabled case; it's more like a
|
||||
// read-only state.
|
||||
_disabled={
|
||||
speciesIsDisabled
|
||||
? { opacity: "1", cursor: "not-allowed" }
|
||||
: undefined
|
||||
}
|
||||
onChange={onChangeSpecies}
|
||||
size={size}
|
||||
valids={valids}
|
||||
speciesId={speciesId}
|
||||
colorId={colorId}
|
||||
data-test-id={speciesTestId}
|
||||
>
|
||||
{
|
||||
// If the selected species isn't in the set we have here, show the
|
||||
// placeholder. (Can happen during loading, or if an invalid species
|
||||
// ID like null is intentionally provided while the real value
|
||||
// loads.)
|
||||
!allSpecies.some((s) => s.id === speciesId) && (
|
||||
<option value="SpeciesColorPicker-species-loading-placeholder">
|
||||
{speciesPlaceholderText}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
{
|
||||
// A long name for sizing! Should appear below the placeholder, out
|
||||
// of view.
|
||||
allSpecies.length === 0 && <option>Tuskaninny</option>
|
||||
}
|
||||
{allSpecies.map((species) => (
|
||||
<option key={species.id} value={species.id}>
|
||||
{species.name}
|
||||
</option>
|
||||
))}
|
||||
</SpeciesColorSelect>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const SpeciesColorSelect = ({
|
||||
size,
|
||||
valids,
|
||||
speciesId,
|
||||
colorId,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
...props
|
||||
}) => {
|
||||
const backgroundColor = useColorModeValue("white", "gray.600");
|
||||
const borderColor = useColorModeValue("green.600", "transparent");
|
||||
const textColor = useColorModeValue("inherit", "green.50");
|
||||
|
||||
const loadingProps = isLoading
|
||||
? {
|
||||
// Visually the disabled state is the same as the normal state, but
|
||||
// with a wait cursor. We don't expect this to take long, and the flash
|
||||
// of content is rough!
|
||||
opacity: "1 !important",
|
||||
cursor: "wait !important",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Select
|
||||
backgroundColor={backgroundColor}
|
||||
color={textColor}
|
||||
size={size}
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="md"
|
||||
width="auto"
|
||||
transition="all 0.25s"
|
||||
_hover={{
|
||||
borderColor: "green.400",
|
||||
}}
|
||||
isInvalid={
|
||||
valids &&
|
||||
speciesId &&
|
||||
colorId &&
|
||||
!pairIsValid(valids, speciesId, colorId)
|
||||
}
|
||||
isDisabled={isDisabled || isLoading}
|
||||
errorBorderColor="red.300"
|
||||
{...props}
|
||||
{...loadingProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
let cachedResponseForAllValidPetPoses = null;
|
||||
|
||||
/**
|
||||
* useAllValidPoses fetches the valid pet poses, as a `valids` object ready to
|
||||
* pass into the various validity-checker utility functions!
|
||||
*
|
||||
* In addition to the network caching, we globally cache this response in the
|
||||
* client code as `cachedResponseForAllValidPetPoses`. This helps prevent extra
|
||||
* re-renders when client-side navigating between pages, similar to how cached
|
||||
* data from GraphQL serves on the first render, without a loading state.
|
||||
*/
|
||||
export function useAllValidPetPoses() {
|
||||
const networkResponse = useFetch("/api/validPetPoses", {
|
||||
responseType: "arrayBuffer",
|
||||
// If we already have globally-cached valids, skip the request.
|
||||
skip: cachedResponseForAllValidPetPoses != null,
|
||||
});
|
||||
|
||||
// Use the globally-cached response if we have one, or await the network
|
||||
// response if not.
|
||||
const response = cachedResponseForAllValidPetPoses || networkResponse;
|
||||
const { loading, error, data: validsBuffer } = response;
|
||||
|
||||
const valids = React.useMemo(
|
||||
() => validsBuffer && new DataView(validsBuffer),
|
||||
[validsBuffer]
|
||||
);
|
||||
|
||||
// Once a network response comes in, save it as the globally-cached response.
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
networkResponse &&
|
||||
!networkResponse.loading &&
|
||||
!cachedResponseForAllValidPetPoses
|
||||
) {
|
||||
cachedResponseForAllValidPetPoses = networkResponse;
|
||||
}
|
||||
}, [networkResponse]);
|
||||
|
||||
return { loading, error, valids };
|
||||
}
|
||||
|
||||
function getPairByte(valids, speciesId, colorId) {
|
||||
// Reading a bit table, owo!
|
||||
const speciesIndex = speciesId - 1;
|
||||
const colorIndex = colorId - 1;
|
||||
const numColors = valids.getUint8(1);
|
||||
const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
|
||||
try {
|
||||
return valids.getUint8(pairByteIndex);
|
||||
} catch (e) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function pairIsValid(valids, speciesId, colorId) {
|
||||
return getPairByte(valids, speciesId, colorId) !== 0;
|
||||
}
|
||||
|
||||
export function getValidPoses(valids, speciesId, colorId) {
|
||||
const pairByte = getPairByte(valids, speciesId, colorId);
|
||||
|
||||
const validPoses = new Set();
|
||||
if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC");
|
||||
if (pairByte & 0b00000010) validPoses.add("SAD_MASC");
|
||||
if (pairByte & 0b00000100) validPoses.add("SICK_MASC");
|
||||
if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM");
|
||||
if (pairByte & 0b00010000) validPoses.add("SAD_FEM");
|
||||
if (pairByte & 0b00100000) validPoses.add("SICK_FEM");
|
||||
if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
|
||||
if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
|
||||
|
||||
return validPoses;
|
||||
}
|
||||
|
||||
export function getClosestPose(validPoses, idealPose) {
|
||||
return closestPosesInOrder[idealPose].find((p) => validPoses.has(p)) || null;
|
||||
}
|
||||
|
||||
// For each pose, in what order do we prefer to match other poses?
|
||||
//
|
||||
// The principles of this ordering are:
|
||||
// - Happy/sad matters more than gender presentation.
|
||||
// - "Sick" is an unpopular emotion, and it's better to change gender
|
||||
// presentation and stay happy/sad than to become sick.
|
||||
// - Sad is a better fallback for sick than happy.
|
||||
// - Unconverted vs converted is the biggest possible difference.
|
||||
// - Unknown is the pose of last resort - even coming from another unknown.
|
||||
const closestPosesInOrder = {
|
||||
HAPPY_MASC: [
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
HAPPY_FEM: [
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SAD_MASC: [
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SAD_FEM: [
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SICK_MASC: [
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SICK_FEM: [
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
UNCONVERTED: [
|
||||
"UNCONVERTED",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNKNOWN",
|
||||
],
|
||||
UNKNOWN: [
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
};
|
||||
|
||||
export default React.memo(SpeciesColorPicker);
|
458
app/javascript/wardrobe-2020/components/SquareItemCard.js
Normal file
|
@ -0,0 +1,458 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
useToken,
|
||||
} from "@chakra-ui/react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { safeImageUrl, useCommonStyles } from "../util";
|
||||
import { CheckIcon, CloseIcon, StarIcon } from "@chakra-ui/icons";
|
||||
import usePreferArchive from "./usePreferArchive";
|
||||
|
||||
function SquareItemCard({
|
||||
item,
|
||||
showRemoveButton = false,
|
||||
onRemove = () => {},
|
||||
tradeMatchingMode = null,
|
||||
footer = null,
|
||||
...props
|
||||
}) {
|
||||
const outlineShadowValue = useToken("shadows", "outline");
|
||||
const mdRadiusValue = useToken("radii", "md");
|
||||
|
||||
const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200");
|
||||
const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200");
|
||||
const [
|
||||
tradeMatchOwnShadowColorValue,
|
||||
tradeMatchWantShadowColorValue,
|
||||
] = useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]);
|
||||
|
||||
// When this is a trade match, give it an extra colorful shadow highlight so
|
||||
// it stands out! (They'll generally be sorted to the front anyway, but this
|
||||
// make it easier to scan a user's lists page, and to learn how the sorting
|
||||
// works!)
|
||||
let tradeMatchShadow;
|
||||
if (tradeMatchingMode === "offering" && item.currentUserWantsThis) {
|
||||
tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`;
|
||||
} else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) {
|
||||
tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`;
|
||||
} else {
|
||||
tradeMatchShadow = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
// SquareItemCard renders in large lists of 1k+ items, so we get a big
|
||||
// perf win by using Emotion directly instead of Chakra's styled-system
|
||||
// Box.
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
`}
|
||||
role="group"
|
||||
>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<Box
|
||||
as="a"
|
||||
className={css`
|
||||
border-radius: ${mdRadiusValue};
|
||||
transition: all 0.2s;
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: ${outlineShadowValue};
|
||||
outline: none;
|
||||
}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<SquareItemCardLayout
|
||||
name={item.name}
|
||||
thumbnailImage={
|
||||
<ItemThumbnail
|
||||
item={item}
|
||||
tradeMatchingMode={tradeMatchingMode}
|
||||
/>
|
||||
}
|
||||
removeButton={
|
||||
showRemoveButton ? (
|
||||
<SquareItemCardRemoveButton onClick={onRemove} />
|
||||
) : null
|
||||
}
|
||||
boxShadow={tradeMatchShadow}
|
||||
footer={footer}
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
{showRemoveButton && (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
z-index: 1;
|
||||
|
||||
/* Apply some padding, so accidental clicks around the button
|
||||
* don't click the link instead, or vice-versa! */
|
||||
padding: 0.75em;
|
||||
|
||||
opacity: 0;
|
||||
[role="group"]:hover &,
|
||||
[role="group"]:focus-within &,
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<SquareItemCardRemoveButton onClick={onRemove} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function SquareItemCardLayout({
|
||||
name,
|
||||
thumbnailImage,
|
||||
footer,
|
||||
minHeightNumLines = 2,
|
||||
boxShadow = null,
|
||||
}) {
|
||||
const { brightBackground } = useCommonStyles();
|
||||
const brightBackgroundValue = useToken("colors", brightBackground);
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
// SquareItemCard renders in large lists of 1k+ items, so we get a big perf
|
||||
// win by using Emotion directly instead of Chakra's styled-system Box.
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
box-shadow: ${boxShadow || theme.shadows.md};
|
||||
border-radius: ${theme.radii.md};
|
||||
padding: ${theme.space["3"]};
|
||||
width: calc(80px + 2em);
|
||||
background: ${brightBackgroundValue};
|
||||
`}
|
||||
>
|
||||
{thumbnailImage}
|
||||
<div
|
||||
className={css`
|
||||
margin-top: ${theme.space["1"]};
|
||||
font-size: ${theme.fontSizes.sm};
|
||||
/* Set min height to match a 2-line item name, so the cards
|
||||
* in a row aren't toooo differently sized... */
|
||||
min-height: ${minHeightNumLines * 1.5 + "em"};
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
`}
|
||||
// HACK: Emotion turns this into -webkit-display: -webkit-box?
|
||||
style={{ display: "-webkit-box" }}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{footer && (
|
||||
<Box marginTop="2" width="100%">
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemThumbnail({ item, tradeMatchingMode }) {
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray";
|
||||
|
||||
const thumbnailShadowColor = useColorModeValue(
|
||||
`${kindColorScheme}.200`,
|
||||
`${kindColorScheme}.600`
|
||||
);
|
||||
const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor);
|
||||
const mdRadiusValue = useToken("radii", "md");
|
||||
|
||||
// Normally, we just show the owns/wants badges depending on whether the
|
||||
// current user owns/wants it. But, in a trade list, we use trade-matching
|
||||
// mode instead: only show the badge if it represents a viable trade, and add
|
||||
// some extra flair to it, too!
|
||||
let showOwnsBadge;
|
||||
let showWantsBadge;
|
||||
let showTradeMatchFlair;
|
||||
if (tradeMatchingMode == null) {
|
||||
showOwnsBadge = item.currentUserOwnsThis;
|
||||
showWantsBadge = item.currentUserWantsThis;
|
||||
showTradeMatchFlair = false;
|
||||
} else if (tradeMatchingMode === "offering") {
|
||||
showOwnsBadge = false;
|
||||
showWantsBadge = item.currentUserWantsThis;
|
||||
showTradeMatchFlair = true;
|
||||
} else if (tradeMatchingMode === "seeking") {
|
||||
showOwnsBadge = item.currentUserOwnsThis;
|
||||
showWantsBadge = false;
|
||||
showTradeMatchFlair = true;
|
||||
} else if (tradeMatchingMode === "hide-all") {
|
||||
showOwnsBadge = false;
|
||||
showWantsBadge = false;
|
||||
showTradeMatchFlair = false;
|
||||
} else {
|
||||
throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
|
||||
alt={`Thumbnail art for ${item.name}`}
|
||||
width={80}
|
||||
height={80}
|
||||
className={css`
|
||||
border-radius: ${mdRadiusValue};
|
||||
box-shadow: 0 0 4px ${thumbnailShadowColorValue};
|
||||
|
||||
/* Don't let alt text flash in while loading */
|
||||
&:-moz-loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`}
|
||||
>
|
||||
{showOwnsBadge && (
|
||||
<ItemOwnsWantsBadge
|
||||
colorScheme="green"
|
||||
label={
|
||||
showTradeMatchFlair
|
||||
? "You own this, and they want it!"
|
||||
: "You own this"
|
||||
}
|
||||
>
|
||||
<CheckIcon />
|
||||
{showTradeMatchFlair && (
|
||||
<div
|
||||
className={css`
|
||||
margin-left: 0.25em;
|
||||
margin-right: 0.125rem;
|
||||
`}
|
||||
>
|
||||
Match
|
||||
</div>
|
||||
)}
|
||||
</ItemOwnsWantsBadge>
|
||||
)}
|
||||
{showWantsBadge && (
|
||||
<ItemOwnsWantsBadge
|
||||
colorScheme="blue"
|
||||
label={
|
||||
showTradeMatchFlair
|
||||
? "You want this, and they own it!"
|
||||
: "You want this"
|
||||
}
|
||||
>
|
||||
<StarIcon />
|
||||
{showTradeMatchFlair && (
|
||||
<div
|
||||
className={css`
|
||||
margin-left: 0.25em;
|
||||
margin-right: 0.125rem;
|
||||
`}
|
||||
>
|
||||
Match
|
||||
</div>
|
||||
)}
|
||||
</ItemOwnsWantsBadge>
|
||||
)}
|
||||
</div>
|
||||
{item.isNc != null && (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: -3px;
|
||||
`}
|
||||
>
|
||||
<ItemThumbnailKindBadge colorScheme={kindColorScheme}>
|
||||
{item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
|
||||
</ItemThumbnailKindBadge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemOwnsWantsBadge({ colorScheme, children, label }) {
|
||||
const badgeBackground = useColorModeValue(
|
||||
`${colorScheme}.100`,
|
||||
`${colorScheme}.500`
|
||||
);
|
||||
const badgeColor = useColorModeValue(
|
||||
`${colorScheme}.500`,
|
||||
`${colorScheme}.100`
|
||||
);
|
||||
|
||||
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
|
||||
badgeBackground,
|
||||
badgeColor,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={css`
|
||||
border-radius: 999px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 2px ${badgeBackgroundValue};
|
||||
/* Decrease the padding: I don't want to hit the edges, but I want
|
||||
* to be a circle when possible! */
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
/* Copied from Chakra <Badge> */
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
background: ${badgeBackgroundValue};
|
||||
color: ${badgeColorValue};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemThumbnailKindBadge({ colorScheme, children }) {
|
||||
const badgeBackground = useColorModeValue(
|
||||
`${colorScheme}.100`,
|
||||
`${colorScheme}.500`
|
||||
);
|
||||
const badgeColor = useColorModeValue(
|
||||
`${colorScheme}.500`,
|
||||
`${colorScheme}.100`
|
||||
);
|
||||
|
||||
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
|
||||
badgeBackground,
|
||||
badgeColor,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
/* Copied from Chakra <Badge> */
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 0.125rem;
|
||||
font-weight: 700;
|
||||
background: ${badgeBackgroundValue};
|
||||
color: ${badgeColorValue};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function SquareItemCardRemoveButton({ onClick }) {
|
||||
const backgroundColor = useColorModeValue("gray.200", "gray.500");
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
aria-label="Remove"
|
||||
title="Remove"
|
||||
icon={<CloseIcon />}
|
||||
size="xs"
|
||||
borderRadius="full"
|
||||
boxShadow="lg"
|
||||
backgroundColor={backgroundColor}
|
||||
onClick={onClick}
|
||||
_hover={{
|
||||
// Override night mode's fade-out on hover
|
||||
opacity: 1,
|
||||
transform: "scale(1.15, 1.15)",
|
||||
}}
|
||||
_focus={{
|
||||
transform: "scale(1.15, 1.15)",
|
||||
boxShadow: "outline",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SquareItemCardSkeleton({ minHeightNumLines, footer = null }) {
|
||||
return (
|
||||
<SquareItemCardLayout
|
||||
name={
|
||||
<>
|
||||
<Skeleton width="100%" height="1em" marginTop="2" />
|
||||
{minHeightNumLines >= 3 && (
|
||||
<Skeleton width="100%" height="1em" marginTop="2" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
thumbnailImage={<Skeleton width="80px" height="80px" />}
|
||||
minHeightNumLines={minHeightNumLines}
|
||||
footer={footer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SquareItemCard;
|
135
app/javascript/wardrobe-2020/components/getVisibleLayers.js
Normal file
|
@ -0,0 +1,135 @@
|
|||
import gql from "graphql-tag";
|
||||
|
||||
function getVisibleLayers(petAppearance, itemAppearances) {
|
||||
if (!petAppearance) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const validItemAppearances = itemAppearances.filter((a) => a);
|
||||
|
||||
const petLayers = petAppearance.layers.map((l) => ({ ...l, source: "pet" }));
|
||||
|
||||
const itemLayers = validItemAppearances
|
||||
.map((a) => a.layers)
|
||||
.flat()
|
||||
.map((l) => ({ ...l, source: "item" }));
|
||||
|
||||
let allLayers = [...petLayers, ...itemLayers];
|
||||
|
||||
const itemRestrictedZoneIds = new Set(
|
||||
validItemAppearances
|
||||
.map((a) => a.restrictedZones)
|
||||
.flat()
|
||||
.map((z) => z.id)
|
||||
);
|
||||
const petRestrictedZoneIds = new Set(
|
||||
petAppearance.restrictedZones.map((z) => z.id)
|
||||
);
|
||||
|
||||
const visibleLayers = allLayers.filter((layer) => {
|
||||
// When an item restricts a zone, it hides pet layers of the same zone.
|
||||
// We use this to e.g. make a hat hide a hair ruff.
|
||||
//
|
||||
// NOTE: Items' restricted layers also affect what items you can wear at
|
||||
// the same time. We don't enforce anything about that here, and
|
||||
// instead assume that the input by this point is valid!
|
||||
if (layer.source === "pet" && itemRestrictedZoneIds.has(layer.zone.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// When a pet appearance restricts a zone, or when the pet is Unconverted,
|
||||
// it makes body-specific items incompatible. We use this to disallow UCs
|
||||
// from wearing certain body-specific Biology Effects, Statics, etc, while
|
||||
// still allowing non-body-specific items in those zones! (I think this
|
||||
// happens for some Invisible pet stuff, too?)
|
||||
//
|
||||
// TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
||||
// should be doing this way earlier, to prevent the item from even
|
||||
// showing up even in search results!
|
||||
//
|
||||
// NOTE: This can result in both pet layers and items occupying the same
|
||||
// zone, like Static, so long as the item isn't body-specific! That's
|
||||
// correct, and the item layer should be on top! (Here, we implement
|
||||
// it by placing item layers second in the list, and rely on JS sort
|
||||
// stability, and *then* rely on the UI to respect that ordering when
|
||||
// rendering them by depth. Not great! 😅)
|
||||
//
|
||||
// NOTE: We used to also include the pet appearance's *occupied* zones in
|
||||
// this condition, not just the restricted zones, as a sensible
|
||||
// defensive default, even though we weren't aware of any relevant
|
||||
// items. But now we know that actually the "Bruce Brucey B Mouth"
|
||||
// occupies the real Mouth zone, and still should be visible and
|
||||
// above pet layers! So, we now only check *restricted* zones.
|
||||
//
|
||||
// NOTE: UCs used to implement their restrictions by listing specific
|
||||
// zones, but it seems that the logic has changed to just be about
|
||||
// UC-ness and body-specific-ness, and not necessarily involve the
|
||||
// set of restricted zones at all. (This matters because e.g. UCs
|
||||
// shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
|
||||
// don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
|
||||
// zone restriction case running too, because I don't think it
|
||||
// _hurts_ anything, and I'm not confident enough in this conclusion.
|
||||
//
|
||||
// TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
|
||||
// use zone restrictions?
|
||||
if (
|
||||
layer.source === "item" &&
|
||||
layer.bodyId !== "0" &&
|
||||
(petAppearance.pose === "UNCONVERTED" ||
|
||||
petRestrictedZoneIds.has(layer.zone.id))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A pet appearance can also restrict its own zones. The Wraith Uni is an
|
||||
// interesting example: it has a horn, but its zone restrictions hide it!
|
||||
if (layer.source === "pet" && petRestrictedZoneIds.has(layer.zone.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth);
|
||||
|
||||
return visibleLayers;
|
||||
}
|
||||
|
||||
// TODO: The web client could save bandwidth by applying @client to the `depth`
|
||||
// field, because it already has zone depths cached.
|
||||
export const itemAppearanceFragmentForGetVisibleLayers = gql`
|
||||
fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
|
||||
id
|
||||
layers {
|
||||
id
|
||||
bodyId
|
||||
zone {
|
||||
id
|
||||
depth
|
||||
}
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// TODO: The web client could save bandwidth by applying @client to the `depth`
|
||||
// field, because it already has zone depths cached.
|
||||
export const petAppearanceFragmentForGetVisibleLayers = gql`
|
||||
fragment PetAppearanceForGetVisibleLayers on PetAppearance {
|
||||
id
|
||||
pose
|
||||
layers {
|
||||
id
|
||||
zone {
|
||||
id
|
||||
depth
|
||||
}
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default getVisibleLayers;
|
237
app/javascript/wardrobe-2020/components/useCurrentUser.js
Normal file
|
@ -0,0 +1,237 @@
|
|||
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||
import { useAuth0 } from "@auth0/auth0-react";
|
||||
import { useLocalStorage } from "../util";
|
||||
|
||||
const NOT_LOGGED_IN_USER = {
|
||||
isLoading: false,
|
||||
isLoggedIn: false,
|
||||
id: null,
|
||||
username: null,
|
||||
};
|
||||
|
||||
function useCurrentUser() {
|
||||
const [authMode] = useAuthModeFeatureFlag();
|
||||
const currentUserViaAuth0 = useCurrentUserViaAuth0({
|
||||
isEnabled: authMode === "auth0",
|
||||
});
|
||||
const currentUserViaDb = useCurrentUserViaDb({
|
||||
isEnabled: authMode === "db",
|
||||
});
|
||||
|
||||
// In development, you can start the server with
|
||||
// `IMPRESS_LOG_IN_AS=12345 vc dev` to simulate logging in as user 12345.
|
||||
//
|
||||
// This flag shouldn't be present in prod anyway, but the dev check is an
|
||||
// extra safety precaution!
|
||||
//
|
||||
// NOTE: In package.json, we forward the flag to REACT_APP_IMPRESS_LOG_IN_AS,
|
||||
// because create-react-app only forwards flags with that prefix.
|
||||
if (
|
||||
process.env["NODE_ENV"] === "development" &&
|
||||
process.env["REACT_APP_IMPRESS_LOG_IN_AS"]
|
||||
) {
|
||||
const id = process.env["REACT_APP_IMPRESS_LOG_IN_AS"];
|
||||
return {
|
||||
isLoading: false,
|
||||
isLoggedIn: true,
|
||||
id,
|
||||
username: `<Simulated User ${id}>`,
|
||||
};
|
||||
}
|
||||
|
||||
if (authMode === "auth0") {
|
||||
return currentUserViaAuth0;
|
||||
} else if (authMode === "db") {
|
||||
return currentUserViaDb;
|
||||
} else {
|
||||
console.error(`Unexpected auth mode: ${JSON.stringify(authMode)}`);
|
||||
return NOT_LOGGED_IN_USER;
|
||||
}
|
||||
}
|
||||
|
||||
function useCurrentUserViaAuth0({ isEnabled }) {
|
||||
// NOTE: I don't think we can actually, by the rule of hooks, *not* ask for
|
||||
// Auth0 login state when `isEnabled` is false, because `useAuth0`
|
||||
// doesn't accept a similar parameter to disable itself. We'll just
|
||||
// accept the redundant network effort during rollout, then delete it
|
||||
// when we're done. (So, the param isn't actually doing a whole lot; I
|
||||
// mostly have it for consistency with `useCurrentUserViaDb`, to make
|
||||
// it clear where the real difference is.)
|
||||
const { isLoading, isAuthenticated, user } = useAuth0();
|
||||
|
||||
if (!isEnabled) {
|
||||
return NOT_LOGGED_IN_USER;
|
||||
} else if (isLoading) {
|
||||
return { ...NOT_LOGGED_IN_USER, isLoading: true };
|
||||
} else if (!isAuthenticated) {
|
||||
return NOT_LOGGED_IN_USER;
|
||||
} else {
|
||||
return {
|
||||
isLoading: false,
|
||||
isLoggedIn: true,
|
||||
...getUserInfoFromAuth0Data(user),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function useCurrentUserViaDb({ isEnabled }) {
|
||||
const { loading, data } = useQuery(
|
||||
gql`
|
||||
query useCurrentUser {
|
||||
currentUser {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
skip: !isEnabled,
|
||||
onError: (error) => {
|
||||
// On error, we don't report anything to the user, but we do keep a
|
||||
// record in the console. We figure that most errors are likely to be
|
||||
// solvable by retrying the login button and creating a new session,
|
||||
// which the user would do without an error prompt anyway; and if not,
|
||||
// they'll either get an error when they try, or they'll see their
|
||||
// login state continue to not work, which should be a clear hint that
|
||||
// something is wrong and they need to reach out.
|
||||
console.error("[useCurrentUser] Couldn't get current user:", error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!isEnabled) {
|
||||
return NOT_LOGGED_IN_USER;
|
||||
} else if (loading) {
|
||||
return { ...NOT_LOGGED_IN_USER, isLoading: true };
|
||||
} else if (data?.currentUser == null) {
|
||||
return NOT_LOGGED_IN_USER;
|
||||
} else {
|
||||
return {
|
||||
isLoading: false,
|
||||
isLoggedIn: true,
|
||||
id: data.currentUser.id,
|
||||
username: data.currentUser.username,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getUserInfoFromAuth0Data(user) {
|
||||
return {
|
||||
id: user.sub?.match(/^auth0\|impress-([0-9]+)$/)?.[1],
|
||||
username: user["https://oauth.impress-2020.openneo.net/username"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useLoginActions returns a `startLogin` function to start login with Auth0,
|
||||
* and a `logout` function to logout from whatever auth mode is in use.
|
||||
*
|
||||
* Note that `startLogin` is only supported with the Auth0 auto mode. In db
|
||||
* mode, you should open a `LoginModal` instead!
|
||||
*/
|
||||
export function useLogout() {
|
||||
const { logout: logoutWithAuth0 } = useAuth0();
|
||||
const [authMode] = useAuthModeFeatureFlag();
|
||||
|
||||
const [sendLogoutMutation, { loading, error }] = useMutation(
|
||||
gql`
|
||||
mutation useLogout_Logout {
|
||||
logout {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
update: (cache, { data }) => {
|
||||
// Evict the `currentUser` from the cache, which will force all queries
|
||||
// on the page that depend on it to update. (This includes the
|
||||
// GlobalHeader that shows who you're logged in as!)
|
||||
//
|
||||
// We also evict the user themself, to force-update things that we're
|
||||
// allowed to see about this user (e.g. private lists).
|
||||
//
|
||||
// I don't do any optimistic UI here, because auth is complex enough
|
||||
// that I'd rather only show logout success after validating it through
|
||||
// an actual server round-trip.
|
||||
cache.evict({ id: "ROOT_QUERY", fieldName: "currentUser" });
|
||||
if (data.logout?.id != null) {
|
||||
cache.evict({ id: `User:${data.logout.id}` });
|
||||
}
|
||||
cache.gc();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const logoutWithDb = () => {
|
||||
sendLogoutMutation().catch((e) => {}); // handled in error UI
|
||||
};
|
||||
|
||||
if (authMode === "auth0") {
|
||||
return [logoutWithAuth0, { loading: false, error: null }];
|
||||
} else if (authMode === "db") {
|
||||
return [logoutWithDb, { loading, error }];
|
||||
} else {
|
||||
console.error(`unexpected auth mode: ${JSON.stringify(authMode)}`);
|
||||
return [() => {}, { loading: false, error: null }];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useAuthModeFeatureFlag returns "db" by default, but "auto" if you're falling
|
||||
* back to the old auth0-backed login mode.
|
||||
*
|
||||
* To set this manually, click "Better login system" on the homepage in the
|
||||
* Coming Soon block, and switch the toggle.
|
||||
*/
|
||||
export function useAuthModeFeatureFlag() {
|
||||
// We'll probably add a like, experimental gradual rollout thing here too.
|
||||
// But for now we just check your device's local storage! (This is why we
|
||||
// default to `null` instead of "auth0", I want to be unambiguous that this
|
||||
// is the *absence* of a localStorage value, and not risk accidentally
|
||||
// setting this override value to auth0 on everyone's devices 😅)
|
||||
let [savedValue, setSavedValue] = useLocalStorage(
|
||||
"DTIAuthModeFeatureFlag",
|
||||
null
|
||||
);
|
||||
|
||||
if (!["auth0", "db", null].includes(savedValue)) {
|
||||
console.warn(
|
||||
`Unexpected DTIAuthModeFeatureFlag value: %o. Ignoring.`,
|
||||
savedValue
|
||||
);
|
||||
savedValue = null;
|
||||
}
|
||||
|
||||
const value = savedValue || "db";
|
||||
|
||||
return [value, setSavedValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* getAuthModeFeatureFlag returns the authMode at the time it's called.
|
||||
* It's generally preferable to use `useAuthModeFeatureFlag` in a React
|
||||
* setting, but we use this instead for Apollo stuff!
|
||||
*/
|
||||
export function getAuthModeFeatureFlag() {
|
||||
const savedValueString = localStorage.getItem("DTIAuthModeFeatureFlag");
|
||||
|
||||
let savedValue;
|
||||
try {
|
||||
savedValue = JSON.parse(savedValueString);
|
||||
} catch (error) {
|
||||
console.warn(`DTIAuthModeFeatureFlag was not valid JSON. Ignoring.`);
|
||||
savedValue = null;
|
||||
}
|
||||
|
||||
if (!["auth0", "db", null].includes(savedValue)) {
|
||||
console.warn(
|
||||
`Unexpected DTIAuthModeFeatureFlag value: %o. Ignoring.`,
|
||||
savedValue
|
||||
);
|
||||
savedValue = null;
|
||||
}
|
||||
|
||||
return savedValue || "db";
|
||||
}
|
||||
|
||||
export default useCurrentUser;
|
194
app/javascript/wardrobe-2020/components/useOutfitAppearance.js
Normal file
|
@ -0,0 +1,194 @@
|
|||
import React from "react";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import getVisibleLayers, {
|
||||
itemAppearanceFragmentForGetVisibleLayers,
|
||||
petAppearanceFragmentForGetVisibleLayers,
|
||||
} from "../components/getVisibleLayers";
|
||||
|
||||
/**
|
||||
* useOutfitAppearance downloads the outfit's appearance data, and returns
|
||||
* visibleLayers for rendering.
|
||||
*/
|
||||
export default function useOutfitAppearance(outfitState) {
|
||||
const { wornItemIds, speciesId, colorId, pose, appearanceId } = outfitState;
|
||||
|
||||
// We split this query out from the other one, so that we can HTTP cache it.
|
||||
//
|
||||
// While Apollo gives us fine-grained caching during the page session, we can
|
||||
// only HTTP a full query at a time.
|
||||
//
|
||||
// This is a minor optimization with respect to keeping the user's cache
|
||||
// populated with their favorite species/color combinations. Once we start
|
||||
// caching the items by body instead of species/color, this could make color
|
||||
// changes really snappy!
|
||||
//
|
||||
// The larger optimization is that this enables the CDN to edge-cache the
|
||||
// most popular species/color combinations, for very fast previews on the
|
||||
// HomePage. At time of writing, Vercel isn't actually edge-caching these, I
|
||||
// assume because our traffic isn't enough - so let's keep an eye on this!
|
||||
const {
|
||||
loading: loading1,
|
||||
error: error1,
|
||||
data: data1,
|
||||
} = useQuery(
|
||||
appearanceId == null
|
||||
? gql`
|
||||
query OutfitPetAppearance(
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$pose: Pose!
|
||||
) {
|
||||
petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: $pose
|
||||
) {
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
${petAppearanceFragment}
|
||||
`
|
||||
: gql`
|
||||
query OutfitPetAppearanceById($appearanceId: ID!) {
|
||||
petAppearance: petAppearanceById(id: $appearanceId) {
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
${petAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
},
|
||||
skip:
|
||||
speciesId == null ||
|
||||
colorId == null ||
|
||||
(pose == null && appearanceId == null),
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
loading: loading2,
|
||||
error: error2,
|
||||
data: data2,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query OutfitItemsAppearance(
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$wornItemIds: [ID!]!
|
||||
) {
|
||||
items(ids: $wornItemIds) {
|
||||
id
|
||||
name # HACK: This is for HTML5 detection UI in OutfitControls!
|
||||
appearance: appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||
...ItemAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
${itemAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
speciesId,
|
||||
colorId,
|
||||
wornItemIds,
|
||||
},
|
||||
skip: speciesId == null || colorId == null || wornItemIds.length === 0,
|
||||
}
|
||||
);
|
||||
|
||||
const petAppearance = data1?.petAppearance;
|
||||
const items = data2?.items;
|
||||
const itemAppearances = React.useMemo(
|
||||
() => (items || []).map((i) => i.appearance),
|
||||
[items]
|
||||
);
|
||||
const visibleLayers = React.useMemo(
|
||||
() => getVisibleLayers(petAppearance, itemAppearances),
|
||||
[petAppearance, itemAppearances]
|
||||
);
|
||||
|
||||
const bodyId = petAppearance?.bodyId;
|
||||
|
||||
return {
|
||||
loading: loading1 || loading2,
|
||||
error: error1 || error2,
|
||||
petAppearance,
|
||||
items: items || [],
|
||||
itemAppearances,
|
||||
visibleLayers,
|
||||
bodyId,
|
||||
};
|
||||
}
|
||||
|
||||
export const appearanceLayerFragment = gql`
|
||||
fragment AppearanceLayerForOutfitPreview on AppearanceLayer {
|
||||
id
|
||||
svgUrl
|
||||
canvasMovieLibraryUrl
|
||||
imageUrl: imageUrlV2(idealSize: SIZE_600)
|
||||
bodyId
|
||||
knownGlitches # For HTML5 & Known Glitches UI
|
||||
zone {
|
||||
id
|
||||
depth @client
|
||||
label @client
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const appearanceLayerFragmentForSupport = gql`
|
||||
fragment AppearanceLayerForSupport on AppearanceLayer {
|
||||
id
|
||||
remoteId # HACK: This is for Support tools, but other views don't need it
|
||||
swfUrl # HACK: This is for Support tools, but other views don't need it
|
||||
zone {
|
||||
id
|
||||
label @client # HACK: This is for Support tools, but other views don't need it
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const itemAppearanceFragment = gql`
|
||||
fragment ItemAppearanceForOutfitPreview on ItemAppearance {
|
||||
id
|
||||
layers {
|
||||
id
|
||||
...AppearanceLayerForOutfitPreview
|
||||
...AppearanceLayerForSupport # HACK: Most users don't need this!
|
||||
}
|
||||
...ItemAppearanceForGetVisibleLayers
|
||||
}
|
||||
|
||||
${appearanceLayerFragment}
|
||||
${appearanceLayerFragmentForSupport}
|
||||
${itemAppearanceFragmentForGetVisibleLayers}
|
||||
`;
|
||||
|
||||
export const petAppearanceFragment = gql`
|
||||
fragment PetAppearanceForOutfitPreview on PetAppearance {
|
||||
id
|
||||
bodyId
|
||||
pose # For Known Glitches UI
|
||||
isGlitched # For Known Glitches UI
|
||||
species {
|
||||
id # For Known Glitches UI
|
||||
}
|
||||
color {
|
||||
id # For Known Glitches UI
|
||||
}
|
||||
layers {
|
||||
id
|
||||
...AppearanceLayerForOutfitPreview
|
||||
}
|
||||
...PetAppearanceForGetVisibleLayers
|
||||
}
|
||||
|
||||
${appearanceLayerFragment}
|
||||
${petAppearanceFragmentForGetVisibleLayers}
|
||||
`;
|
22
app/javascript/wardrobe-2020/components/usePreferArchive.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useLocalStorage } from "../util";
|
||||
|
||||
/**
|
||||
* usePreferArchive helps the user choose to try using our archive before
|
||||
* using images.neopets.com, when images.neopets.com is being slow and bleh!
|
||||
*/
|
||||
function usePreferArchive() {
|
||||
const [preferArchiveSavedValue, setPreferArchive] = useLocalStorage(
|
||||
"DTIPreferArchive",
|
||||
null
|
||||
);
|
||||
|
||||
// Oct 13 2022: I might default this back to on again if the lag gets
|
||||
// miserable again, but it's okaaay right now? ish? Bad enough that I want to
|
||||
// offer this option, but decent enough that I don't want to turn it on by
|
||||
// default and break new items yet!
|
||||
const preferArchive = preferArchiveSavedValue ?? false;
|
||||
|
||||
return [preferArchive, setPreferArchive];
|
||||
}
|
||||
|
||||
export default usePreferArchive;
|
BIN
app/javascript/wardrobe-2020/images/error-grundo.png
Normal file
After Width: | Height: | Size: 82 KiB |
1
app/javascript/wardrobe-2020/images/twemoji/cry.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><path d="m36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18 0-9.94 8.06-18 18-18 9.941 0 18 8.06 18 18" fill="#ffcc4d"/><g fill="#664500"><ellipse cx="11.5" cy="17" rx="2.5" ry="3.5"/><ellipse cx="24.5" cy="17" rx="2.5" ry="3.5"/><path d="m5.999 13.5c-.208 0-.419-.065-.599-.2-.442-.331-.531-.958-.2-1.4 3.262-4.35 7.616-4.4 7.8-4.4.552 0 1 .448 1 1 0 .551-.445.998-.996 1-.155.002-3.568.086-6.204 3.6-.196.262-.497.4-.801.4zm24.002 0c-.305 0-.604-.138-.801-.4-2.641-3.521-6.061-3.599-6.206-3.6-.55-.006-.994-.456-.991-1.005.003-.551.447-.995.997-.995.184 0 4.537.05 7.8 4.4.332.442.242 1.069-.2 1.4-.18.135-.39.2-.599.2zm-6.516 14.879c-.011-.044-1.145-4.379-5.485-4.379s-5.474 4.335-5.485 4.379c-.053.213.044.431.232.544.188.112.433.086.596-.06.009-.008 1.013-.863 4.657-.863 3.59 0 4.617.83 4.656.863.095.09.219.137.344.137.084 0 .169-.021.246-.064.196-.112.294-.339.239-.557z"/></g><path d="m16 31c0 2.762-2.238 5-5 5s-5-2.238-5-5 4-10 5-10 5 7.238 5 10z" fill="#5dadec"/></svg>
|
After Width: | Height: | Size: 1 KiB |
1
app/javascript/wardrobe-2020/images/twemoji/fem.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFAC33" d="M14 0c-1.721 0-3.343.406-4.793 1.111C8.814 1.043 8.412 1 8 1 4.134 1 1 4.134 1 8v12h.018C1.201 26.467 6.489 31.656 13 31.656c6.511 0 11.799-5.189 11.982-11.656H25v-9c0-6.075-4.925-11-11-11z"/><path fill="#9268CA" d="M22 27H4c-2.209 0-4 1.791-4 4v5h26v-5c0-2.209-1.791-4-4-4z"/><path fill="#7450A8" d="M21 32h1v4h-1zM4 32h1v4H4z"/><path fill="#FFDC5D" d="M10 22v6c0 1.657 1.343 3 3 3s3-1.343 3-3v-6h-6z"/><path fill="#FFDC5D" d="M9 5s-.003 5.308-5 5.936V17c0 4.971 4.029 9 9 9s9-4.029 9-9v-5.019C10.89 11.605 9 5 9 5z"/><path fill="#DF1F32" d="M17 22H9s1 2 4 2 4-2 4-2z"/><path fill="#9268CA" d="M29 36h-7l1-17h6z"/><path fill="#F9CA55" d="M31.541 15.443c-.144-.693-.822-1.139-1.517-.997L27.35 15H25c-1.104 0-2 .896-2 2v2h5c1.079 0 1.953-.857 1.992-1.927l.355-.073H31c0-.074-.028-.144-.045-.216.444-.276.698-.799.586-1.341z"/><path fill="#FFDC5D" d="M36 16c0-.552-.447-1-1-1l-6 1h-5c-.553 0-1 .448-1 1v2h6l6-2s1-.447 1-1z"/><path fill="#C1694F" d="M14 19.5h-2c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h2c.276 0 .5.224.5.5s-.224.5-.5.5z"/><path fill="#662113" d="M9 16c-.552 0-1-.448-1-1v-1c0-.552.448-1 1-1s1 .448 1 1v1c0 .552-.448 1-1 1zm8 0c-.552 0-1-.448-1-1v-1c0-.552.448-1 1-1s1 .448 1 1v1c0 .552-.448 1-1 1z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
app/javascript/wardrobe-2020/images/twemoji/masc.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><path d="m22 27h-18c-2.209 0-4 1.791-4 4v5h26v-5c0-2.209-1.791-4-4-4z" fill="#4289c1"/><path d="m21 32h1v4h-1zm-17 0h1v4h-1z" fill="#2a6797"/><path d="m10 22v6c0 1.657 1.343 3 3 3s3-1.343 3-3v-6z" fill="#ffdc5d"/><path d="m29 36h-7l1-17h6z" fill="#4289c1"/><path d="m31.541 15.443c-.144-.693-.822-1.139-1.517-.997l-2.674.554h-2.35c-1.104 0-2 .896-2 2v2h5c1.079 0 1.953-.857 1.992-1.927l.355-.073h.653c0-.074-.028-.144-.045-.216.444-.276.698-.799.586-1.341z" fill="#f9ca55"/><path d="m36 16c0-.552-.447-1-1-1l-6 1h-5c-.553 0-1 .448-1 1v2h6l6-2s1-.447 1-1zm-32-10.062v11.062c0 4.971 4.029 9 9 9s9-4.029 9-9v-10.75z" fill="#ffdc5d"/><path d="m9 22h8s-1 2-4 2-4-2-4-2z" fill="#c1694f"/><path d="m9 16c-.552 0-1-.448-1-1v-1c0-.552.448-1 1-1s1 .448 1 1v1c0 .552-.448 1-1 1zm8 0c-.552 0-1-.448-1-1v-1c0-.552.448-1 1-1s1 .448 1 1v1c0 .552-.448 1-1 1z" fill="#662113"/><path d="m14 19.5h-2c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h2c.276 0 .5.224.5.5s-.224.5-.5.5z" fill="#c1694f"/><path d="m5.847 13.715c0 1.58-.801 2.861-1.788 2.861s-1.788-1.281-1.788-2.861.801-2.861 1.788-2.861 1.788 1.281 1.788 2.861zm17.882 0c0 1.58-.8 2.861-1.788 2.861s-1.788-1.281-1.788-2.861.8-2.861 1.788-2.861 1.788 1.281 1.788 2.861z" fill="#ffdc5d"/><path d="m13 .823c-7.019 0-10.139 4.684-10.139 8.588 0 3.903 1.343 4.986 1.56 3.903.78-3.903 3.12-5.101 3.12-5.101 4.68 3.904 3.9.781 3.9.781 4.679 4.684 2.34 0 2.34 0 1.56 1.562 6.239 1.562 6.239 1.562s.78 1.198 1.559 2.759c.78 1.562 1.56 0 1.56-3.903 0-3.905-3.9-8.589-10.139-8.589z" fill="#ffac33"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
1
app/javascript/wardrobe-2020/images/twemoji/question.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#CCD6DD" d="M17 27c-1.657 0-3-1.343-3-3v-4c0-1.657 1.343-3 3-3 .603-.006 6-1 6-5 0-2-2-4-5-4-2.441 0-4 2-4 3 0 1.657-1.343 3-3 3s-3-1.343-3-3c0-4.878 4.58-9 10-9 8 0 11 5.982 11 11 0 4.145-2.277 7.313-6.413 8.92-.9.351-1.79.587-2.587.747V24c0 1.657-1.343 3-3 3z"/><circle fill="#CCD6DD" cx="17" cy="32" r="3"/></svg>
|
After Width: | Height: | Size: 388 B |
1
app/javascript/wardrobe-2020/images/twemoji/sick.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><circle cx="18" cy="18" fill="#ffcc4d" r="18"/><g fill="#664500"><ellipse cx="12.5" cy="16.5" rx="2.5" ry="3.5"/><ellipse cx="23.5" cy="16.5" rx="2.5" ry="3.5"/><path d="m29 12c-5.554 0-7.802-4.367-7.895-4.553-.247-.494-.047-1.095.447-1.342.492-.247 1.092-.048 1.34.443.075.146 1.821 3.452 6.108 3.452.553 0 1 .448 1 1 0 .553-.447 1-1 1zm-22-.006c-.552 0-1-.448-1-1s.448-1 1-1c5.083 0 5.996-3.12 6.033-3.253.145-.528.69-.848 1.219-.709.53.139.851.673.718 1.205-.049.194-1.266 4.757-7.97 4.757z"/></g><path d="m35.968 17.068-4.005.813-16.187 10.508 7.118.963 11.685-7.211c.703-.431.994-.835 1.198-1.747s.191-3.326.191-3.326z" fill="#ffac33"/><path d="m23.485 29.379c-.011-.044-1.145-2.879-5.485-2.879s-5.474 2.835-5.485 2.879c-.053.213.044.431.232.544.188.112.433.086.596-.06.009-.008 1.013-.863 4.657-.863 3.59 0 4.617.83 4.656.863.095.09.219.137.344.137.084 0 .169-.021.246-.064.196-.112.294-.339.239-.557z" fill="#664500"/><path d="m35.474 15.729c-.829-1.104-2.397-1.328-3.501-.5l-17.206 12.908c.646-.242 1.51-.444 2.615-.522.256-.018 2.66-.627 2.66-.627l1.293 1.036s.911.367 1.197.539l12.444-9.335c1.103-.827 1.326-2.395.498-3.499z" fill="#ccd6dd"/><path d="m28.686 20.87c-.448-.596-.787-1.145-1.383-.698l-9.922 7.443c.256-.018.5-.042.783-.044 1.36-.009 2.4.195 3.17.452l7.588-5.692c.596-.447.211-.864-.236-1.461z" fill="#da2f47"/><path d="m18.599 25.228 1.234 1.598.741-.595-1.197-1.587zm2.223-1.661 1.235 1.598.741-.595-1.197-1.587zm2.184-1.619 1.234 1.598.741-.595-1.197-1.587zm2.181-1.66 1.235 1.597.741-.594-1.197-1.587zm2.238-1.641 1.235 1.598.74-.595-1.196-1.587zm2.14-1.618 1.235 1.598.74-.595-1.196-1.587z" fill="#67757f"/><path d="m22.531 28.563.805.522s-.197-.362-.526-.726z" fill="#452e04"/></svg>
|
After Width: | Height: | Size: 1.7 KiB |
1
app/javascript/wardrobe-2020/images/twemoji/smile.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><path fill="#664500" d="M28.457 17.797c-.06-.135-1.499-3.297-4.457-3.297-2.957 0-4.397 3.162-4.457 3.297-.092.207-.032.449.145.591.175.142.426.147.61.014.012-.009 1.262-.902 3.702-.902 2.426 0 3.674.881 3.702.901.088.066.194.099.298.099.11 0 .221-.037.312-.109.177-.142.238-.386.145-.594zm-12 0c-.06-.135-1.499-3.297-4.457-3.297-2.957 0-4.397 3.162-4.457 3.297-.092.207-.032.449.144.591.176.142.427.147.61.014.013-.009 1.262-.902 3.703-.902 2.426 0 3.674.881 3.702.901.088.066.194.099.298.099.11 0 .221-.037.312-.109.178-.142.237-.386.145-.594zM18 22c-3.623 0-6.027-.422-9-1-.679-.131-2 0-2 2 0 4 4.595 9 11 9 6.404 0 11-5 11-9 0-2-1.321-2.132-2-2-2.973.578-5.377 1-9 1z"/><path fill="#FFF" d="M9 23s3 1 9 1 9-1 9-1-2 4-9 4-9-4-9-4z"/></svg>
|
After Width: | Height: | Size: 920 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18S0 27.941 0 18 8.059 0 18 0s18 8.059 18 18"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#292F33" d="M1.24 11.018c.24.239 1.438.957 1.677 1.675.24.717.72 4.784 2.158 5.981 1.483 1.232 7.077.774 8.148.24 2.397-1.195 2.691-4.531 3.115-6.221.239-.957 1.677-.957 1.677-.957s1.438 0 1.678.956c.424 1.691.72 5.027 3.115 6.221 1.072.535 6.666.994 8.151-.238 1.436-1.197 1.915-5.264 2.155-5.982.238-.717 1.438-1.435 1.677-1.674.241-.239.241-1.196 0-1.436-.479-.478-6.134-.904-12.223-.239-1.215.133-1.677.478-4.554.478-2.875 0-3.339-.346-4.553-.478-6.085-.666-11.741-.24-12.221.238-.239.239-.239 1.197 0 1.436z"/><path fill="#664500" d="M27.335 23.629c-.178-.161-.444-.171-.635-.029-.039.029-3.922 2.9-8.7 2.9-4.766 0-8.662-2.871-8.7-2.9-.191-.142-.457-.13-.635.029-.177.16-.217.424-.094.628C8.7 24.472 11.788 29.5 18 29.5s9.301-5.028 9.429-5.243c.123-.205.084-.468-.094-.628z"/></svg>
|
After Width: | Height: | Size: 997 B |
3
app/javascript/wardrobe-2020/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import WardrobePage from "./WardrobePage";
|
||||
|
||||
export { WardrobePage };
|
526
app/javascript/wardrobe-2020/util.js
Normal file
|
@ -0,0 +1,526 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
Heading,
|
||||
Link,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
import loadableLibrary from "@loadable/component";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { WarningIcon } from "@chakra-ui/icons";
|
||||
import NextImage from "next/image";
|
||||
|
||||
import ErrorGrundoImg from "./images/error-grundo.png";
|
||||
|
||||
/**
|
||||
* Delay hides its content at first, then shows it after the given delay.
|
||||
*
|
||||
* This is useful for loading states: it can be disruptive to see a spinner or
|
||||
* skeleton element for only a brief flash, we'd rather just show them if
|
||||
* loading is genuinely taking a while!
|
||||
*
|
||||
* 300ms is a pretty good default: that's about when perception shifts from "it
|
||||
* wasn't instant" to "the process took time".
|
||||
* https://developers.google.com/web/fundamentals/performance/rail
|
||||
*/
|
||||
export function Delay({ children, ms = 300 }) {
|
||||
const [isVisible, setIsVisible] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = setTimeout(() => setIsVisible(true), ms);
|
||||
return () => clearTimeout(id);
|
||||
}, [ms, setIsVisible]);
|
||||
|
||||
return (
|
||||
<Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s">
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading1 is a large, page-title-ish heading, with our DTI-brand-y Delicious
|
||||
* font and some special typographical styles!
|
||||
*/
|
||||
export function Heading1({ children, ...props }) {
|
||||
return (
|
||||
<Heading
|
||||
as="h1"
|
||||
size="2xl"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="800"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading2 is a major subheading, with our DTI-brand-y Delicious font and some
|
||||
* special typographical styles!!
|
||||
*/
|
||||
export function Heading2({ children, ...props }) {
|
||||
return (
|
||||
<Heading
|
||||
as="h2"
|
||||
size="xl"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading2 is a minor subheading, with our DTI-brand-y Delicious font and some
|
||||
* special typographical styles!!
|
||||
*/
|
||||
export function Heading3({ children, ...props }) {
|
||||
return (
|
||||
<Heading
|
||||
as="h3"
|
||||
size="lg"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorMessage is a simple error message for simple errors!
|
||||
*/
|
||||
export function ErrorMessage({ children, ...props }) {
|
||||
return (
|
||||
<Box color="red.400" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCommonStyles() {
|
||||
return {
|
||||
brightBackground: useColorModeValue("white", "gray.700"),
|
||||
bodyBackground: useColorModeValue("gray.50", "gray.800"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* safeImageUrl returns an HTTPS-safe image URL for Neopets assets!
|
||||
*/
|
||||
export function safeImageUrl(
|
||||
urlString,
|
||||
{ crossOrigin = null, preferArchive = false } = {}
|
||||
) {
|
||||
if (urlString == null) {
|
||||
return urlString;
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(
|
||||
urlString,
|
||||
// A few item thumbnail images incorrectly start with "/". When that
|
||||
// happens, the correct URL is at images.neopets.com.
|
||||
//
|
||||
// So, we provide "http://images.neopets.com" as the base URL when
|
||||
// parsing. Most URLs are absolute and will ignore it, but relative URLs
|
||||
// will resolve relative to that base.
|
||||
"http://images.neopets.com"
|
||||
);
|
||||
} catch (e) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`
|
||||
)
|
||||
);
|
||||
return "https://impress-2020.openneo.net/__error__URL-was-not-parseable__";
|
||||
}
|
||||
|
||||
// Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our
|
||||
// proxy if we need CORS headers.
|
||||
if (
|
||||
url.origin === "http://images.neopets.com" ||
|
||||
url.origin === "https://images.neopets.com"
|
||||
) {
|
||||
url.protocol = "https:";
|
||||
if (preferArchive) {
|
||||
const archiveUrl = new URL(
|
||||
`/api/readFromArchive`,
|
||||
window.location.origin
|
||||
);
|
||||
archiveUrl.search = new URLSearchParams({ url: url.toString() });
|
||||
url = archiveUrl;
|
||||
} else if (crossOrigin) {
|
||||
url.host = "images.neopets-asset-proxy.openneo.net";
|
||||
}
|
||||
} else if (
|
||||
url.origin === "http://pets.neopets.com" ||
|
||||
url.origin === "https://pets.neopets.com"
|
||||
) {
|
||||
url.protocol = "https:";
|
||||
if (crossOrigin) {
|
||||
url.host = "pets.neopets-asset-proxy.openneo.net";
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && url.hostname !== "localhost") {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`safeImageUrl was provided an unsafe URL, but we don't know how to ` +
|
||||
`upgrade it to HTTPS: ${urlString}. Returning a placeholder.`
|
||||
)
|
||||
);
|
||||
return "https://impress-2020.openneo.net/__error__URL-was-not-HTTPS__";
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* useDebounce helps make a rapidly-changing value change less! It waits for a
|
||||
* pause in the incoming data before outputting the latest value.
|
||||
*
|
||||
* We use it in search: when the user types rapidly, we don't want to update
|
||||
* our query and send a new request every keystroke. We want to wait for it to
|
||||
* seem like they might be done, while still feeling responsive!
|
||||
*
|
||||
* Adapted from https://usehooks.com/useDebounce/
|
||||
*/
|
||||
export function useDebounce(
|
||||
value,
|
||||
delay,
|
||||
{ waitForFirstPause = false, initialValue = null, forceReset = null } = {}
|
||||
) {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(
|
||||
waitForFirstPause ? initialValue : value
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
// Update debounced value after delay
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
// This is how we prevent debounced value from updating if value is changed ...
|
||||
// .. within the delay period. Timeout gets cleared and restarted.
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay] // Only re-call effect if value or delay changes
|
||||
);
|
||||
|
||||
// The `forceReset` option helps us decide whether to set the value
|
||||
// immediately! We'll update it in an effect for consistency and clarity, but
|
||||
// also return it immediately rather than wait a tick.
|
||||
const shouldForceReset = forceReset && forceReset(debouncedValue, value);
|
||||
React.useEffect(() => {
|
||||
if (shouldForceReset) {
|
||||
setDebouncedValue(value);
|
||||
}
|
||||
}, [shouldForceReset, value]);
|
||||
|
||||
return shouldForceReset ? value : debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* useFetch uses `fetch` to fetch the given URL, and returns the request state.
|
||||
*
|
||||
* Our limited API is designed to match the `use-http` library!
|
||||
*/
|
||||
export function useFetch(url, { responseType, skip, ...fetchOptions }) {
|
||||
// Just trying to be clear about what you'll get back ^_^` If we want to
|
||||
// fetch non-binary data later, extend this and get something else from res!
|
||||
if (responseType !== "arrayBuffer") {
|
||||
throw new Error(`unsupported responseType ${responseType}`);
|
||||
}
|
||||
|
||||
const [response, setResponse] = React.useState({
|
||||
loading: skip ? false : true,
|
||||
error: null,
|
||||
data: null,
|
||||
});
|
||||
|
||||
// We expect this to be a simple object, so this helps us only re-send the
|
||||
// fetch when the options have actually changed, rather than e.g. a new copy
|
||||
// of an identical object!
|
||||
const fetchOptionsAsJson = JSON.stringify(fetchOptions);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (skip) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
|
||||
fetch(url, JSON.parse(fetchOptionsAsJson))
|
||||
.then(async (res) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
setResponse({ loading: false, error: null, data: arrayBuffer });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResponse({ loading: false, error, data: null });
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [skip, url, fetchOptionsAsJson]);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* useLocalStorage is like React.useState, but it persists the value in the
|
||||
* device's `localStorage`, so it comes back even after reloading the page.
|
||||
*
|
||||
* Adapted from https://usehooks.com/useLocalStorage/.
|
||||
*/
|
||||
let storageListeners = [];
|
||||
export function useLocalStorage(key, initialValue) {
|
||||
const loadValue = React.useCallback(() => {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return initialValue;
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
const [storedValue, setStoredValue] = React.useState(loadValue);
|
||||
|
||||
const setValue = React.useCallback(
|
||||
(value) => {
|
||||
try {
|
||||
setStoredValue(value);
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
storageListeners.forEach((l) => l());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
const reloadValue = React.useCallback(() => {
|
||||
setStoredValue(loadValue());
|
||||
}, [loadValue, setStoredValue]);
|
||||
|
||||
// Listen for changes elsewhere on the page, and update here too!
|
||||
React.useEffect(() => {
|
||||
storageListeners.push(reloadValue);
|
||||
return () => {
|
||||
storageListeners = storageListeners.filter((l) => l !== reloadValue);
|
||||
};
|
||||
}, [reloadValue]);
|
||||
|
||||
// Listen for changes in other tabs, and update here too! (This does not
|
||||
// catch same-page updates!)
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("storage", reloadValue);
|
||||
return () => window.removeEventListener("storage", reloadValue);
|
||||
}, [reloadValue]);
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
|
||||
export function loadImage(
|
||||
rawSrc,
|
||||
{ crossOrigin = null, preferArchive = false } = {}
|
||||
) {
|
||||
const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive });
|
||||
const image = new Image();
|
||||
let canceled = false;
|
||||
let resolved = false;
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
image.onload = () => {
|
||||
if (canceled) return;
|
||||
resolved = true;
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (canceled) return;
|
||||
reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
|
||||
};
|
||||
if (crossOrigin) {
|
||||
image.crossOrigin = crossOrigin;
|
||||
}
|
||||
image.src = src;
|
||||
});
|
||||
|
||||
promise.cancel = () => {
|
||||
// NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel
|
||||
// resolved images. That's because our approach to cancelation
|
||||
// mutates the Image object we already returned, which could be
|
||||
// surprising if the caller is using the Image and expected the
|
||||
// `cancel` call to only cancel any in-flight network requests.
|
||||
// (e.g. we cancel a DTI movie when it unloads from the page, but
|
||||
// it might stick around in the movie cache, and we want those images
|
||||
// to still work!)
|
||||
if (resolved) return;
|
||||
image.src = "";
|
||||
canceled = true;
|
||||
};
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* loadable is a wrapper for `@loadable/component`, with extra error handling.
|
||||
* Loading the page will often fail if you keep a session open during a deploy,
|
||||
* because Vercel doesn't keep old JS chunks on the CDN. Recover by reloading!
|
||||
*/
|
||||
export function loadable(load, options) {
|
||||
return loadableLibrary(
|
||||
() =>
|
||||
load().catch((e) => {
|
||||
console.error("Error loading page, reloading:", e);
|
||||
window.location.reload();
|
||||
// Return a component that renders nothing, while we reload!
|
||||
return () => null;
|
||||
}),
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* logAndCapture will print an error to the console, and send it to Sentry.
|
||||
*
|
||||
* This is useful when there's a graceful recovery path, but it's still a
|
||||
* genuinely unexpected error worth logging.
|
||||
*/
|
||||
export function logAndCapture(e) {
|
||||
console.error(e);
|
||||
Sentry.captureException(e);
|
||||
}
|
||||
|
||||
export function getGraphQLErrorMessage(error) {
|
||||
// If this is a GraphQL Bad Request error, show the message of the first
|
||||
// error the server returned. Otherwise, just use the normal error message!
|
||||
return (
|
||||
error?.networkError?.result?.errors?.[0]?.message || error?.message || null
|
||||
);
|
||||
}
|
||||
|
||||
export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
|
||||
// Log the detailed error to the console, so we can have a good debug
|
||||
// experience without the parent worrying about it!
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Flex justify="center" marginTop="8">
|
||||
<Grid
|
||||
templateAreas='"icon title" "icon description" "icon details"'
|
||||
templateColumns="auto minmax(0, 1fr)"
|
||||
maxWidth="500px"
|
||||
marginX="8"
|
||||
columnGap="4"
|
||||
>
|
||||
<Box gridArea="icon" marginTop="2">
|
||||
<Box
|
||||
borderRadius="full"
|
||||
boxShadow="md"
|
||||
overflow="hidden"
|
||||
width="100px"
|
||||
height="100px"
|
||||
>
|
||||
<NextImage
|
||||
src={ErrorGrundoImg}
|
||||
alt="Distressed Grundo programmer"
|
||||
width={100}
|
||||
height={100}
|
||||
layout="fixed"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box gridArea="title" fontSize="lg" marginBottom="1">
|
||||
{variant === "unexpected" && <>Ah dang, I broke it 😖</>}
|
||||
{variant === "network" && <>Oops, it didn't work, sorry 😖</>}
|
||||
{variant === "not-found" && <>Oops, page not found 😖</>}
|
||||
</Box>
|
||||
<Box gridArea="description" marginBottom="2">
|
||||
{variant === "unexpected" && (
|
||||
<>
|
||||
There was an error displaying this page. I'll get info about it
|
||||
automatically, but you can tell me more at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
{variant === "network" && (
|
||||
<>
|
||||
There was an error displaying this page. Check your internet
|
||||
connection and try again—and if you keep having trouble, please
|
||||
tell me more at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
{variant === "not-found" && (
|
||||
<>
|
||||
We couldn't find this page. Maybe it's been deleted? Check the URL
|
||||
and try again—and if you keep having trouble, please tell me more
|
||||
at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{error && (
|
||||
<Box gridArea="details" fontSize="xs" opacity="0.8">
|
||||
<WarningIcon
|
||||
marginRight="1.5"
|
||||
marginTop="-2px"
|
||||
aria-label="Error message"
|
||||
/>
|
||||
"{getGraphQLErrorMessage(error)}"
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestErrorSender() {
|
||||
React.useEffect(() => {
|
||||
if (window.location.href.includes("send-test-error-for-sentry")) {
|
||||
throw new Error("Test error for Sentry");
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
|
@ -265,4 +265,5 @@
|
|||
= include_javascript_libraries :jquery, :swfobject, :jquery_tmpl
|
||||
= javascript_include_tag 'ajax_auth', 'jquery.jgrowl', 'wardrobe',
|
||||
'ZeroClipboard.min', 'outfits/edit'
|
||||
= javascript_include_tag 'wardrobe-2020-page', defer: true
|
||||
|
||||
|
|
8
bin/dev
Executable file
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
if ! gem list foreman -i --silent; then
|
||||
echo "Installing foreman..."
|
||||
gem install foreman
|
||||
fi
|
||||
|
||||
exec foreman start -f Procfile.dev "$@"
|
30
package.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "app",
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.9",
|
||||
"@auth0/auth0-react": "^1.0.0",
|
||||
"@chakra-ui/icons": "^1.0.4",
|
||||
"@chakra-ui/react": "^1.6.0",
|
||||
"@emotion/react": "^11.1.4",
|
||||
"@emotion/styled": "^11.0.0",
|
||||
"@loadable/component": "^5.12.0",
|
||||
"@sentry/react": "^5.30.0",
|
||||
"easeljs": "^1.0.2",
|
||||
"esbuild": "^0.19.0",
|
||||
"framer-motion": "^4.1.11",
|
||||
"graphql": "^15.5.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"immer": "^9.0.6",
|
||||
"lru-cache": "^6.0.0",
|
||||
"next": "12.0.2",
|
||||
"react": "^17.0.1",
|
||||
"react-autosuggest": "^10.0.2",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-icons": "^4.2.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"tweenjs": "^1.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --loader:.js=jsx --loader:.png=file --loader:.svg=file"
|
||||
}
|
||||
}
|