Emi Matchu
0e314482f7
I haven't been running Prettier consistently on things in this project. Now, it's quick-runnable, and I've got it on everything! Also, I just think tabs are the right default for this kind of thing, and I'm glad to get to switch over to it! (In `package.json`.)
644 lines
17 KiB
JavaScript
644 lines
17 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 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;
|