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;