Add Support view for all appearances of an item

I think this will be generally useful to minimize switching around for common operations, but also I'm thinking of building a bulk assign tool for things with broken body IDs, and this will be the place for it to live, I think
This commit is contained in:
Emi Matchu 2021-03-14 07:16:01 -07:00
parent 535abec228
commit 7c5e7ab21a
10 changed files with 334 additions and 96 deletions

View file

@ -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 (
<Modal size="4xl" isOpen={isOpen} onClose={onClose}>
<ModalOverlay>
<ModalContent background={bodyBackground}>
<ModalHeader as="h1">
<Box as="span" fontWeight="700">
Layers on all pets:
</Box>{" "}
<Box as="span" fontWeight="normal">
{item.name}
</Box>
</ModalHeader>
<ModalCloseButton />
<ModalBody paddingBottom="12">
<AllItemLayersSupportModalContent item={item} />
</ModalBody>
</ModalContent>
</ModalOverlay>
</Modal>
);
}
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 (
<Flex align="center" justify="center" minHeight="64">
<HangerSpinner />
</Flex>
);
}
if (error) {
return <ErrorMessage>{error.message}</ErrorMessage>;
}
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 (
<Wrap justify="center" spacing="4">
{itemAppearances.map((itemAppearance) => (
<WrapItem key={itemAppearance.id}>
<ItemAppearanceCard item={item} itemAppearance={itemAppearance} />
</WrapItem>
))}
</Wrap>
);
}
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.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 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;

View file

@ -43,7 +43,7 @@ import useSupport from "./useSupport";
function ItemLayerSupportModal({ function ItemLayerSupportModal({
item, item,
itemLayer, itemLayer,
outfitState, outfitState, // speciesId, colorId, pose
isOpen, isOpen,
onClose, onClose,
}) { }) {
@ -574,7 +574,9 @@ function ItemLayerSupportModalRemoveButton({
const SWF_URL_PATTERN = /^http:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/; const SWF_URL_PATTERN = /^http:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/;
function convertSwfUrlToPossibleManifestUrls(swfUrl) { 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) { if (!match) {
throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`); throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`);
} }

View file

@ -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 (
<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 fontWeight="bold">{itemLayer.zone.label}</Box>
<Box>Zone ID: {itemLayer.zone.id}</Box>
<Box>DTI ID: {itemLayer.id}</Box>
<ItemLayerSupportModal
item={item}
itemLayer={itemLayer}
outfitState={outfitState}
isOpen={isOpen}
onClose={onClose}
/>
</Box>
)}
</ClassNames>
);
}
export default ItemSupportAppearanceLayer;

View file

