impress/src/app/WardrobePage/support/ItemLayerSupportModal.js
Matchu 1e4063f0d9 Add glitch DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN
I'm applying this to the "MiniMME11-S1: Approaching Eventide Skirt" on the Acara, which seems to load all 1000 images from the manifest, but then show no animation and no errors. Not sure what's up, and not inclined to deep-debug until we have a check on whether it works on-site!
2021-04-12 19:36:08 -07:00

622 lines
19 KiB
JavaScript

import * as React from "react";
import gql from "graphql-tag";
import { useMutation } from "@apollo/client";
import {
Button,
Box,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
Spinner,
useDisclosure,
useToast,
CheckboxGroup,
VStack,
Checkbox,
} from "@chakra-ui/react";
import { ChevronRightIcon, ExternalLinkIcon } from "@chakra-ui/icons";
import ItemLayerSupportUploadModal from "./ItemLayerSupportUploadModal";
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
import { OutfitLayers } from "../../components/OutfitPreview";
import SpeciesColorPicker from "../../components/SpeciesColorPicker";
import useOutfitAppearance, {
itemAppearanceFragment,
} from "../../components/useOutfitAppearance";
import useSupport from "./useSupport";
/**
* ItemLayerSupportModal offers Support info and tools for a specific item
* appearance layer. Open it by clicking a layer from ItemSupportDrawer.
*/
function ItemLayerSupportModal({
item,
itemLayer,
outfitState, // speciesId, colorId, pose
isOpen,
onClose,
}) {
const [selectedBodyId, setSelectedBodyId] = React.useState(itemLayer.bodyId);
const [selectedKnownGlitches, setSelectedKnownGlitches] = React.useState(
itemLayer.knownGlitches
);
const [previewBiology, setPreviewBiology] = React.useState({
speciesId: outfitState.speciesId,
colorId: outfitState.colorId,
pose: outfitState.pose,
isValid: true,
});
const [uploadModalIsOpen, setUploadModalIsOpen] = React.useState(false);
const { supportSecret } = useSupport();
const toast = useToast();
const [
mutate,
{ loading: mutationLoading, error: mutationError },
] = useMutation(
gql`
mutation ItemSupportSetLayerBodyId(
$layerId: ID!
$bodyId: ID!
$knownGlitches: [AppearanceLayerKnownGlitch!]!
$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
}
}
}
setLayerKnownGlitches(
layerId: $layerId
knownGlitches: $knownGlitches
supportSecret: $supportSecret
) {
id
knownGlitches
svgUrl # Affected by OFFICIAL_SVG_IS_INCORRECT
}
}
${itemAppearanceFragment}
`,
{
variables: {
layerId: itemLayer.id,
bodyId: selectedBodyId,
knownGlitches: selectedKnownGlitches,
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}`,
});
},
}
);
// TODO: Would be nicer to just learn the correct URL from the server, but we
// don't happen to be saving it, and it would be extra stuff to put on
// the GraphQL request for non-Support users. We could also just try
// loading them, but, ehhh…
const [newManifestUrl, oldManifestUrl] = convertSwfUrlToPossibleManifestUrls(
itemLayer.swfUrl
);
return (
<Modal size="xl" isOpen={isOpen} onClose={onClose}>
<ModalOverlay>
<ModalContent>
<ModalHeader>
Layer {itemLayer.id}: {item.name}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Metadata>
<MetadataLabel>DTI ID:</MetadataLabel>
<MetadataValue>{itemLayer.id}</MetadataValue>
<MetadataLabel>Neopets ID:</MetadataLabel>
<MetadataValue>{itemLayer.remoteId}</MetadataValue>
<MetadataLabel>Zone:</MetadataLabel>
<MetadataValue>
{itemLayer.zone.label} ({itemLayer.zone.id})
</MetadataValue>
<MetadataLabel>Assets:</MetadataLabel>
<MetadataValue>
<HStack spacing="2">
<Button
as="a"
size="xs"
target="_blank"
href={newManifestUrl}
colorScheme="teal"
>
Manifest (new) <ExternalLinkIcon ml="1" />
</Button>
<Button
as="a"
size="xs"
target="_blank"
href={oldManifestUrl}
colorScheme="teal"
>
Manifest (old) <ExternalLinkIcon ml="1" />
</Button>
</HStack>
<HStack spacing="2" marginTop="1">
{itemLayer.canvasMovieLibraryUrl ? (
<Button
as="a"
size="xs"
target="_blank"
href={itemLayer.canvasMovieLibraryUrl}
colorScheme="teal"
>
Movie <ExternalLinkIcon ml="1" />
</Button>
) : (
<Button size="xs" isDisabled>
No Movie
</Button>
)}
{itemLayer.svgUrl ? (
<Button
as="a"
size="xs"
target="_blank"
href={itemLayer.svgUrl}
colorScheme="teal"
>
SVG <ExternalLinkIcon ml="1" />
</Button>
) : (
<Button size="xs" isDisabled>
No SVG
</Button>
)}
{itemLayer.imageUrl ? (
<Button
as="a"
size="xs"
target="_blank"
href={itemLayer.imageUrl}
colorScheme="teal"
>
PNG <ExternalLinkIcon ml="1" />
</Button>
) : (
<Button size="xs" isDisabled>
No PNG
</Button>
)}
<Button
as="a"
size="xs"
target="_blank"
href={itemLayer.swfUrl}
colorScheme="teal"
>
SWF <ExternalLinkIcon ml="1" />
</Button>
<Box flex="1 1 0" />
<Button
size="xs"
colorScheme="gray"
onClick={() => setUploadModalIsOpen(true)}
>
Upload PNG <ChevronRightIcon />
</Button>
<ItemLayerSupportUploadModal
item={item}
itemLayer={itemLayer}
isOpen={uploadModalIsOpen}
onClose={() => setUploadModalIsOpen(false)}
/>
</HStack>
</MetadataValue>
</Metadata>
<Box height="8" />
<ItemLayerSupportPetCompatibilityFields
item={item}
itemLayer={itemLayer}
outfitState={outfitState}
selectedBodyId={selectedBodyId}
previewBiology={previewBiology}
onChangeBodyId={setSelectedBodyId}
onChangePreviewBiology={setPreviewBiology}
/>
<Box height="8" />
<ItemLayerSupportKnownGlitchesFields
selectedKnownGlitches={selectedKnownGlitches}
onChange={setSelectedKnownGlitches}
/>
</ModalBody>
<ModalFooter>
<ItemLayerSupportModalRemoveButton
item={item}
itemLayer={itemLayer}
outfitState={outfitState}
onRemoveSuccess={onClose}
/>
<Box flex="1 0 0" />
{mutationError && (
<Box
color="red.400"
fontSize="sm"
marginLeft="8"
marginRight="2"
textAlign="right"
>
{mutationError.message}
</Box>
)}
<Button
isLoading={mutationLoading}
colorScheme="green"
onClick={mutate}
flex="0 0 auto"
>
Save changes
</Button>
</ModalFooter>
</ModalContent>
</ModalOverlay>
</Modal>
);
}
function ItemLayerSupportPetCompatibilityFields({
item,
itemLayer,
outfitState,
selectedBodyId,
previewBiology,
onChangeBodyId,
onChangePreviewBiology,
}) {
const [selectedBiology, setSelectedBiology] = React.useState(previewBiology);
const {
loading,
error,
visibleLayers,
bodyId: appearanceBodyId,
} = useOutfitAppearance({
speciesId: previewBiology.speciesId,
colorId: previewBiology.colorId,
pose: previewBiology.pose,
wornItemIds: [item.id],
});
const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
// After we touch a species/color selector and null out `bodyId`, when the
// appearance body ID loads in, select it as the new body ID.
//
// This might move the radio button away from "all pets", but I think that's
// a _less_ surprising experience: if you're touching the pickers, then
// that's probably where you head is.
React.useEffect(() => {
if (selectedBodyId == null && appearanceBodyId != null) {
onChangeBodyId(appearanceBodyId);
}
}, [selectedBodyId, appearanceBodyId, onChangeBodyId]);
return (
<FormControl isInvalid={error || !selectedBiology.isValid ? true : false}>
<FormLabel fontWeight="bold">Pet compatibility</FormLabel>
<RadioGroup
colorScheme="green"
value={selectedBodyId}
onChange={(newBodyId) => onChangeBodyId(newBodyId)}
marginBottom="4"
>
<Radio value="0">
Fits all pets{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Body ID: 0)
</Box>
</Radio>
<Radio as="div" value={appearanceBodyId} marginTop="2">
Fits all pets with the same body as:{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Body ID:{" "}
{appearanceBodyId == null ? (
<Spinner size="sm" />
) : (
appearanceBodyId
)}
)
</Box>
</Radio>
</RadioGroup>
<Box display="flex" flexDirection="column" alignItems="center">
<Box
width="150px"
height="150px"
marginTop="2"
marginBottom="2"
boxShadow="md"
borderRadius="md"
>
<OutfitLayers
loading={loading}
visibleLayers={[...biologyLayers, itemLayer]}
/>
</Box>
<SpeciesColorPicker
speciesId={selectedBiology.speciesId}
colorId={selectedBiology.colorId}
idealPose={outfitState.pose}
size="sm"
showPlaceholders
onChange={(species, color, isValid, pose) => {
const speciesId = species.id;
const colorId = color.id;
setSelectedBiology({ speciesId, colorId, isValid, pose });
if (isValid) {
onChangePreviewBiology({ speciesId, colorId, isValid, pose });
// Also temporarily null out the body ID. We'll switch to the new
// body ID once it's loaded.
onChangeBodyId(null);
}
}}
/>
<Box height="1" />
{!error && (
<FormHelperText>
If it doesn't look right, try some other options until it does!
</FormHelperText>
)}
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
</Box>
</FormControl>
);
}
function ItemLayerSupportKnownGlitchesFields({
selectedKnownGlitches,
onChange,
}) {
return (
<FormControl>
<FormLabel fontWeight="bold">Known glitches</FormLabel>
<CheckboxGroup value={selectedKnownGlitches} onChange={onChange}>
<VStack spacing="2" align="flex-start">
<Checkbox value="OFFICIAL_SWF_IS_INCORRECT">
Official SWF is incorrect{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Will display a message)
</Box>
</Checkbox>
<Checkbox value="OFFICIAL_SVG_IS_INCORRECT">
Official SVG is incorrect{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Will use the PNG instead)
</Box>
</Checkbox>
<Checkbox value="DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN">
Displays incorrectly, but cause unknown{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(Will display a vague message)
</Box>
</Checkbox>
<Checkbox value="OFFICIAL_BODY_ID_IS_INCORRECT">
Fits all pets on-site, but should not{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(TNT's fault. Will show a message, and keep the compatibility
settings above.)
</Box>
</Checkbox>
<Checkbox value="REQUIRES_OTHER_BODY_SPECIFIC_ASSETS">
Only fits pets with other body-specific assets{" "}
<Box display="inline" color="gray.400" fontSize="sm">
(DTI's fault: bodyId=0 is a lie! Will mark incompatible for some
pets.)
</Box>
</Checkbox>
</VStack>
</CheckboxGroup>
</FormControl>
);
}
function ItemLayerSupportModalRemoveButton({
item,
itemLayer,
outfitState,
onRemoveSuccess,
}) {
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
const { supportSecret } = useSupport();
const [mutate, { loading, error }] = useMutation(
gql`
mutation ItemLayerSupportRemoveButton(
$layerId: ID!
$itemId: ID!
$outfitSpeciesId: ID!
$outfitColorId: ID!
$supportSecret: String!
) {
removeLayerFromItem(
layerId: $layerId
itemId: $itemId
supportSecret: $supportSecret
) {
# This mutation returns the affected layer, and the affected item.
# Fetch the updated appearance for the current outfit, which should
# no longer include this layer. This means you should be able to see
# your changes immediately!
item {
id
appearanceOn(speciesId: $outfitSpeciesId, colorId: $outfitColorId) {
...ItemAppearanceForOutfitPreview
}
}
# The layer's item should be null now, fetch to confirm and update!
layer {
id
item {
id
}
}
}
}
${itemAppearanceFragment}
`,
{
variables: {
layerId: itemLayer.id,
itemId: item.id,
outfitSpeciesId: outfitState.speciesId,
outfitColorId: outfitState.colorId,
supportSecret,
},
onCompleted: () => {
onClose();
onRemoveSuccess();
toast({
status: "success",
title: `Removed layer ${itemLayer.id} from ${item.name}`,
});
},
}
);
return (
<>
<Button colorScheme="red" flex="0 0 auto" onClick={onOpen}>
Remove
</Button>
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay>
<ModalContent>
<ModalCloseButton />
<ModalHeader>
Remove Layer {itemLayer.id} ({itemLayer.zone.label}) from{" "}
{item.name}?
</ModalHeader>
<ModalBody>
<Box as="p" marginBottom="4">
This will permanently-ish remove Layer {itemLayer.id} (
{itemLayer.zone.label}) from this item.
</Box>
<Box as="p" marginBottom="4">
If you remove a correct layer by mistake, re-modeling should fix
it, or Matchu can restore it if you write down the layer ID
before proceeding!
</Box>
<Box as="p" marginBottom="4">
Are you sure you want to remove Layer {itemLayer.id} from this
item?
</Box>
</ModalBody>
<ModalFooter>
<Button flex="0 0 auto" onClick={onClose}>
Close
</Button>
<Box flex="1 0 0" />
{error && (
<Box
color="red.400"
fontSize="sm"
marginLeft="8"
marginRight="2"
textAlign="right"
>
{error.message}
</Box>
)}
<Button
colorScheme="red"
flex="0 0 auto"
onClick={() =>
mutate().catch((e) => {
/* Discard errors here; we'll show them in the UI! */
})
}
isLoading={loading}
>
Yes, remove permanently
</Button>
</ModalFooter>
</ModalContent>
</ModalOverlay>
</Modal>
</>
);
}
const SWF_URL_PATTERN = /^http:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/;
function convertSwfUrlToPossibleManifestUrls(swfUrl) {
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)}`);
}
const type = match[1];
const folders = match[2];
const hash = match[3];
// TODO: There are a few potential manifest URLs in play! Long-term, we
// should get this from modeling data. But these are some good guesses!
return [
`http://images.neopets.com/cp/${type}/data/${folders}/manifest.json`,
`http://images.neopets.com/cp/${type}/data/${folders}_${hash}/manifest.json`,
];
}
export default ItemLayerSupportModal;