Show previews for bulk-add layer tool

This commit is contained in:
Emi Matchu 2021-03-15 07:50:13 -07:00
parent 9eb0906a69
commit e4c8031c3b
4 changed files with 241 additions and 63 deletions

View file

@ -11,13 +11,13 @@ import {
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalOverlay, ModalOverlay,
Select,
Tooltip, Tooltip,
Wrap, Wrap,
WrapItem, WrapItem,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { gql, useQuery } from "@apollo/client"; import { gql, useQuery } from "@apollo/client";
import { import {
appearanceLayerFragment,
itemAppearanceFragment, itemAppearanceFragment,
petAppearanceFragment, petAppearanceFragment,
} from "../../components/useOutfitAppearance"; } from "../../components/useOutfitAppearance";
@ -25,7 +25,6 @@ import HangerSpinner from "../../components/HangerSpinner";
import { ErrorMessage, useCommonStyles } from "../../util"; import { ErrorMessage, useCommonStyles } from "../../util";
import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer"; import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
import { EditIcon } from "@chakra-ui/icons"; import { EditIcon } from "@chakra-ui/icons";
import cachedZones from "../../cached-data/zones.json";
function AllItemLayersSupportModal({ item, isOpen, onClose }) { function AllItemLayersSupportModal({ item, isOpen, onClose }) {
const [bulkAddProposal, setBulkAddProposal] = React.useState(null); const [bulkAddProposal, setBulkAddProposal] = React.useState(null);
@ -46,19 +45,15 @@ function AllItemLayersSupportModal({ item, isOpen, onClose }) {
</ModalHeader> </ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody paddingBottom="12"> <ModalBody paddingBottom="12">
<BulkAddBodySpecificAssetsForm onSubmit={setBulkAddProposal} /> <BulkAddBodySpecificAssetsForm
bulkAddProposal={bulkAddProposal}
onSubmit={setBulkAddProposal}
/>
<Box height="8" /> <Box height="8" />
{bulkAddProposal ? ( <AllItemLayersSupportModalContent
<> item={item}
TODO: Show assets {bulkAddProposal.minAssetId} bulkAddProposal={bulkAddProposal}
{Number(bulkAddProposal.minAssetId) + 53}, tenatively applied to />
zone {bulkAddProposal.zoneId}
</>
) : (
""
)}
<Box height="8" />
<AllItemLayersSupportModalContent item={item} />
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</ModalOverlay> </ModalOverlay>
@ -66,14 +61,11 @@ function AllItemLayersSupportModal({ item, isOpen, onClose }) {
); );
} }
function BulkAddBodySpecificAssetsForm({ onSubmit }) { function BulkAddBodySpecificAssetsForm({ bulkAddProposal, onSubmit }) {
const zones = [...cachedZones].sort((a, b) => const [minAssetId, setMinAssetId] = React.useState(
`${a.label}-${a.id}`.localeCompare(`${b.label}-${b.id}`) bulkAddProposal?.minAssetId
); );
const [minAssetId, setMinAssetId] = React.useState(null);
const [zoneId, setZoneId] = React.useState(zones[0].id);
return ( return (
<Flex <Flex
align="center" align="center"
@ -83,7 +75,7 @@ function BulkAddBodySpecificAssetsForm({ onSubmit }) {
transition="0.2s all" transition="0.2s all"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
onSubmit({ minAssetId, zoneId }); onSubmit({ minAssetId });
}} }}
> >
<Tooltip <Tooltip
@ -121,33 +113,18 @@ function BulkAddBodySpecificAssetsForm({ onSubmit }) {
<Box width="1" /> <Box width="1" />
<Input <Input
type="number" type="number"
min="54" min="55"
step="1" step="1"
size="xs" size="xs"
width="9ch" width="9ch"
placeholder="Max ID" placeholder="Max ID"
// There are 54 species at time of writing, so offsetting the max ID // There are 55 species at time of writing, so offsetting the max ID
// by 53 gives us ranges like 154, one for each species. // by 54 gives us ranges like 155, one for each species.
value={minAssetId != null ? Number(minAssetId) + 53 : ""} value={minAssetId != null ? Number(minAssetId) + 54 : ""}
onChange={(e) => onChange={(e) =>
setMinAssetId(e.target.value ? Number(e.target.value) - 53 : null) setMinAssetId(e.target.value ? Number(e.target.value) - 54 : null)
} }
/> />
<Box width="1" />
<Box>, assigned to </Box>
<Box width="2" />
<Select
size="xs"
width="20ch"
value={zoneId}
onChange={(e) => setZoneId(e.target.value)}
>
{zones.map((zone) => (
<option key={zone.id} value={zone.id}>
{zone.label} (Zone {zone.id})
</option>
))}
</Select>
<Box width="2" /> <Box width="2" />
<Button type="submit" size="xs" isDisabled={minAssetId == null}> <Button type="submit" size="xs" isDisabled={minAssetId == null}>
Preview Preview
@ -156,7 +133,7 @@ function BulkAddBodySpecificAssetsForm({ onSubmit }) {
); );
} }
function AllItemLayersSupportModalContent({ item }) { function AllItemLayersSupportModalContent({ item, bulkAddProposal }) {
const { loading, error, data } = useQuery( const { loading, error, data } = useQuery(
gql` gql`
query AllItemLayersSupportModal($itemId: ID!) { query AllItemLayersSupportModal($itemId: ID!) {
@ -193,7 +170,57 @@ function AllItemLayersSupportModalContent({ item }) {
{ variables: { itemId: item.id } } { variables: { itemId: item.id } }
); );
if (loading) { const {
loading: loading2,
error: error2,
data: bulkAddProposalData,
} = useQuery(
gql`
query AllItemLayersSupportModal_BulkAddProposal($layerRemoteIds: [ID!]!) {
layersToAdd: itemAppearanceLayersByRemoteId(
remoteIds: $layerRemoteIds
) {
id
...AppearanceLayerForOutfitPreview
}
allSpecies {
id
name
standardBodyId
canonicalAppearance {
id
species {
id
name
}
color {
id
name
isStandard
}
pose
...PetAppearanceForOutfitPreview
}
}
}
${appearanceLayerFragment}
${petAppearanceFragment}
`,
{
variables: {
layerRemoteIds: bulkAddProposal
? Array.from({ length: 54 }, (_, i) =>
String(Number(bulkAddProposal.minAssetId) + i)
)
: [],
},
skip: bulkAddProposal == null,
}
);
if (loading || loading2) {
return ( return (
<Flex align="center" justify="center" minHeight="64"> <Flex align="center" justify="center" minHeight="64">
<HangerSpinner /> <HangerSpinner />
@ -201,17 +228,21 @@ function AllItemLayersSupportModalContent({ item }) {
); );
} }
if (error) { if (error || error2) {
return <ErrorMessage>{error.message}</ErrorMessage>; return <ErrorMessage>{(error || error2).message}</ErrorMessage>;
} }
const itemAppearances = [...(data.item?.allAppearances || [])].sort( let itemAppearances = data.item?.allAppearances || [];
(a, b) => { itemAppearances = mergeBulkAddProposalIntoItemAppearances(
const aKey = getSortKeyForPetAppearance(a.body.canonicalAppearance); itemAppearances,
const bKey = getSortKeyForPetAppearance(b.body.canonicalAppearance); bulkAddProposal,
return aKey.localeCompare(bKey); bulkAddProposalData
}
); );
itemAppearances = [...itemAppearances].sort((a, b) => {
const aKey = getSortKeyForBody(a.body);
const bKey = getSortKeyForBody(b.body);
return aKey.localeCompare(bKey);
});
return ( return (
<Wrap justify="center" spacing="4"> <Wrap justify="center" spacing="4">
@ -246,6 +277,18 @@ function ItemAppearanceCard({ item, itemAppearance }) {
</Heading> </Heading>
<Box height="3" /> <Box height="3" />
<Wrap paddingX="3" spacing="5"> <Wrap paddingX="3" spacing="5">
{itemLayers.length === 0 && (
<Flex
minWidth="150px"
minHeight="150px"
align="center"
justify="center"
>
<Box fontSize="sm" fontStyle="italic">
(No data)
</Box>
</Flex>
)}
{itemLayers.map((itemLayer) => ( {itemLayers.map((itemLayer) => (
<WrapItem key={itemLayer.id}> <WrapItem key={itemLayer.id}>
<ItemSupportAppearanceLayer <ItemSupportAppearanceLayer
@ -265,7 +308,13 @@ function ItemAppearanceCard({ item, itemAppearance }) {
); );
} }
function getSortKeyForPetAppearance({ color, species }) { function getSortKeyForBody(body) {
// "All bodies" sorts first!
if (body.representsAllBodies) {
return "";
}
const { color, species } = body.canonicalAppearance;
// Sort standard colors first, then special colors by name, then by species // Sort standard colors first, then special colors by name, then by species
// within each color. // within each color.
return `${color.isStandard ? "A" : "Z"}-${color.name}-${species.name}`; return `${color.isStandard ? "A" : "Z"}-${color.name}-${species.name}`;
@ -286,4 +335,71 @@ function capitalize(str) {
return str[0].toUpperCase() + str.slice(1); return str[0].toUpperCase() + str.slice(1);
} }
function mergeBulkAddProposalIntoItemAppearances(
itemAppearances,
bulkAddProposal,
bulkAddProposalData
) {
if (!bulkAddProposalData) {
return itemAppearances;
}
// Do a deep copy of the existing item appearances, so we can mutate them as
// we loop through them in this function!
const mergedItemAppearances = JSON.parse(JSON.stringify(itemAppearances));
// Set up the data in convenient formats.
const { allSpecies, layersToAdd } = bulkAddProposalData;
const sortedSpecies = [...allSpecies].sort((a, b) =>
a.name.localeCompare(b.name)
);
const layersToAddByRemoteId = {};
for (const layer of layersToAdd) {
layersToAddByRemoteId[layer.remoteId] = layer;
}
for (const [index, species] of sortedSpecies.entries()) {
// Find the existing item appearance to add to, or create a new one if it
// doesn't exist yet.
let itemAppearance = mergedItemAppearances.find(
(a) =>
a.body.canonicalAppearance.species.id === species.id &&
!a.body.representsAllBodies
);
if (!itemAppearance) {
itemAppearance = {
id: `bulk-add-proposal-new-item-appearance-for-body-${species.standardBodyId}`,
layers: [],
body: {
id: species.standardBodyId,
canonicalAppearance: species.canonicalAppearance,
},
};
mergedItemAppearances.push(itemAppearance);
}
const layerToAddRemoteId = String(
Number(bulkAddProposal.minAssetId) + index
);
const layerToAdd = layersToAddByRemoteId[layerToAddRemoteId];
if (!layerToAdd) {
continue;
}
// Delete this layer from other appearances (because we're going to
// override its body ID), then add it to this new one.
for (const otherItemAppearance of mergedItemAppearances) {
const indexToDelete = otherItemAppearance.layers.findIndex(
(l) => l.remoteId === layerToAddRemoteId
);
if (indexToDelete >= 0) {
otherItemAppearance.layers.splice(indexToDelete, 1);
}
}
itemAppearance.layers.push(layerToAdd);
}
return mergedItemAppearances;
}
export default AllItemLayersSupportModal; export default AllItemLayersSupportModal;

View file

@ -204,25 +204,34 @@ export const itemAppearanceFragmentForGetVisibleLayers = gql`
} }
`; `;
export const appearanceLayerFragment = gql`
fragment AppearanceLayerForOutfitPreview on AppearanceLayer {
id
remoteId # HACK: This is for Support tools, but other views don't need it
svgUrl
canvasMovieLibraryUrl
imageUrl(size: SIZE_600)
swfUrl # HACK: This is for Support tools, but other views don't need it
knownGlitches # HACK: This is for Support tools, but other views don't need it
bodyId
zone {
id
depth @client
label @client # HACK: This is for Support tools, but other views don't need it
}
}
`;
export const itemAppearanceFragment = gql` export const itemAppearanceFragment = gql`
fragment ItemAppearanceForOutfitPreview on ItemAppearance { fragment ItemAppearanceForOutfitPreview on ItemAppearance {
id id
layers { layers {
id ...AppearanceLayerForOutfitPreview
remoteId # HACK: This is for Support tools, but other views don't need it
svgUrl
canvasMovieLibraryUrl
imageUrl(size: SIZE_600)
swfUrl # HACK: This is for Support tools, but other views don't need it
knownGlitches # HACK: This is for Support tools, but other views don't need it
bodyId
zone {
label @client # HACK: This is for Support tools, but other views don't need it
}
} }
...ItemAppearanceForGetVisibleLayers ...ItemAppearanceForGetVisibleLayers
} }
${appearanceLayerFragment}
${itemAppearanceFragmentForGetVisibleLayers} ${itemAppearanceFragmentForGetVisibleLayers}
`; `;

View file

@ -92,6 +92,11 @@ const typeDefs = gql`
} }
extend type Query { extend type Query {
# Return the item appearance layers with the given remoteIds. We use this
# in Support tool to bulk-add a range of layers to an item. When we can't
# find a layer with the given ID, we omit its entry from the returned list.
itemAppearanceLayersByRemoteId(remoteIds: [ID!]!): [AppearanceLayer]!
# Return the number of layers that have been converted to HTML5, optionally # Return the number of layers that have been converted to HTML5, optionally
# filtered by type. Cache for 30 minutes (we re-sync with Neopets every # filtered by type. Cache for 30 minutes (we re-sync with Neopets every
# hour). # hour).
@ -253,6 +258,16 @@ const resolvers = {
}, },
Query: { Query: {
itemAppearanceLayersByRemoteId: async (
_,
{ remoteIds },
{ swfAssetByRemoteIdLoader }
) => {
const layers = await swfAssetByRemoteIdLoader.loadMany(
remoteIds.map((remoteId) => ({ type: "object", remoteId }))
);
return layers.filter((l) => l).map(({ id }) => ({ id }));
},
numAppearanceLayersConverted: async ( numAppearanceLayersConverted: async (
_, _,
{ type }, { type },

View file

@ -23,6 +23,10 @@ const typeDefs = gql`
id: ID! id: ID!
name: String! name: String!
# A PetAppearance that has this species. Prefers Blue (or the optional
# preferredColorId), and happy poses.
canonicalAppearance(preferredColorId: ID): PetAppearance
# The bodyId for PetAppearances that use this species and a standard color. # The bodyId for PetAppearances that use this species and a standard color.
# We use this to preload the standard body IDs, so that items stay when # We use this to preload the standard body IDs, so that items stay when
# switching between standard colors. # switching between standard colors.
@ -60,6 +64,7 @@ const typeDefs = gql`
species: Species! species: Species!
color: Color! color: Color!
pose: Pose! pose: Pose!
body: Body!
bodyId: ID! bodyId: ID!
layers: [AppearanceLayer!]! layers: [AppearanceLayer!]!
@ -134,6 +139,34 @@ const resolvers = {
const speciesTranslation = await speciesTranslationLoader.load(id); const speciesTranslation = await speciesTranslationLoader.load(id);
return capitalize(speciesTranslation.name); return capitalize(speciesTranslation.name);
}, },
canonicalAppearance: async (
{ id, species },
{ preferredColorId },
{ petTypeBySpeciesAndColorLoader, canonicalPetStateForBodyLoader }
) => {
const petType = await petTypeBySpeciesAndColorLoader.load({
speciesId: id,
colorId: preferredColorId || "8", // defaults to Blue
});
if (!petType) {
// HACK: For a new species, we shouldn't necessarily crash if Blue
// isn't modeled… but like, whatever :p
return null;
}
const petState = await canonicalPetStateForBodyLoader.load({
bodyId: petType.bodyId,
preferredColorId,
fallbackColorId: FALLBACK_COLOR_IDS[species?.id] || "8",
});
if (!petState) {
return null;
}
return { id: petState.id };
},
standardBodyId: async ({ id }, _, { petTypeBySpeciesAndColorLoader }) => { standardBodyId: async ({ id }, _, { petTypeBySpeciesAndColorLoader }) => {
const petType = await petTypeBySpeciesAndColorLoader.load({ const petType = await petTypeBySpeciesAndColorLoader.load({
speciesId: id, speciesId: id,
@ -195,6 +228,11 @@ const resolvers = {
const petType = await petTypeLoader.load(petState.petTypeId); const petType = await petTypeLoader.load(petState.petTypeId);
return { id: petType.speciesId }; return { id: petType.speciesId };
}, },
body: async ({ id }, _, { petStateLoader, petTypeLoader }) => {
const petState = await petStateLoader.load(id);
const petType = await petTypeLoader.load(petState.petTypeId);
return { id: petType.bodyId };
},
bodyId: async ({ id }, _, { petStateLoader, petTypeLoader }) => { bodyId: async ({ id }, _, { petStateLoader, petTypeLoader }) => {
const petState = await petStateLoader.load(id); const petState = await petStateLoader.load(id);
const petType = await petTypeLoader.load(petState.petTypeId); const petType = await petTypeLoader.load(petState.petTypeId);