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;