import * as React from "react"; import gql from "graphql-tag"; import { useQuery, useMutation } from "@apollo/client"; import { css } from "emotion"; import { Badge, Box, Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay, FormControl, FormErrorMessage, FormHelperText, FormLabel, HStack, Link, Select, Spinner, Stack, useBreakpointValue, useDisclosure, } from "@chakra-ui/core"; import { CheckCircleIcon, EditIcon, ExternalLinkIcon } from "@chakra-ui/icons"; import ItemLayerSupportModal from "./ItemLayerSupportModal"; import { OutfitLayers } from "../../components/OutfitPreview"; import useOutfitAppearance from "../../components/useOutfitAppearance"; import { OutfitStateContext } from "../useOutfitState"; import useSupportSecret from "./useSupportSecret"; /** * ItemSupportDrawer shows Support UI for the item when open. * * This component controls the drawer element. The actual content is imported * from another lazy-loaded component! */ function ItemSupportDrawer({ item, isOpen, onClose }) { const placement = useBreakpointValue({ base: "bottom", lg: "right", // TODO: There's a bug in the Chakra RC that doesn't read the breakpoint // specification correctly - we need these extra keys until it's fixed! // https://github.com/chakra-ui/chakra-ui/issues/1444 0: "bottom", 1: "bottom", 2: "right", 3: "right", }); return ( <Drawer placement={placement} isOpen={isOpen} onClose={onClose} // blockScrollOnMount doesn't matter on our fullscreen UI, but the // default implementation breaks out layout somehow 🤔 idk, let's not! blockScrollOnMount={false} > <DrawerOverlay> <DrawerContent maxHeight={placement === "bottom" ? "90vh" : undefined} overflow="auto" > <DrawerCloseButton /> <DrawerHeader color="green.800"> {item.name} <Badge colorScheme="pink" marginLeft="3"> Support <span aria-hidden="true">💖</span> </Badge> </DrawerHeader> <DrawerBody color="green.800"> <Box paddingBottom="5"> <Stack spacing="8"> <ItemSupportSpecialColorFields item={item} /> <ItemSupportAppearanceFields item={item} /> </Stack> </Box> </DrawerBody> </DrawerContent> </DrawerOverlay> </Drawer> ); } function ItemSupportSpecialColorFields({ item }) { const supportSecret = useSupportSecret(); const { loading: itemLoading, error: itemError, data: itemData } = useQuery( gql` query ItemSupportDrawerManualSpecialColor($itemId: ID!) { item(id: $itemId) { id manualSpecialColor { id } } } `, { variables: { itemId: item.id }, // HACK: I think it's a bug in @apollo/client 3.1.1 that, if the // optimistic response sets `manualSpecialColor` to null, the query // doesn't update, even though its cache has updated :/ // // This cheap trick of changing the display name every re-render // persuades Apollo that this is a different query, so it re-checks // its cache and finds the empty `manualSpecialColor`. Weird! displayName: `ItemSupportDrawerManualSpecialColor-${new Date()}`, } ); const { loading: colorsLoading, error: colorsError, data: colorsData, } = useQuery( gql` query ItemSupportDrawerAllColors { allColors { id name isStandard } } ` ); const [ mutate, { loading: mutationLoading, error: mutationError, data: mutationData }, ] = useMutation(gql` mutation ItemSupportDrawerSetManualSpecialColor( $itemId: ID! $colorId: ID $supportSecret: String! ) { setManualSpecialColor( itemId: $itemId colorId: $colorId supportSecret: $supportSecret ) { id manualSpecialColor { id } } } `); const nonStandardColors = colorsData?.allColors?.filter((c) => !c.isStandard) || []; nonStandardColors.sort((a, b) => a.name.localeCompare(b.name)); return ( <FormControl isInvalid={colorsError || itemError || mutationError ? true : false} > <FormLabel>Special color</FormLabel> <Select placeholder={ colorsLoading || itemLoading ? "Loading…" : "Default: Auto-detect from item description" } value={itemData?.item?.manualSpecialColor?.id} isDisabled={mutationLoading} icon={ colorsLoading || itemLoading || mutationLoading ? ( <Spinner /> ) : mutationData ? ( <CheckCircleIcon /> ) : undefined } onChange={(e) => { const colorId = e.target.value || null; const color = colorId != null ? { __typename: "Color", id: colorId } : null; mutate({ variables: { itemId: item.id, colorId, supportSecret, }, optimisticResponse: { __typename: "Mutation", setManualSpecialColor: { __typename: "Item", id: item.id, manualSpecialColor: color, }, }, }); }} > {nonStandardColors.map((color) => ( <option key={color.id} value={color.id}> {color.name} </option> ))} </Select> {colorsError && ( <FormErrorMessage>{colorsError.message}</FormErrorMessage> )} {itemError && <FormErrorMessage>{itemError.message}</FormErrorMessage>} {mutationError && ( <FormErrorMessage>{mutationError.message}</FormErrorMessage> )} {!colorsError && !itemError && !mutationError && ( <FormHelperText> This controls which previews we show on the{" "} <Link href={`https://impress.openneo.net/items/${ item.id }-${item.name.replace(/ /g, "-")}`} color="green.500" isExternal > item page <ExternalLinkIcon /> </Link> . </FormHelperText> )} </FormControl> ); } /** * NOTE: This component takes `outfitState` from context, rather than as a prop * from its parent, for performance reasons. We want `Item` to memoize * and generally skip re-rendering on `outfitState` changes, and to make * sure the context isn't accessed when the drawer is closed. So we use * it here, only when the drawer is open! */ function ItemSupportAppearanceFields({ item }) { const outfitState = React.useContext(OutfitStateContext); const { speciesId, colorId, pose } = outfitState; const { error, visibleLayers } = useOutfitAppearance({ speciesId, colorId, pose, wornItemIds: [item.id], }); const biologyLayers = visibleLayers.filter((l) => l.source === "pet"); const itemLayers = visibleLayers.filter((l) => l.source === "item"); itemLayers.sort((a, b) => a.zone.depth - b.zone.depth); return ( <FormControl> <FormLabel>Appearance layers</FormLabel> <HStack spacing="4" overflow="auto" paddingX="1"> {itemLayers.map((itemLayer) => ( <ItemSupportAppearanceLayer key={itemLayer.id} item={item} itemLayer={itemLayer} biologyLayers={biologyLayers} outfitState={outfitState} /> ))} </HStack> {error && <FormErrorMessage>{error.message}</FormErrorMessage>} </FormControl> ); } function ItemSupportAppearanceLayer({ item, itemLayer, biologyLayers, outfitState, }) { const { isOpen, onOpen, onClose } = useDisclosure(); return ( <Box as="button" width="150px" textAlign="center" fontSize="xs" onClick={onOpen} > <Box width="150px" height="150px" marginBottom="1" boxShadow="md" borderRadius="md" position="relative" > <OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} /> <Box className={css` opacity: 0; transition: opacity 0.2s; button:hover &, button:focus & { opacity: 1; } /* On touch devices, always show the icon, to clarify that this is * an interactable object! (Whereas I expect other devices to * discover things by exploratory hover or focus!) */ @media (hover: none) { opacity: 1; } `} background="green.100" borderRadius="full" boxShadow="sm" position="absolute" bottom="2" right="2" padding="2" alignItems="center" justifyContent="center" width="32px" height="32px" > <EditIcon boxSize="16px" position="relative" top="-2px" right="-1px" /> </Box> </Box> <Box fontWeight="bold">{itemLayer.zone.label}</Box> <Box>Zone ID: {itemLayer.zone.id}</Box> <Box>DTI ID: {itemLayer.id}</Box> <ItemLayerSupportModal item={item} itemLayer={itemLayer} outfitState={outfitState} isOpen={isOpen} onClose={onClose} /> </Box> ); } export default ItemSupportDrawer;