diff --git a/src/app/WardrobePage/support/ItemSupportAppearanceLayerModal.js b/src/app/WardrobePage/support/ItemSupportAppearanceLayerModal.js index 70efab3..1afafd9 100644 --- a/src/app/WardrobePage/support/ItemSupportAppearanceLayerModal.js +++ b/src/app/WardrobePage/support/ItemSupportAppearanceLayerModal.js @@ -1,4 +1,6 @@ import * as React from "react"; +import gql from "graphql-tag"; +import { useMutation } from "@apollo/client"; import { Button, Box, @@ -17,12 +19,16 @@ import { Radio, RadioGroup, Spinner, + useToast, } from "@chakra-ui/core"; import { ExternalLinkIcon } from "@chakra-ui/icons"; import { OutfitLayers } from "../../components/OutfitPreview"; import SpeciesColorPicker from "../../components/SpeciesColorPicker"; -import useOutfitAppearance from "../../components/useOutfitAppearance"; +import useOutfitAppearance, { + itemAppearanceFragment, +} from "../../components/useOutfitAppearance"; +import useSupportSecret from "./useSupportSecret"; function ItemSupportAppearanceLayerModal({ item, @@ -31,6 +37,82 @@ function ItemSupportAppearanceLayerModal({ isOpen, onClose, }) { + const [selectedBodyId, setSelectedBodyId] = React.useState(itemLayer.bodyId); + const [previewBiology, setPreviewBiology] = React.useState({ + speciesId: outfitState.speciesId, + colorId: outfitState.colorId, + pose: outfitState.pose, + isValid: true, + }); + const supportSecret = useSupportSecret(); + const toast = useToast(); + + const [ + mutate, + { loading: mutationLoading, error: mutationError }, + ] = useMutation( + gql` + mutation ItemSupportSetLayerBodyId( + $layerId: ID! + $bodyId: ID! + $supportSecret: String! + $outfitSpeciesId: ID! + $outfitColorId: ID! + $formPreviewSpeciesId: ID! + $formPreviewColorId: ID! + ) { + setLayerBodyId( + layerId: $layerId + bodyId: $bodyId + supportSecret: $supportSecret + ) { + # This mutation returns the affected AppearanceLayer. Fetch the + # updated fields, including the appearance on the outfit pet and the + # form preview pet, to automatically update our cached appearance in + # the rest of the app. That means you should be able to see your + # changes immediately! + id + bodyId + item { + id + appearanceOnOutfit: appearanceOn( + speciesId: $outfitSpeciesId + colorId: $outfitColorId + ) { + ...ItemAppearanceForOutfitPreview + } + + appearanceOnFormPreviewPet: appearanceOn( + speciesId: $formPreviewSpeciesId + colorId: $formPreviewColorId + ) { + ...ItemAppearanceForOutfitPreview + } + } + } + } + ${itemAppearanceFragment} + `, + { + variables: { + layerId: itemLayer.id, + bodyId: selectedBodyId, + supportSecret, + outfitSpeciesId: outfitState.speciesId, + outfitColorId: outfitState.colorId, + formPreviewSpeciesId: previewBiology.speciesId, + formPreviewColorId: previewBiology.colorId, + }, + onCompleted: () => { + onClose(); + toast({ + status: "success", + title: `Saved layer ${itemLayer.id}: ${item.name}`, + }); + }, + } + ); + return ( @@ -41,7 +123,7 @@ function ItemSupportAppearanceLayerModal({ - ID: + DTI ID: {itemLayer.id} Zone: @@ -88,10 +170,30 @@ function ItemSupportAppearanceLayerModal({ item={item} itemLayer={itemLayer} outfitState={outfitState} + selectedBodyId={selectedBodyId} + previewBiology={previewBiology} + onChangeBodyId={setSelectedBodyId} + onChangePreviewBiology={setPreviewBiology} /> - + {mutationError && ( + + {mutationError.message} + + )} + @@ -103,15 +205,12 @@ function ItemSupportAppearanceLayerPetCompatibility({ item, itemLayer, outfitState, + selectedBodyId, + previewBiology, + onChangeBodyId, + onChangePreviewBiology, }) { - const [bodyId, setBodyId] = React.useState(itemLayer.bodyId); - const [selectedBiology, setSelectedBiology] = React.useState({ - speciesId: outfitState.speciesId, - colorId: outfitState.colorId, - pose: outfitState.pose, - isValid: true, - }); - const [visibleBiology, setVisibleBiology] = React.useState(selectedBiology); + const [selectedBiology, setSelectedBiology] = React.useState(previewBiology); const { loading, @@ -119,9 +218,9 @@ function ItemSupportAppearanceLayerPetCompatibility({ visibleLayers, bodyId: appearanceBodyId, } = useOutfitAppearance({ - speciesId: visibleBiology.speciesId, - colorId: visibleBiology.colorId, - pose: visibleBiology.pose, + speciesId: previewBiology.speciesId, + colorId: previewBiology.colorId, + pose: previewBiology.pose, wornItemIds: [item.id], }); @@ -130,18 +229,18 @@ function ItemSupportAppearanceLayerPetCompatibility({ // When the appearance body ID changes, select it as the new body ID. (This // is an effect because it happens after the appearance finishes loading!) React.useEffect(() => { - if (bodyId !== "0") { - setBodyId(appearanceBodyId); + if (selectedBodyId !== "0") { + onChangeBodyId(appearanceBodyId); } - }, [bodyId, appearanceBodyId]); + }, [selectedBodyId, appearanceBodyId, onChangeBodyId]); return ( Pet compatibility setBodyId(newBodyId)} + value={selectedBodyId} + onChange={(newBodyId) => onChangeBodyId(newBodyId)} marginBottom="4" > @@ -189,7 +288,7 @@ function ItemSupportAppearanceLayerPetCompatibility({ setSelectedBiology({ speciesId, colorId, isValid, pose }); if (isValid) { - setVisibleBiology({ speciesId, colorId, isValid, pose }); + onChangePreviewBiology({ speciesId, colorId, isValid, pose }); } }} /> diff --git a/src/app/WardrobePage/support/ItemSupportDrawer.js b/src/app/WardrobePage/support/ItemSupportDrawer.js index 4c2e3b5..521e651 100644 --- a/src/app/WardrobePage/support/ItemSupportDrawer.js +++ b/src/app/WardrobePage/support/ItemSupportDrawer.js @@ -250,6 +250,7 @@ function ItemSupportAppearanceFields({ item, outfitState }) { {itemLayers.map((itemLayer) => ( {itemLayer.zone.label} Zone ID: {itemLayer.zone.id} - Layer ID: {itemLayer.id} + DTI ID: {itemLayer.id} { + bodyId: async ({ id }, _, { swfAssetLoader }) => { + const layer = await swfAssetLoader.load(id); + return layer.bodyId; + }, + zone: async ({ id }, _, { swfAssetLoader, zoneLoader }) => { + const layer = await swfAssetLoader.load(id); const zone = await zoneLoader.load(layer.zoneId); return zone; }, - imageUrl: (layer, { size }) => { + imageUrl: async ({ id }, { size }, { swfAssetLoader }) => { + const layer = await swfAssetLoader.load(id); + if (!layer.hasImage) { return null; } @@ -311,7 +329,9 @@ const resolvers = { `/${rid1}/${rid2}/${rid3}/${rid}/${sizeNum}x${sizeNum}.png?v2-${time}` ); }, - svgUrl: async (layer, _, { svgLogger }) => { + svgUrl: async ({ id }, _, { swfAssetLoader, svgLogger }) => { + const layer = await swfAssetLoader.load(id); + const manifest = await neopets.loadAssetManifest(layer.url); if (!manifest) { svgLogger.log("no-manifest"); @@ -339,6 +359,24 @@ const resolvers = { const url = new URL(assetDatum.path, "http://images.neopets.com"); return url.toString(); }, + item: async ({ id }, _, { db }) => { + // TODO: If this becomes a popular request, we'll definitely need to + // loaderize this! I'm cheating for now because it's just Support, one at + // a time. + const [rows] = await db.query( + ` + SELECT parent_id FROM parents_swf_assets + WHERE swf_asset_id = ? AND parent_type = "Item" LIMIT 1; + `, + [id] + ); + + if (rows.length === 0) { + return null; + } + + return { id: String(rows[0].parent_id) }; + }, }, Zone: { depth: async ({ id }, _, { zoneLoader }) => { @@ -515,6 +553,27 @@ const resolvers = { return { id: itemId }; }, + + setLayerBodyId: async (_, { layerId, bodyId, supportSecret }, { db }) => { + if (supportSecret !== process.env["SUPPORT_SECRET"]) { + throw new Error(`Support secret is incorrect. Try setting up again?`); + } + + const [ + result, + ] = await db.execute( + `UPDATE swf_assets SET body_id = ? WHERE id = ? LIMIT 1`, + [bodyId, layerId] + ); + + if (result.affectedRows !== 1) { + throw new Error( + `Expected to affect 1 layer, but affected ${result.affectedRows}` + ); + } + + return { id: layerId }; + }, }, }; diff --git a/src/server/loaders.js b/src/server/loaders.js index 069e1f8..fbf29b8 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -223,7 +223,22 @@ const buildPetTypeBySpeciesAndColorLoader = (db, loaders) => ); }); -const buildItemSwfAssetLoader = (db) => +const buildSwfAssetLoader = (db) => + new DataLoader(async (swfAssetIds) => { + const qs = swfAssetIds.map((_) => "?").join(","); + const [rows, _] = await db.execute( + `SELECT * FROM swf_assets WHERE id IN (${qs})`, + swfAssetIds + ); + + const entities = rows.map(normalizeRow); + + return swfAssetIds.map((swfAssetId) => + entities.find((e) => e.id === swfAssetId) + ); + }); + +const buildItemSwfAssetLoader = (db, loaders) => new DataLoader(async (itemAndBodyPairs) => { const conditions = []; const values = []; @@ -245,6 +260,10 @@ const buildItemSwfAssetLoader = (db) => const entities = rows.map(normalizeRow); + for (const swfAsset of entities) { + loaders.swfAssetLoader.prime(swfAsset.id, swfAsset); + } + return itemAndBodyPairs.map(({ itemId, bodyId }) => entities.filter( (e) => @@ -253,7 +272,7 @@ const buildItemSwfAssetLoader = (db) => ); }); -const buildPetSwfAssetLoader = (db) => +const buildPetSwfAssetLoader = (db, loaders) => new DataLoader(async (petStateIds) => { const qs = petStateIds.map((_) => "?").join(","); const [rows, _] = await db.execute( @@ -267,6 +286,10 @@ const buildPetSwfAssetLoader = (db) => const entities = rows.map(normalizeRow); + for (const swfAsset of entities) { + loaders.swfAssetLoader.prime(swfAsset.id, swfAsset); + } + return petStateIds.map((petStateId) => entities.filter((e) => e.parentId === petStateId) ); @@ -388,8 +411,9 @@ function buildLoaders(db) { db, loaders ); - loaders.itemSwfAssetLoader = buildItemSwfAssetLoader(db); - loaders.petSwfAssetLoader = buildPetSwfAssetLoader(db); + loaders.swfAssetLoader = buildSwfAssetLoader(db); + loaders.itemSwfAssetLoader = buildItemSwfAssetLoader(db, loaders); + loaders.petSwfAssetLoader = buildPetSwfAssetLoader(db, loaders); loaders.outfitLoader = buildOutfitLoader(db); loaders.itemOutfitRelationshipsLoader = buildItemOutfitRelationshipsLoader( db