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,
ModalHeader,
ModalOverlay,
Select,
Tooltip,
Wrap,
WrapItem,
} from "@chakra-ui/react";
import { gql, useQuery } from "@apollo/client";
import {
appearanceLayerFragment,
itemAppearanceFragment,
petAppearanceFragment,
} from "../../components/useOutfitAppearance";
@ -25,7 +25,6 @@ import HangerSpinner from "../../components/HangerSpinner";
import { ErrorMessage, useCommonStyles } from "../../util";
import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
import { EditIcon } from "@chakra-ui/icons";
import cachedZones from "../../cached-data/zones.json";
function AllItemLayersSupportModal({ item, isOpen, onClose }) {
const [bulkAddProposal, setBulkAddProposal] = React.useState(null);
@ -46,19 +45,15 @@ function AllItemLayersSupportModal({ item, isOpen, onClose }) {
</ModalHeader>
<ModalCloseButton />
<ModalBody paddingBottom="12">
<BulkAddBodySpecificAssetsForm onSubmit={setBulkAddProposal} />
<BulkAddBodySpecificAssetsForm
bulkAddProposal={bulkAddProposal}
onSubmit={setBulkAddProposal}
/>
<Box height="8" />
{bulkAddProposal ? (
<>
TODO: Show assets {bulkAddProposal.minAssetId}
{Number(bulkAddProposal.minAssetId) + 53}, tenatively applied to
zone {bulkAddProposal.zoneId}
</>
) : (
""
)}
<Box height="8" />
<AllItemLayersSupportModalContent item={item} />
<AllItemLayersSupportModalContent
item={item}
bulkAddProposal={bulkAddProposal}
/>
</ModalBody>
</ModalContent>
</ModalOverlay>
@ -66,14 +61,11 @@ function AllItemLayersSupportModal({ item, isOpen, onClose }) {
);
}
function BulkAddBodySpecificAssetsForm({ onSubmit }) {
const zones = [...cachedZones].sort((a, b) =>
`${a.label}-${a.id}`.localeCompare(`${b.label}-${b.id}`)
function BulkAddBodySpecificAssetsForm({ bulkAddProposal, onSubmit }) {
const [minAssetId, setMinAssetId] = React.useState(
bulkAddProposal?.minAssetId
);
const [minAssetId, setMinAssetId] = React.useState(null);
const [zoneId, setZoneId] = React.useState(zones[0].id);
return (
<Flex
align="center"
@ -83,7 +75,7 @@ function BulkAddBodySpecificAssetsForm({ onSubmit }) {
transition="0.2s all"
onSubmit={(e) => {
e.preventDefault();
onSubmit({ minAssetId, zoneId });
onSubmit({ minAssetId });
}}
>
<Tooltip
@ -121,33 +113,18 @@ function BulkAddBodySpecificAssetsForm({ onSubmit }) {
<Box width="1" />
<Input
type="number"
min="54"
min="55"
step="1"
size="xs"
width="9ch"
placeholder="Max ID"
// There are 54 species at time of writing, so offsetting the max ID
// by 53 gives us ranges like 154, one for each species.
value={minAssetId != null ? Number(minAssetId) + 53 : ""}
// There are 55 species at time of writing, so offsetting the max ID
// by 54 gives us ranges like 155, one for each species.
value={minAssetId != null ? Number(minAssetId) + 54 : ""}
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" />
<Button type="submit" size="xs" isDisabled={minAssetId == null}>
Preview
@ -156,7 +133,7 @@ function BulkAddBodySpecificAssetsForm({ onSubmit }) {
);
}
function AllItemLayersSupportModalContent({ item }) {
function AllItemLayersSupportModalContent({ item, bulkAddProposal }) {
const { loading, error, data } = useQuery(
gql`
query AllItemLayersSupportModal($itemId: ID!) {
@ -193,7 +170,57 @@ function AllItemLayersSupportModalContent({ item }) {
{ 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 (
<Flex align="center" justify="center" minHeight="64">
<HangerSpinner />
@ -201,17 +228,21 @@ function AllItemLayersSupportModalContent({ item }) {
);
}
if (error) {
return <ErrorMessage>{error.message}</ErrorMessage>;
if (error || error2) {
return <ErrorMessage>{(error || error2).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);
}
let itemAppearances = data.item?.allAppearances || [];
itemAppearances = mergeBulkAddProposalIntoItemAppearances(
itemAppearances,
bulkAddProposal,
bulkAddProposalData
);
itemAppearances = [...itemAppearances].sort((a, b) => {
const aKey = getSortKeyForBody(a.body);
const bKey = getSortKeyForBody(b.body);
return aKey.localeCompare(bKey);
});
return (
<Wrap justify="center" spacing="4">
@ -246,6 +277,18 @@ function ItemAppearanceCard({ item, itemAppearance }) {
</Heading>
<Box height="3" />
<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) => (
<WrapItem key={itemLayer.id}>
<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
// within each color.
return `${color.isStandard ? "A" : "Z"}-${color.name}-${species.name}`;
@ -286,4 +335,71 @@ function capitalize(str) {
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;

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`
fragment ItemAppearanceForOutfitPreview on ItemAppearance {
id
layers {
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 {
label @client # HACK: This is for Support tools, but other views don't need it
}
...AppearanceLayerForOutfitPreview
}
...ItemAppearanceForGetVisibleLayers
}
${appearanceLayerFragment}
${itemAppearanceFragmentForGetVisibleLayers}
`;

View file

@ -92,6 +92,11 @@ const typeDefs = gql`
}
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
# filtered by type. Cache for 30 minutes (we re-sync with Neopets every
# hour).
@ -253,6 +258,16 @@ const resolvers = {
},
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 (
_,
{ type },

View file

@ -23,6 +23,10 @@ const typeDefs = gql`
id: ID!
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.
# We use this to preload the standard body IDs, so that items stay when
# switching between standard colors.
@ -60,6 +64,7 @@ const typeDefs = gql`
species: Species!
color: Color!
pose: Pose!
body: Body!
bodyId: ID!
layers: [AppearanceLayer!]!
@ -134,6 +139,34 @@ const resolvers = {
const speciesTranslation = await speciesTranslationLoader.load(id);
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 }) => {
const petType = await petTypeBySpeciesAndColorLoader.load({
speciesId: id,
@ -195,6 +228,11 @@ const resolvers = {
const petType = await petTypeLoader.load(petState.petTypeId);
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 }) => {
const petState = await petStateLoader.load(id);
const petType = await petTypeLoader.load(petState.petTypeId);