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 AppearanceLayerSupportUploadModal from "./AppearanceLayerSupportUploadModal"; 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"; /** * AppearanceLayerSupportModal offers Support info and tools for a specific item * appearance layer. Open it by clicking a layer from ItemSupportDrawer. */ function AppearanceLayerSupportModal({ item, // Specify this or `petAppearance` petAppearance, // Specify this or `item` layer, outfitState, // speciesId, colorId, pose isOpen, onClose, }) { const [selectedBodyId, setSelectedBodyId] = React.useState(layer.bodyId); const [selectedKnownGlitches, setSelectedKnownGlitches] = React.useState( layer.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 parentName = item ? item.name : `${petAppearance.color.name} ${petAppearance.species.name} ${petAppearance.id}`; const [mutate, { loading: mutationLoading, error: mutationError }] = useMutation( gql` mutation ApperanceLayerSupportSetLayerBodyId( $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: layer.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 ${layer.id}: ${parentName}`, }); }, }, ); // 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( layer.swfUrl, ); return ( <Modal size="xl" isOpen={isOpen} onClose={onClose}> <ModalOverlay> <ModalContent> <ModalHeader> Layer {layer.id}: {parentName} </ModalHeader> <ModalCloseButton /> <ModalBody> <Metadata> <MetadataLabel>DTI ID:</MetadataLabel> <MetadataValue>{layer.id}</MetadataValue> <MetadataLabel>Neopets ID:</MetadataLabel> <MetadataValue>{layer.remoteId}</MetadataValue> <MetadataLabel>Zone:</MetadataLabel> <MetadataValue> {layer.zone.label} ({layer.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"> {layer.canvasMovieLibraryUrl ? ( <Button as="a" size="xs" target="_blank" href={layer.canvasMovieLibraryUrl} colorScheme="teal" > Movie <ExternalLinkIcon ml="1" /> </Button> ) : ( <Button size="xs" isDisabled> No Movie </Button> )} {layer.svgUrl ? ( <Button as="a" size="xs" target="_blank" href={layer.svgUrl} colorScheme="teal" > SVG <ExternalLinkIcon ml="1" /> </Button> ) : ( <Button size="xs" isDisabled> No SVG </Button> )} {layer.imageUrl ? ( <Button as="a" size="xs" target="_blank" href={layer.imageUrl} colorScheme="teal" > PNG <ExternalLinkIcon ml="1" /> </Button> ) : ( <Button size="xs" isDisabled> No PNG </Button> )} <Button as="a" size="xs" target="_blank" href={layer.swfUrl} colorScheme="teal" > SWF <ExternalLinkIcon ml="1" /> </Button> <Box flex="1 1 0" /> {item && ( <> <Button size="xs" colorScheme="gray" onClick={() => setUploadModalIsOpen(true)} > Upload PNG <ChevronRightIcon /> </Button> <AppearanceLayerSupportUploadModal item={item} layer={layer} isOpen={uploadModalIsOpen} onClose={() => setUploadModalIsOpen(false)} /> </> )} </HStack> </MetadataValue> </Metadata> <Box height="8" /> {item && ( <> <AppearanceLayerSupportPetCompatibilityFields item={item} layer={layer} outfitState={outfitState} selectedBodyId={selectedBodyId} previewBiology={previewBiology} onChangeBodyId={setSelectedBodyId} onChangePreviewBiology={setPreviewBiology} /> <Box height="8" /> </> )} <AppearanceLayerSupportKnownGlitchesFields selectedKnownGlitches={selectedKnownGlitches} onChange={setSelectedKnownGlitches} /> </ModalBody> <ModalFooter> {item && ( <AppearanceLayerSupportModalRemoveButton item={item} layer={layer} 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().catch((e) => { /* Discard errors here; we'll show them in the UI! */ }) } flex="0 0 auto" > Save changes </Button> </ModalFooter> </ModalContent> </ModalOverlay> </Modal> ); } function AppearanceLayerSupportPetCompatibilityFields({ item, layer, 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, layer]} /> </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 AppearanceLayerSupportKnownGlitchesFields({ 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="OFFICIAL_MOVIE_IS_INCORRECT"> Official Movie is incorrect{" "} <Box display="inline" color="gray.400" fontSize="sm"> (Will display a message) </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 AppearanceLayerSupportModalRemoveButton({ item, layer, outfitState, onRemoveSuccess, }) { const { isOpen, onOpen, onClose } = useDisclosure(); const toast = useToast(); const { supportSecret } = useSupport(); const [mutate, { loading, error }] = useMutation( gql` mutation AppearanceLayerSupportRemoveButton( $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: layer.id, itemId: item.id, outfitSpeciesId: outfitState.speciesId, outfitColorId: outfitState.colorId, supportSecret, }, onCompleted: () => { onClose(); onRemoveSuccess(); toast({ status: "success", title: `Removed layer ${layer.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 {layer.id} ({layer.zone.label}) from {item.name}? </ModalHeader> <ModalBody> <Box as="p" marginBottom="4"> This will permanently-ish remove Layer {layer.id} ( {layer.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 {layer.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 = /^https?:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/; function convertSwfUrlToPossibleManifestUrls(swfUrl) { const match = new URL(swfUrl, "https://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 [ `https://images.neopets.com/cp/${type}/data/${folders}/manifest.json`, `https://images.neopets.com/cp/${type}/data/${folders}_${hash}/manifest.json`, ]; } export default AppearanceLayerSupportModal;