impress/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportModal.js
Emi Matchu 0e314482f7 Set Prettier default to tabs instead of spaces, run on all JS
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`.)
2024-09-09 16:11:48 -07:00

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;