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 ( Layer {layer.id}: {parentName} DTI ID: {layer.id} Neopets ID: {layer.remoteId} Zone: {layer.zone.label} ({layer.zone.id}) Assets: {layer.canvasMovieLibraryUrl ? ( ) : ( )} {layer.svgUrl ? ( ) : ( )} {layer.imageUrl ? ( ) : ( )} {item && ( <> setUploadModalIsOpen(false)} /> )} {item && ( <> )} {item && ( )} {mutationError && ( {mutationError.message} )} ); } 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 ( Pet compatibility onChangeBodyId(newBodyId)} marginBottom="4" > Fits all pets{" "} (Body ID: 0) Fits all pets with the same body as:{" "} (Body ID:{" "} {appearanceBodyId == null ? ( ) : ( appearanceBodyId )} ) { 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); } }} /> {!error && ( If it doesn't look right, try some other options until it does! )} {error && {error.message}} ); } function AppearanceLayerSupportKnownGlitchesFields({ selectedKnownGlitches, onChange, }) { return ( Known glitches Official SWF is incorrect{" "} (Will display a message) Official SVG is incorrect{" "} (Will use the PNG instead) Official Movie is incorrect{" "} (Will display a message) Displays incorrectly, but cause unknown{" "} (Will display a vague message) Fits all pets on-site, but should not{" "} (TNT's fault. Will show a message, and keep the compatibility settings above.) Only fits pets with other body-specific assets{" "} (DTI's fault: bodyId=0 is a lie! Will mark incompatible for some pets.) ); } 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 ( <> Remove Layer {layer.id} ({layer.zone.label}) from {item.name}? This will permanently-ish remove Layer {layer.id} ( {layer.zone.label}) from this item. 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! Are you sure you want to remove Layer {layer.id} from this item? {error && ( {error.message} )} ); } 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;