diff --git a/src/app/WardrobePage/support/AllItemLayersSupportModal.js b/src/app/WardrobePage/support/AllItemLayersSupportModal.js new file mode 100644 index 0000000..891d5aa --- /dev/null +++ b/src/app/WardrobePage/support/AllItemLayersSupportModal.js @@ -0,0 +1,179 @@ +import React from "react"; +import { + Box, + Flex, + Heading, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Wrap, + WrapItem, +} from "@chakra-ui/react"; +import { gql, useQuery } from "@apollo/client"; +import { + itemAppearanceFragment, + petAppearanceFragment, +} from "../../components/useOutfitAppearance"; +import HangerSpinner from "../../components/HangerSpinner"; +import { ErrorMessage, useCommonStyles } from "../../util"; +import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer"; + +function AllItemLayersSupportModal({ item, isOpen, onClose }) { + const { bodyBackground } = useCommonStyles(); + + return ( + + + + + + Layers on all pets: + {" "} + + {item.name} + + + + + + + + + + ); +} + +function AllItemLayersSupportModalContent({ item }) { + const { loading, error, data } = useQuery( + gql` + query AllItemLayersSupportModal($itemId: ID!) { + item(id: $itemId) { + id + allAppearances { + id + body { + id + representsAllBodies + canonicalAppearance { + id + species { + id + name + } + color { + id + name + isStandard + } + pose + ...PetAppearanceForOutfitPreview + } + } + ...ItemAppearanceForOutfitPreview + } + } + } + + ${itemAppearanceFragment} + ${petAppearanceFragment} + `, + { variables: { itemId: item.id } } + ); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return {error.message}; + } + + const itemAppearances = [...(data.item?.allAppearances || [])].sort( + (a, b) => { + const aKey = getSortKeyForPetAppearance(a.body.canonicalAppearance); + const bKey = getSortKeyForPetAppearance(b.body.canonicalAppearance); + return aKey.localeCompare(bKey); + } + ); + + return ( + + {itemAppearances.map((itemAppearance) => ( + + + + ))} + + ); +} + +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 ( + + + {getBodyName(itemAppearance.body)} + + + + {itemLayers.map((itemLayer) => ( + + + + ))} + + + ); +} + +function getSortKeyForPetAppearance({ color, species }) { + // 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); +} + +export default AllItemLayersSupportModal; diff --git a/src/app/WardrobePage/support/ItemLayerSupportModal.js b/src/app/WardrobePage/support/ItemLayerSupportModal.js index 6f84431..e966651 100644 --- a/src/app/WardrobePage/support/ItemLayerSupportModal.js +++ b/src/app/WardrobePage/support/ItemLayerSupportModal.js @@ -43,7 +43,7 @@ import useSupport from "./useSupport"; function ItemLayerSupportModal({ item, itemLayer, - outfitState, + outfitState, // speciesId, colorId, pose isOpen, onClose, }) { @@ -574,7 +574,9 @@ function ItemLayerSupportModalRemoveButton({ const SWF_URL_PATTERN = /^http:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/; function convertSwfUrlToPossibleManifestUrls(swfUrl) { - const match = swfUrl.match(SWF_URL_PATTERN); + 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)}`); } diff --git a/src/app/WardrobePage/support/ItemSupportAppearanceLayer.js b/src/app/WardrobePage/support/ItemSupportAppearanceLayer.js new file mode 100644 index 0000000..baa54c9 --- /dev/null +++ b/src/app/WardrobePage/support/ItemSupportAppearanceLayer.js @@ -0,0 +1,92 @@ +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 ItemLayerSupportModal from "./ItemLayerSupportModal"; +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 ( + + {({ css }) => ( + + + + + + + + {itemLayer.zone.label} + Zone ID: {itemLayer.zone.id} + DTI ID: {itemLayer.id} + + + )} + + ); +} + +export default ItemSupportAppearanceLayer; diff --git a/src/app/WardrobePage/support/ItemSupportDrawer.js b/src/app/WardrobePage/support/ItemSupportDrawer.js index e7f6e73..b12dda4 100644 --- a/src/app/WardrobePage/support/ItemSupportDrawer.js +++ b/src/app/WardrobePage/support/ItemSupportDrawer.js @@ -1,16 +1,17 @@ import * as React from "react"; import gql from "graphql-tag"; import { useQuery, useMutation } from "@apollo/client"; -import { ClassNames } from "@emotion/react"; import { Badge, Box, + Button, Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay, + Flex, FormControl, FormErrorMessage, FormHelperText, @@ -25,14 +26,18 @@ import { useColorModeValue, useDisclosure, } from "@chakra-ui/react"; -import { CheckCircleIcon, EditIcon, ExternalLinkIcon } from "@chakra-ui/icons"; +import { + CheckCircleIcon, + ChevronRightIcon, + ExternalLinkIcon, +} from "@chakra-ui/icons"; -import ItemLayerSupportModal from "./ItemLayerSupportModal"; +import AllItemLayersSupportModal from "./AllItemLayersSupportModal"; import Metadata, { MetadataLabel, MetadataValue } from "./Metadata"; -import { OutfitLayers } from "../../components/OutfitPreview"; 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. @@ -423,9 +428,22 @@ function ItemSupportAppearanceLayers({ item }) { const itemLayers = visibleLayers.filter((l) => l.source === "item"); itemLayers.sort((a, b) => a.zone.depth - b.zone.depth); + const modalState = useDisclosure(); + return ( - Appearance layers + + Appearance layers + + + + {itemLayers.map((itemLayer) => ( - {({ css }) => ( - - - - - - - - {itemLayer.zone.label} - Zone ID: {itemLayer.zone.id} - DTI ID: {itemLayer.id} - - - )} - - ); -} - export default ItemSupportDrawer; diff --git a/src/app/components/useOutfitAppearance.js b/src/app/components/useOutfitAppearance.js index 9af4096..3d4dc60 100644 --- a/src/app/components/useOutfitAppearance.js +++ b/src/app/components/useOutfitAppearance.js @@ -56,7 +56,10 @@ export default function useOutfitAppearance(outfitState) { pose, appearanceId, }, - skip: speciesId == null || colorId == null || pose == null, + skip: + speciesId == null || + colorId == null || + (pose == null && appearanceId == null), } ); diff --git a/src/app/util.js b/src/app/util.js index 7405cef..9bc7b6b 100644 --- a/src/app/util.js +++ b/src/app/util.js @@ -97,6 +97,7 @@ export function ErrorMessage({ children, ...props }) { export function useCommonStyles() { return { brightBackground: useColorModeValue("white", "gray.700"), + bodyBackground: useColorModeValue("gray.50", "gray.800"), }; } diff --git a/src/server/loaders.js b/src/server/loaders.js index 1986029..ce788dd 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -934,12 +934,15 @@ const buildCanonicalPetStateForBodyLoader = (db, loaders) => // creates an even distribution. const gender = bodyId % 2 === 0 ? "masc" : "fem"; + const bodyCondition = bodyId !== "0" ? `pet_types.body_id = ?` : `1`; + const bodyValues = bodyId !== "0" ? [bodyId] : []; + const [rows, _] = await db.execute( { sql: ` SELECT pet_states.*, pet_types.* FROM pet_states INNER JOIN pet_types ON pet_types.id = pet_states.pet_type_id - WHERE pet_types.body_id = ? + WHERE ${bodyCondition} ORDER BY pet_types.color_id = ? DESC, -- Prefer preferredColorId pet_types.color_id = ? DESC, -- Prefer fallbackColorId @@ -951,7 +954,7 @@ const buildCanonicalPetStateForBodyLoader = (db, loaders) => nestTables: true, }, [ - bodyId, + ...bodyValues, preferredColorId || "", fallbackColorId, gender === "fem", diff --git a/src/server/neopets-assets.js b/src/server/neopets-assets.js index 021adc8..53c67ac 100644 --- a/src/server/neopets-assets.js +++ b/src/server/neopets-assets.js @@ -37,7 +37,9 @@ async function loadAssetManifest(swfUrl) { const SWF_URL_PATTERN = /^http:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/; function convertSwfUrlToPossibleManifestUrls(swfUrl) { - const match = swfUrl.match(SWF_URL_PATTERN); + 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)}`); } diff --git a/src/server/types/Item.js b/src/server/types/Item.js index d437ebe..9eff8ae 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -101,6 +101,10 @@ const typeDefs = gql` # occupies for that body. Note that this might return the special # representsAllPets body, e.g. if this is just a Background! compatibleBodiesAndTheirZones: [BodyAndZones!]! @cacheControl(maxAge: 1, staleWhileRevalidate: ${oneWeek}) + + # All appearances for this item. Used in Support tools, to show and manage + # how this item fits every pet body. + allAppearances: [ItemAppearance!]! } type ItemAppearance { @@ -462,6 +466,24 @@ const resolvers = { zones: row.zoneIds.split(",").map((zoneId) => ({ id: zoneId })), })); }, + allAppearances: async ({ id }, _, { db }) => { + // HACK: Copy-pasted from `compatibleBodies`. Could be a loader? + const [rows, __] = await db.query( + ` + SELECT DISTINCT swf_assets.body_id + FROM items + INNER JOIN parents_swf_assets ON + items.id = parents_swf_assets.parent_id AND + parents_swf_assets.parent_type = "Item" + INNER JOIN swf_assets ON + parents_swf_assets.swf_asset_id = swf_assets.id + WHERE items.id = ? + `, + [id] + ); + const bodyIds = rows.map((row) => String(row.body_id)); + return bodyIds.map((bodyId) => ({ item: { id }, bodyId })); + }, }, ItemAppearance: { diff --git a/src/server/types/PetAppearance.js b/src/server/types/PetAppearance.js index 3803493..4fff136 100644 --- a/src/server/types/PetAppearance.js +++ b/src/server/types/PetAppearance.js @@ -175,7 +175,7 @@ const resolvers = { const petState = await canonicalPetStateForBodyLoader.load({ bodyId: id, preferredColorId, - fallbackColorId: FALLBACK_COLOR_IDS[species.id] || "8", + fallbackColorId: FALLBACK_COLOR_IDS[species?.id] || "8", }); if (!petState) { return null;