forked from OpenNeo/impress
583 lines
15 KiB
JavaScript
583 lines
15 KiB
JavaScript
|
import React from "react";
|
||
|
import gql from "graphql-tag";
|
||
|
import { useMutation, useQuery } from "@apollo/client";
|
||
|
import {
|
||
|
Box,
|
||
|
Button,
|
||
|
IconButton,
|
||
|
Select,
|
||
|
Spinner,
|
||
|
Switch,
|
||
|
Wrap,
|
||
|
WrapItem,
|
||
|
useDisclosure,
|
||
|
UnorderedList,
|
||
|
ListItem,
|
||
|
} from "@chakra-ui/react";
|
||
|
import {
|
||
|
ArrowBackIcon,
|
||
|
ArrowForwardIcon,
|
||
|
CheckCircleIcon,
|
||
|
EditIcon,
|
||
|
} from "@chakra-ui/icons";
|
||
|
|
||
|
import HangerSpinner from "../../components/HangerSpinner";
|
||
|
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
|
||
|
import useSupport from "./useSupport";
|
||
|
import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal";
|
||
|
import { petAppearanceForPosePickerFragment } from "../PosePicker";
|
||
|
|
||
|
function PosePickerSupport({
|
||
|
speciesId,
|
||
|
colorId,
|
||
|
pose,
|
||
|
appearanceId,
|
||
|
initialFocusRef,
|
||
|
dispatchToOutfit,
|
||
|
}) {
|
||
|
const { loading, error, data } = useQuery(
|
||
|
gql`
|
||
|
query PosePickerSupport($speciesId: ID!, $colorId: ID!) {
|
||
|
petAppearances(speciesId: $speciesId, colorId: $colorId) {
|
||
|
id
|
||
|
pose
|
||
|
isGlitched
|
||
|
layers {
|
||
|
id
|
||
|
zone {
|
||
|
id
|
||
|
label @client
|
||
|
}
|
||
|
|
||
|
# For AppearanceLayerSupportModal
|
||
|
remoteId
|
||
|
bodyId
|
||
|
swfUrl
|
||
|
svgUrl
|
||
|
imageUrl: imageUrlV2(idealSize: SIZE_600)
|
||
|
canvasMovieLibraryUrl
|
||
|
}
|
||
|
restrictedZones {
|
||
|
id
|
||
|
label @client
|
||
|
}
|
||
|
|
||
|
# For AppearanceLayerSupportModal to know the name
|
||
|
species {
|
||
|
id
|
||
|
name
|
||
|
}
|
||
|
color {
|
||
|
id
|
||
|
name
|
||
|
}
|
||
|
|
||
|
# Also, anything the PosePicker wants that isn't here, so that we
|
||
|
# don't have to refetch anything when we change the canonical poses.
|
||
|
...PetAppearanceForPosePicker
|
||
|
}
|
||
|
|
||
|
...CanonicalPetAppearances
|
||
|
}
|
||
|
${canonicalPetAppearancesFragment}
|
||
|
${petAppearanceForPosePickerFragment}
|
||
|
`,
|
||
|
{ variables: { speciesId, colorId } }
|
||
|
);
|
||
|
|
||
|
// Resize the Popover when we toggle loading state, because it probably will
|
||
|
// affect the content size. appearanceId might also affect content size, if
|
||
|
// it occupies different zones.
|
||
|
//
|
||
|
// NOTE: This also triggers an additional necessary resize when the component
|
||
|
// first mounts, because PosePicker lazy-loads it, so it actually
|
||
|
// mounting affects size too.
|
||
|
React.useLayoutEffect(() => {
|
||
|
// HACK: To trigger a Popover resize, we simulate a window resize event,
|
||
|
// because Popover listens for window resizes to reposition itself.
|
||
|
// I've also filed an issue requesting an official API!
|
||
|
// https://github.com/chakra-ui/chakra-ui/issues/1853
|
||
|
window.dispatchEvent(new Event("resize"));
|
||
|
}, [loading, appearanceId]);
|
||
|
|
||
|
const canonicalAppearanceIdsByPose = {
|
||
|
HAPPY_MASC: data?.happyMasc?.id,
|
||
|
SAD_MASC: data?.sadMasc?.id,
|
||
|
SICK_MASC: data?.sickMasc?.id,
|
||
|
HAPPY_FEM: data?.happyFem?.id,
|
||
|
SAD_FEM: data?.sadFem?.id,
|
||
|
SICK_FEM: data?.sickFem?.id,
|
||
|
UNCONVERTED: data?.unconverted?.id,
|
||
|
UNKNOWN: data?.unknown?.id,
|
||
|
};
|
||
|
const canonicalAppearanceIds = Object.values(
|
||
|
canonicalAppearanceIdsByPose
|
||
|
).filter((id) => id);
|
||
|
|
||
|
const providedAppearanceId = appearanceId;
|
||
|
if (!providedAppearanceId) {
|
||
|
appearanceId = canonicalAppearanceIdsByPose[pose];
|
||
|
}
|
||
|
|
||
|
// If you don't already have `appearanceId` in the outfit state, opening up
|
||
|
// PosePickerSupport adds it! That way, if you make changes that affect the
|
||
|
// canonical poses, we'll still stay navigated to this one.
|
||
|
React.useEffect(() => {
|
||
|
if (!providedAppearanceId && appearanceId) {
|
||
|
dispatchToOutfit({
|
||
|
type: "setPose",
|
||
|
pose,
|
||
|
appearanceId,
|
||
|
});
|
||
|
}
|
||
|
}, [providedAppearanceId, appearanceId, pose, dispatchToOutfit]);
|
||
|
|
||
|
if (loading) {
|
||
|
return (
|
||
|
<Box display="flex" justifyContent="center">
|
||
|
<HangerSpinner size="sm" />
|
||
|
</Box>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
if (error) {
|
||
|
return (
|
||
|
<Box color="red.400" marginTop="8">
|
||
|
{error.message}
|
||
|
</Box>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
const currentPetAppearance = data.petAppearances.find(
|
||
|
(pa) => pa.id === appearanceId
|
||
|
);
|
||
|
if (!currentPetAppearance) {
|
||
|
return (
|
||
|
<Box color="red.400" marginTop="8">
|
||
|
Pet appearance with ID {JSON.stringify(appearanceId)} not found
|
||
|
</Box>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
<Box>
|
||
|
<PosePickerSupportNavigator
|
||
|
petAppearances={data.petAppearances}
|
||
|
currentPetAppearance={currentPetAppearance}
|
||
|
canonicalAppearanceIds={canonicalAppearanceIds}
|
||
|
dropdownRef={initialFocusRef}
|
||
|
dispatchToOutfit={dispatchToOutfit}
|
||
|
/>
|
||
|
<Metadata
|
||
|
fontSize="sm"
|
||
|
// Build a new copy of this tree when the appearance changes, to reset
|
||
|
// things like element focus and mutation state!
|
||
|
key={currentPetAppearance.id}
|
||
|
>
|
||
|
<MetadataLabel>DTI ID:</MetadataLabel>
|
||
|
<MetadataValue>{appearanceId}</MetadataValue>
|
||
|
<MetadataLabel>Pose:</MetadataLabel>
|
||
|
<MetadataValue>
|
||
|
<PosePickerSupportPoseFields
|
||
|
petAppearance={currentPetAppearance}
|
||
|
speciesId={speciesId}
|
||
|
colorId={colorId}
|
||
|
/>
|
||
|
</MetadataValue>
|
||
|
<MetadataLabel>Layers:</MetadataLabel>
|
||
|
<MetadataValue>
|
||
|
<Wrap spacing="1">
|
||
|
{currentPetAppearance.layers
|
||
|
.map((layer) => [`${layer.zone.label} (${layer.zone.id})`, layer])
|
||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||
|
.map(([text, layer]) => (
|
||
|
<WrapItem key={layer.id}>
|
||
|
<PetLayerSupportLink
|
||
|
outfitState={{ speciesId, colorId, pose }}
|
||
|
petAppearance={currentPetAppearance}
|
||
|
layer={layer}
|
||
|
>
|
||
|
{text}
|
||
|
<EditIcon marginLeft="1" />
|
||
|
</PetLayerSupportLink>
|
||
|
</WrapItem>
|
||
|
))}
|
||
|
</Wrap>
|
||
|
</MetadataValue>
|
||
|
<MetadataLabel>Restricts:</MetadataLabel>
|
||
|
<MetadataValue maxHeight="64" overflowY="auto">
|
||
|
{currentPetAppearance.restrictedZones.length > 0 ? (
|
||
|
<UnorderedList>
|
||
|
{currentPetAppearance.restrictedZones
|
||
|
.map((zone) => `${zone.label} (${zone.id})`)
|
||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||
|
.map((zoneText) => (
|
||
|
<ListItem key={zoneText}>{zoneText}</ListItem>
|
||
|
))}
|
||
|
</UnorderedList>
|
||
|
) : (
|
||
|
<Box fontStyle="italic" opacity="0.8">
|
||
|
None
|
||
|
</Box>
|
||
|
)}
|
||
|
</MetadataValue>
|
||
|
</Metadata>
|
||
|
</Box>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
function PetLayerSupportLink({ outfitState, petAppearance, layer, children }) {
|
||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||
|
return (
|
||
|
<>
|
||
|
<Button size="xs" onClick={onOpen}>
|
||
|
{children}
|
||
|
</Button>
|
||
|
<AppearanceLayerSupportModal
|
||
|
outfitState={outfitState}
|
||
|
petAppearance={petAppearance}
|
||
|
layer={layer}
|
||
|
isOpen={isOpen}
|
||
|
onClose={onClose}
|
||
|
/>
|
||
|
</>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
function PosePickerSupportNavigator({
|
||
|
petAppearances,
|
||
|
currentPetAppearance,
|
||
|
canonicalAppearanceIds,
|
||
|
dropdownRef,
|
||
|
dispatchToOutfit,
|
||
|
}) {
|
||
|
const currentIndex = petAppearances.indexOf(currentPetAppearance);
|
||
|
const prevPetAppearance = petAppearances[currentIndex - 1];
|
||
|
const nextPetAppearance = petAppearances[currentIndex + 1];
|
||
|
|
||
|
return (
|
||
|
<Box
|
||
|
display="flex"
|
||
|
justifyContent="flex-end"
|
||
|
marginBottom="4"
|
||
|
// Space for the position-absolute PosePicker mode switcher
|
||
|
paddingLeft="12"
|
||
|
>
|
||
|
<IconButton
|
||
|
aria-label="Go to previous appearance"
|
||
|
icon={<ArrowBackIcon />}
|
||
|
size="sm"
|
||
|
marginRight="2"
|
||
|
isDisabled={prevPetAppearance == null}
|
||
|
onClick={() =>
|
||
|
dispatchToOutfit({
|
||
|
type: "setPose",
|
||
|
pose: prevPetAppearance.pose,
|
||
|
appearanceId: prevPetAppearance.id,
|
||
|
})
|
||
|
}
|
||
|
/>
|
||
|
<Select
|
||
|
size="sm"
|
||
|
width="auto"
|
||
|
value={currentPetAppearance.id}
|
||
|
ref={dropdownRef}
|
||
|
onChange={(e) => {
|
||
|
const id = e.target.value;
|
||
|
const petAppearance = petAppearances.find((pa) => pa.id === id);
|
||
|
dispatchToOutfit({
|
||
|
type: "setPose",
|
||
|
pose: petAppearance.pose,
|
||
|
appearanceId: petAppearance.id,
|
||
|
});
|
||
|
}}
|
||
|
>
|
||
|
{petAppearances.map((pa) => (
|
||
|
<option key={pa.id} value={pa.id}>
|
||
|
{POSE_NAMES[pa.pose]}{" "}
|
||
|
{canonicalAppearanceIds.includes(pa.id) && "⭐️"}
|
||
|
{pa.isGlitched && "👾"} ({pa.id})
|
||
|
</option>
|
||
|
))}
|
||
|
</Select>
|
||
|
<IconButton
|
||
|
aria-label="Go to next appearance"
|
||
|
icon={<ArrowForwardIcon />}
|
||
|
size="sm"
|
||
|
marginLeft="2"
|
||
|
isDisabled={nextPetAppearance == null}
|
||
|
onClick={() =>
|
||
|
dispatchToOutfit({
|
||
|
type: "setPose",
|
||
|
pose: nextPetAppearance.pose,
|
||
|
appearanceId: nextPetAppearance.id,
|
||
|
})
|
||
|
}
|
||
|
/>
|
||
|
</Box>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
function PosePickerSupportPoseFields({ petAppearance, speciesId, colorId }) {
|
||
|
const { supportSecret } = useSupport();
|
||
|
|
||
|
const [mutatePose, poseMutation] = useMutation(
|
||
|
gql`
|
||
|
mutation PosePickerSupportSetPetAppearancePose(
|
||
|
$appearanceId: ID!
|
||
|
$pose: Pose!
|
||
|
$supportSecret: String!
|
||
|
) {
|
||
|
setPetAppearancePose(
|
||
|
appearanceId: $appearanceId
|
||
|
pose: $pose
|
||
|
supportSecret: $supportSecret
|
||
|
) {
|
||
|
id
|
||
|
pose
|
||
|
}
|
||
|
}
|
||
|
`,
|
||
|
{
|
||
|
refetchQueries: [
|
||
|
{
|
||
|
query: gql`
|
||
|
query PosePickerSupportRefetchCanonicalAppearances(
|
||
|
$speciesId: ID!
|
||
|
$colorId: ID!
|
||
|
) {
|
||
|
...CanonicalPetAppearances
|
||
|
}
|
||
|
${canonicalPetAppearancesFragment}
|
||
|
`,
|
||
|
variables: { speciesId, colorId },
|
||
|
},
|
||
|
],
|
||
|
}
|
||
|
);
|
||
|
|
||
|
const [mutateIsGlitched, isGlitchedMutation] = useMutation(
|
||
|
gql`
|
||
|
mutation PosePickerSupportSetPetAppearanceIsGlitched(
|
||
|
$appearanceId: ID!
|
||
|
$isGlitched: Boolean!
|
||
|
$supportSecret: String!
|
||
|
) {
|
||
|
setPetAppearanceIsGlitched(
|
||
|
appearanceId: $appearanceId
|
||
|
isGlitched: $isGlitched
|
||
|
supportSecret: $supportSecret
|
||
|
) {
|
||
|
id
|
||
|
isGlitched
|
||
|
}
|
||
|
}
|
||
|
`,
|
||
|
{
|
||
|
refetchQueries: [
|
||
|
{
|
||
|
query: gql`
|
||
|
query PosePickerSupportRefetchCanonicalAppearances(
|
||
|
$speciesId: ID!
|
||
|
$colorId: ID!
|
||
|
) {
|
||
|
...CanonicalPetAppearances
|
||
|
}
|
||
|
${canonicalPetAppearancesFragment}
|
||
|
`,
|
||
|
variables: { speciesId, colorId },
|
||
|
},
|
||
|
],
|
||
|
}
|
||
|
);
|
||
|
|
||
|
return (
|
||
|
<Box>
|
||
|
<Box display="flex" flexDirection="row" alignItems="center">
|
||
|
<Select
|
||
|
size="sm"
|
||
|
value={petAppearance.pose}
|
||
|
flex="0 1 200px"
|
||
|
icon={
|
||
|
poseMutation.loading ? (
|
||
|
<Spinner />
|
||
|
) : poseMutation.data ? (
|
||
|
<CheckCircleIcon />
|
||
|
) : undefined
|
||
|
}
|
||
|
onChange={(e) => {
|
||
|
const pose = e.target.value;
|
||
|
mutatePose({
|
||
|
variables: {
|
||
|
appearanceId: petAppearance.id,
|
||
|
pose,
|
||
|
supportSecret,
|
||
|
},
|
||
|
optimisticResponse: {
|
||
|
__typename: "Mutation",
|
||
|
setPetAppearancePose: {
|
||
|
__typename: "PetAppearance",
|
||
|
id: petAppearance.id,
|
||
|
pose,
|
||
|
},
|
||
|
},
|
||
|
}).catch((e) => {
|
||
|
/* Discard errors here; we'll show them in the UI! */
|
||
|
});
|
||
|
}}
|
||
|
isInvalid={poseMutation.error != null}
|
||
|
>
|
||
|
{Object.entries(POSE_NAMES).map(([pose, name]) => (
|
||
|
<option key={pose} value={pose}>
|
||
|
{name}
|
||
|
</option>
|
||
|
))}
|
||
|
</Select>
|
||
|
<Select
|
||
|
size="sm"
|
||
|
marginLeft="2"
|
||
|
flex="0 1 150px"
|
||
|
value={petAppearance.isGlitched}
|
||
|
icon={
|
||
|
isGlitchedMutation.loading ? (
|
||
|
<Spinner />
|
||
|
) : isGlitchedMutation.data ? (
|
||
|
<CheckCircleIcon />
|
||
|
) : undefined
|
||
|
}
|
||
|
onChange={(e) => {
|
||
|
const isGlitched = e.target.value === "true";
|
||
|
mutateIsGlitched({
|
||
|
variables: {
|
||
|
appearanceId: petAppearance.id,
|
||
|
isGlitched,
|
||
|
supportSecret,
|
||
|
},
|
||
|
optimisticResponse: {
|
||
|
__typename: "Mutation",
|
||
|
setPetAppearanceIsGlitched: {
|
||
|
__typename: "PetAppearance",
|
||
|
id: petAppearance.id,
|
||
|
isGlitched,
|
||
|
},
|
||
|
},
|
||
|
}).catch((e) => {
|
||
|
/* Discard errors here; we'll show them in the UI! */
|
||
|
});
|
||
|
}}
|
||
|
isInvalid={isGlitchedMutation.error != null}
|
||
|
>
|
||
|
<option value="false">Valid</option>
|
||
|
<option value="true">Glitched</option>
|
||
|
</Select>
|
||
|
</Box>
|
||
|
{poseMutation.error && (
|
||
|
<Box color="red.400">{poseMutation.error.message}</Box>
|
||
|
)}
|
||
|
{isGlitchedMutation.error && (
|
||
|
<Box color="red.400">{isGlitchedMutation.error.message}</Box>
|
||
|
)}
|
||
|
</Box>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function PosePickerSupportSwitch({ isChecked, onChange }) {
|
||
|
return (
|
||
|
<Box as="label" display="flex" flexDirection="row" alignItems="center">
|
||
|
<Box fontSize="sm">
|
||
|
<span role="img" aria-label="Support">
|
||
|
💖
|
||
|
</span>
|
||
|
</Box>
|
||
|
<Switch
|
||
|
colorScheme="pink"
|
||
|
marginLeft="1"
|
||
|
size="sm"
|
||
|
isChecked={isChecked}
|
||
|
onChange={onChange}
|
||
|
/>
|
||
|
</Box>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
const POSE_NAMES = {
|
||
|
HAPPY_MASC: "Happy Masc",
|
||
|
HAPPY_FEM: "Happy Fem",
|
||
|
SAD_MASC: "Sad Masc",
|
||
|
SAD_FEM: "Sad Fem",
|
||
|
SICK_MASC: "Sick Masc",
|
||
|
SICK_FEM: "Sick Fem",
|
||
|
UNCONVERTED: "Unconverted",
|
||
|
UNKNOWN: "Unknown",
|
||
|
};
|
||
|
|
||
|
const canonicalPetAppearancesFragment = gql`
|
||
|
fragment CanonicalPetAppearances on Query {
|
||
|
happyMasc: petAppearance(
|
||
|
speciesId: $speciesId
|
||
|
colorId: $colorId
|
||
|
pose: HAPPY_MASC
|
||
|
) {
|
||
|
id
|
||
|
}
|
||
|
|
||
|
sadMasc: petAppearance(
|
||
|
speciesId: $speciesId
|
||
|
colorId: $colorId
|
||
|
pose: SAD_MASC
|
||
|
) {
|
||
|
id
|
||
|
}
|
||
|
|
||
|
sickMasc: petAppearance(
|
||
|
speciesId: $speciesId
|
||
|
colorId: $colorId
|
||
|
pose: SICK_MASC
|
||
|
) {
|
||
|
id
|
||
|
}
|
||
|
|
||
|
happyFem: petAppearance(
|
||
|
speciesId: $speciesId
|
||
|
colorId: $colorId
|
||
|
pose: HAPPY_FEM
|
||
|
) {
|
||
|
id
|
||
|
}
|
||
|
|
||
|
sadFem: petAppearance(
|
||
|
speciesId: $speciesId
|
||
|
colorId: $colorId
|
||
|
pose: SAD_FEM
|
||
|
) {
|
||
|
id
|
||
|
}
|
||
|
|
||
|
sickFem: petAppearance(
|
||
|
speciesId: $speciesId
|
||
|
colorId: $colorId
|
||
|
pose: SICK_FEM
|
||
|
) {
|
||
|
id
|
||
|
}
|
||
|
|
||
|
unconverted: petAppearance(
|
||
|
speciesId: $speciesId
|
||
|
colorId: $colorId
|
||
|
pose: UNCONVERTED
|
||
|
) {
|
||
|
id
|
||
|
}
|
||
|
|
||
|
unknown: petAppearance(
|
||
|
speciesId: $speciesId
|
||
|
colorId: $colorId
|
||
|
pose: UNKNOWN
|
||
|
) {
|
||
|
id
|
||
|
}
|
||
|
}
|
||
|
`;
|
||
|
|
||
|
export default PosePickerSupport;
|