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 } # For AppearanceLayerSupportModal remoteId bodyId swfUrl svgUrl imageUrl: imageUrlV2(idealSize: SIZE_600) canvasMovieLibraryUrl } restrictedZones { id label } # 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;