diff --git a/app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js b/app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js deleted file mode 100644 index d5fc8ddd..00000000 --- a/app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js +++ /dev/null @@ -1,905 +0,0 @@ -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.id === "0", - ); - 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 ( - - - {allSpeciesFaces.map((speciesFace) => ( - - - - ))} - - {error && ( - - - - Error loading this color's pet photos. -
- Check your connection and try again. -
-
- )} -
- ); -} -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 = ( -
- {speciesName} - {disabledExplanation && ( -
- {disabledExplanation} -
- )} -
- ); - - // NOTE: Because we render quite a few of these, avoiding using Chakra - // elements like Box helps with render performance! - return ( - - {({ css }) => ( - - - - )} - - ); - }, -); - -/** - * CrossFadeImage is like , 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 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 ( - - {({ css }) => ( -
div { - grid-area: shared-overlapping-area; - transition: opacity 0.2s; - } - `} - > - {prevImageProps && ( -
- {/* eslint-disable-next-line jsx-a11y/alt-text */} - -
- )} - - {currentImageProps && ( -
- {/* eslint-disable-next-line jsx-a11y/alt-text */} - -
- )} - - {!incomingImageIsCurrentImage && ( -
- {/* eslint-disable-next-line jsx-a11y/alt-text */} - -
- )} -
- )} -
- ); -} -/** - * DeferredTooltip is like Chakra's , 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 ( - - {({ css }) => ( -
- {children} - {shouldShowTooltip && ( - -
- - )} -
- )} - - ); -} - -// 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; diff --git a/app/javascript/wardrobe-2020/ItemPageOutfitPreview.js b/app/javascript/wardrobe-2020/ItemPageOutfitPreview.js deleted file mode 100644 index 97be9452..00000000 --- a/app/javascript/wardrobe-2020/ItemPageOutfitPreview.js +++ /dev/null @@ -1,691 +0,0 @@ -import React from "react"; -import { useQuery } from "@apollo/client"; -import gql from "graphql-tag"; -import { - AspectRatio, - Box, - Button, - Flex, - Grid, - IconButton, - Tooltip, - useColorModeValue, - usePrefersReducedMotion, -} from "@chakra-ui/react"; -import { EditIcon, WarningIcon } from "@chakra-ui/icons"; -import { MdPause, MdPlayArrow } from "react-icons/md"; - -import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge"; -import SpeciesColorPicker, { - useAllValidPetPoses, - getValidPoses, - getClosestPose, -} from "./components/SpeciesColorPicker"; -import SpeciesFacesPicker, { - colorIsBasic, -} from "./ItemPage/SpeciesFacesPicker"; -import { - itemAppearanceFragment, - petAppearanceFragment, -} from "./components/useOutfitAppearance"; -import { useOutfitPreview } from "./components/OutfitPreview"; -import { logAndCapture, useLocalStorage } from "./util"; -import { useItemAppearances } from "./loaders/items"; - -function ItemPageOutfitPreview({ itemId }) { - const idealPose = React.useMemo( - () => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"), - [], - ); - const [petState, setPetState] = React.useState({ - // We'll fill these in once the canonical appearance data arrives. - speciesId: null, - colorId: null, - pose: null, - isValid: false, - - // We use appearance ID, in addition to the above, to give the Apollo cache - // a really clear hint that the canonical pet appearance we preloaded is - // the exact right one to show! But switching species/color will null this - // out again, and that's okay. (We'll do an unnecessary reload if you - // switch back to it though... we could maybe do something clever there!) - appearanceId: null, - }); - const [preferredSpeciesId, setPreferredSpeciesId] = useLocalStorage( - "DTIItemPreviewPreferredSpeciesId", - null, - ); - const [preferredColorId, setPreferredColorId] = useLocalStorage( - "DTIItemPreviewPreferredColorId", - null, - ); - - const setPetStateFromUserAction = React.useCallback( - (newPetState) => - setPetState((prevPetState) => { - // When the user _intentionally_ chooses a species or color, save it in - // local storage for next time. (This won't update when e.g. their - // preferred species or color isn't available for this item, so we update - // to the canonical species or color automatically.) - // - // Re the "ifs", I have no reason to expect null to come in here, but, - // since this is touching client-persisted data, I want it to be even more - // reliable than usual! - if ( - newPetState.speciesId && - newPetState.speciesId !== prevPetState.speciesId - ) { - setPreferredSpeciesId(newPetState.speciesId); - } - if ( - newPetState.colorId && - newPetState.colorId !== prevPetState.colorId - ) { - if (colorIsBasic(newPetState.colorId)) { - // When the user chooses a basic color, don't index on it specifically, - // and instead reset to use default colors. - setPreferredColorId(null); - } else { - setPreferredColorId(newPetState.colorId); - } - } - - return newPetState; - }), - [setPreferredColorId, setPreferredSpeciesId], - ); - - // We don't need to reload this query when preferred species/color change, so - // cache their initial values here to use as query arguments. - const [initialPreferredSpeciesId] = React.useState(preferredSpeciesId); - const [initialPreferredColorId] = React.useState(preferredColorId); - - const { - data: itemAppearancesData, - loading: loadingAppearances, - error: errorAppearances, - } = useItemAppearances(itemId); - const itemName = itemAppearancesData?.name ?? ""; - const itemAppearances = itemAppearancesData?.appearances ?? []; - const restrictedZones = itemAppearancesData?.restrictedZones ?? []; - - // Start by loading the "canonical" pet and item appearance for the outfit - // preview. We'll use this to initialize both the preview and the picker. - // - // If the user has a preferred species saved from using the ItemPage in the - // past, we'll send that instead. This will return the appearance on that - // species if possible, or the default canonical species if not. - // - // TODO: If this is a non-standard pet color, like Mutant, we'll do an extra - // query after this loads, because our Apollo cache can't detect the - // shared item appearance. (For standard colors though, our logic to - // cover standard-color switches works for this preloading too.) - const { - loading: loadingGQL, - error: errorGQL, - data, - } = useQuery( - gql` - query ItemPageOutfitPreview( - $itemId: ID! - $preferredSpeciesId: ID - $preferredColorId: ID - ) { - item(id: $itemId) { - id - canonicalAppearance( - preferredSpeciesId: $preferredSpeciesId - preferredColorId: $preferredColorId - ) { - id - ...ItemAppearanceForOutfitPreview - body { - id - canonicalAppearance(preferredColorId: $preferredColorId) { - id - species { - id - name - } - color { - id - } - pose - - ...PetAppearanceForOutfitPreview - } - } - } - } - } - - ${itemAppearanceFragment} - ${petAppearanceFragment} - `, - { - variables: { - itemId, - preferredSpeciesId: initialPreferredSpeciesId, - preferredColorId: initialPreferredColorId, - }, - onCompleted: (data) => { - const canonicalBody = data?.item?.canonicalAppearance?.body; - const canonicalPetAppearance = canonicalBody?.canonicalAppearance; - - setPetState({ - speciesId: canonicalPetAppearance?.species?.id, - colorId: canonicalPetAppearance?.color?.id, - pose: canonicalPetAppearance?.pose, - isValid: true, - appearanceId: canonicalPetAppearance?.id, - }); - }, - }, - ); - - const compatibleBodies = itemAppearances?.map(({ body }) => body) || []; - - // If there's only one compatible body, and the canonical species's name - // appears in the item name, then this is probably a species-specific item, - // and we should adjust the UI to avoid implying that other species could - // model it. - const speciesName = - data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name ?? - ""; - const isProbablySpeciesSpecific = - compatibleBodies.length === 1 && - compatibleBodies[0] !== "all" && - itemName.toLowerCase().includes(speciesName.toLowerCase()); - const couldProbablyModelMoreData = !isProbablySpeciesSpecific; - - // TODO: Does this double-trigger the HTTP request with SpeciesColorPicker? - const { - loading: loadingValids, - error: errorValids, - valids, - } = useAllValidPetPoses(); - - const [hasAnimations, setHasAnimations] = React.useState(false); - const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true); - - // This is like , but we can use the appearance data, too! - const { appearance, preview } = useOutfitPreview({ - speciesId: petState.speciesId, - colorId: petState.colorId, - pose: petState.pose, - appearanceId: petState.appearanceId, - wornItemIds: [itemId], - isLoading: loadingGQL || loadingValids, - spinnerVariant: "corner", - engine: "canvas", - onChangeHasAnimations: setHasAnimations, - }); - - // If there's an appearance loaded for this item, but it's empty, then the - // item is incompatible. (There should only be one item appearance: this one!) - const itemAppearance = appearance?.itemAppearances?.[0]; - const itemLayers = itemAppearance?.layers || []; - const isCompatible = itemLayers.length > 0; - const usesHTML5 = itemLayers.every(layerUsesHTML5); - - const onChange = React.useCallback( - ({ speciesId, colorId }) => { - const validPoses = getValidPoses(valids, speciesId, colorId); - const pose = getClosestPose(validPoses, idealPose); - setPetStateFromUserAction({ - speciesId, - colorId, - pose, - isValid: true, - appearanceId: null, - }); - }, - [valids, idealPose, setPetStateFromUserAction], - ); - - const borderColor = useColorModeValue("green.700", "green.400"); - const errorColor = useColorModeValue("red.600", "red.400"); - - const error = errorGQL || errorAppearances || errorValids; - if (error) { - return {error.message}; - } - - return ( - - - - {petState.isValid && preview} - - {hasAnimations && ( - setIsPaused(!isPaused)} - /> - )} - - - - - { - setPetStateFromUserAction({ - speciesId: species.id, - colorId: color.id, - pose: closestPose, - isValid, - appearanceId: null, - }); - }} - speciesIsDisabled={isProbablySpeciesSpecific} - size="sm" - showPlaceholders - /> - - { - // Wait for us to start _requesting_ the appearance, and _then_ - // for it to load, and _then_ check compatibility. - !loadingGQL && - !loadingAppearances && - !appearance.loading && - petState.isValid && - !isCompatible && ( - - - - ) - } - - - - - - - {itemAppearances.length > 0 && ( - - )} - - - - - - - ); -} - -function CustomizeMoreButton({ speciesId, colorId, pose, itemId, isDisabled }) { - const url = - `/outfits/new?species=${speciesId}&color=${colorId}&pose=${pose}&` + - `objects[]=${itemId}`; - - // The default background is good in light mode, but in dark mode it's a - // very subtle transparent white... make it a semi-transparent black, for - // better contrast against light-colored background items! - const backgroundColor = useColorModeValue(undefined, "blackAlpha.700"); - const backgroundColorHover = useColorModeValue(undefined, "blackAlpha.900"); - - return ( - - Customize more - - - ); -} - -function LinkOrButton({ href, ...props }) { - if (href != null) { - return