@ -1,16 +1,17 @@
import * as React from "react"; import * as React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery, useMutation } from "@apollo/client"; import { useQuery, useMutation } from "@apollo/client";
import { ClassNames } from "@emotion/react";
import { import {
Badge, Badge,
Box, Box,
Button,
Drawer, Drawer,
DrawerBody, DrawerBody,
DrawerCloseButton, DrawerCloseButton,
DrawerContent, DrawerContent,
DrawerHeader, DrawerHeader,
DrawerOverlay, DrawerOverlay,
Flex,
FormControl, FormControl,
FormErrorMessage, FormErrorMessage,
FormHelperText, FormHelperText,
@ -25,14 +26,18 @@ import {
useColorModeValue, useColorModeValue,
useDisclosure, useDisclosure,
} from "@chakra-ui/react"; } 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 Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
import { OutfitLayers } from "../../components/OutfitPreview";
import useOutfitAppearance from "../../components/useOutfitAppearance"; import useOutfitAppearance from "../../components/useOutfitAppearance";
import { OutfitStateContext } from "../useOutfitState"; import { OutfitStateContext } from "../useOutfitState";
import useSupport from "./useSupport"; import useSupport from "./useSupport";
import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
/** /**
* ItemSupportDrawer shows Support UI for the item when open. * ItemSupportDrawer shows Support UI for the item when open.
@ -423,9 +428,22 @@ function ItemSupportAppearanceLayers({ item }) {
const itemLayers = visibleLayers.filter((l) => l.source === "item"); const itemLayers = visibleLayers.filter((l) => l.source === "item");
itemLayers.sort((a, b) => a.zone.depth - b.zone.depth); itemLayers.sort((a, b) => a.zone.depth - b.zone.depth);
const modalState = useDisclosure();
return ( return (
<FormControl> <FormControl>
<FormLabel>Appearance layers</FormLabel> <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"> <HStack spacing="4" overflow="auto" paddingX="1">
{itemLayers.map((itemLayer) => ( {itemLayers.map((itemLayer) => (
<ItemSupportAppearanceLayer <ItemSupportAppearanceLayer
@ -442,88 +460,4 @@ function ItemSupportAppearanceLayers({ item }) {
); );
} }
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 fontWeight="bold">{itemLayer.zone.label}</Box>
<Box>Zone ID: {itemLayer.zone.id}</Box>
<Box>DTI ID: {itemLayer.id}</Box>
<ItemLayerSupportModal
item={item}
itemLayer={itemLayer}
outfitState={outfitState}
isOpen={isOpen}
onClose={onClose}
/>
</Box>
)}
</ClassNames>
);
}
export default ItemSupportDrawer; export default ItemSupportDrawer;

View file

@ -56,7 +56,10 @@ export default function useOutfitAppearance(outfitState) {
pose, pose,
appearanceId, appearanceId,
}, },
skip: speciesId == null || colorId == null || pose == null, skip:
speciesId == null ||
colorId == null ||
(pose == null && appearanceId == null),
} }
); );

View file

@ -97,6 +97,7 @@ export function ErrorMessage({ children, ...props }) {
export function useCommonStyles() { export function useCommonStyles() {
return { return {
brightBackground: useColorModeValue("white", "gray.700"), brightBackground: useColorModeValue("white", "gray.700"),
bodyBackground: useColorModeValue("gray.50", "gray.800"),
}; };
} }

View file

@ -934,12 +934,15 @@ const buildCanonicalPetStateForBodyLoader = (db, loaders) =>
// creates an even distribution. // creates an even distribution.
const gender = bodyId % 2 === 0 ? "masc" : "fem"; 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( const [rows, _] = await db.execute(
{ {
sql: ` sql: `
SELECT pet_states.*, pet_types.* FROM pet_states SELECT pet_states.*, pet_types.* FROM pet_states
INNER JOIN pet_types ON pet_types.id = pet_states.pet_type_id INNER JOIN pet_types ON pet_types.id = pet_states.pet_type_id
WHERE pet_types.body_id = ? WHERE ${bodyCondition}
ORDER BY ORDER BY
pet_types.color_id = ? DESC, -- Prefer preferredColorId pet_types.color_id = ? DESC, -- Prefer preferredColorId
pet_types.color_id = ? DESC, -- Prefer fallbackColorId pet_types.color_id = ? DESC, -- Prefer fallbackColorId
@ -951,7 +954,7 @@ const buildCanonicalPetStateForBodyLoader = (db, loaders) =>
nestTables: true, nestTables: true,
}, },
[ [
bodyId, ...bodyValues,
preferredColorId || "<ignore>", preferredColorId || "<ignore>",
fallbackColorId, fallbackColorId,
gender === "fem", gender === "fem",

View file

@ -37,7 +37,9 @@ async function loadAssetManifest(swfUrl) {
const SWF_URL_PATTERN = /^http:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/; const SWF_URL_PATTERN = /^http:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/;
function convertSwfUrlToPossibleManifestUrls(swfUrl) { 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) { if (!match) {
throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`); throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`);
} }

View file

@ -101,6 +101,10 @@ const typeDefs = gql`
# occupies for that body. Note that this might return the special # occupies for that body. Note that this might return the special
# representsAllPets body, e.g. if this is just a Background! # representsAllPets body, e.g. if this is just a Background!
compatibleBodiesAndTheirZones: [BodyAndZones!]! @cacheControl(maxAge: 1, staleWhileRevalidate: ${oneWeek}) 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 { type ItemAppearance {
@ -462,6 +466,24 @@ const resolvers = {
zones: row.zoneIds.split(",").map((zoneId) => ({ id: zoneId })), 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: { ItemAppearance: {

View file

@ -175,7 +175,7 @@ const resolvers = {
const petState = await canonicalPetStateForBodyLoader.load({ const petState = await canonicalPetStateForBodyLoader.load({
bodyId: id, bodyId: id,
preferredColorId, preferredColorId,
fallbackColorId: FALLBACK_COLOR_IDS[species.id] || "8", fallbackColorId: FALLBACK_COLOR_IDS[species?.id] || "8",
}); });
if (!petState) { if (!petState) {
return null; return null;