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:
parent
535abec228
commit
7c5e7ab21a
10 changed files with 334 additions and 96 deletions
179
src/app/WardrobePage/support/AllItemLayersSupportModal.js
Normal file
179
src/app/WardrobePage/support/AllItemLayersSupportModal.js
Normal 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;
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
92
src/app/WardrobePage/support/ItemSupportAppearanceLayer.js
Normal file
92
src/app/WardrobePage/support/ItemSupportAppearanceLayer.js
Normal 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;
|
|
@ -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 (
|
||||
<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">
|
||||
{itemLayers.map((itemLayer) => (
|
||||
<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;
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -97,6 +97,7 @@ export function ErrorMessage({ children, ...props }) {
|
|||
export function useCommonStyles() {
|
||||
return {
|
||||
brightBackground: useColorModeValue("white", "gray.700"),
|
||||
bodyBackground: useColorModeValue("gray.50", "gray.800"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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 || "<ignore>",
|
||||
fallbackColorId,
|
||||
gender === "fem",
|
||||
|
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue