impress-2020/src/app/WardrobePage/support/ItemSupportDrawer.js

525 lines
14 KiB
JavaScript

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,
Text,
useBreakpointValue,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/core";
import { CheckCircleIcon, EditIcon, ExternalLinkIcon } from "@chakra-ui/icons";
import ItemLayerSupportModal from "./ItemLayerSupportModal";
import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
import { OutfitLayers } from "../../components/OutfitPreview";
import useOutfitAppearance from "../../components/useOutfitAppearance";
import { OutfitStateContext } from "../useOutfitState";
import useSupport from "./useSupport";
/**
* 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" });
return (
<Drawer
placement={placement}
size="md"
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>
{item.name}
<Badge colorScheme="pink" marginLeft="3">
Support <span aria-hidden="true">💖</span>
</Badge>
</DrawerHeader>
<DrawerBody paddingBottom="5">
<Metadata>
<MetadataLabel>Item ID:</MetadataLabel>
<MetadataValue>{item.id}</MetadataValue>
<MetadataLabel>Restricted zones:</MetadataLabel>
<MetadataValue>
<ItemSupportRestrictedZones item={item} />
</MetadataValue>
</Metadata>
<Stack spacing="8" marginTop="6">
<ItemSupportFields item={item} />
<ItemSupportAppearanceLayers item={item} />
</Stack>
</DrawerBody>
</DrawerContent>
</DrawerOverlay>
</Drawer>
);
}
function ItemSupportRestrictedZones({ item }) {
const { speciesId, colorId } = React.useContext(OutfitStateContext);
// NOTE: It would be a better reflection of the data to just query restricted
// zones right off the item... but we already have them in cache from
// the appearance, so query them that way to be instant in practice!
const { loading, error, data } = useQuery(
gql`
query ItemSupportRestrictedZones(
$itemId: ID!
$speciesId: ID!
$colorId: ID!
) {
item(id: $itemId) {
id
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
restrictedZones {
id
label
}
}
}
}
`,
{ variables: { itemId: item.id, speciesId, colorId } }
);
if (loading) {
return <Spinner size="xs" />;
}
if (error) {
return <Text color="red.400">{error.message}</Text>;
}
const restrictedZones = data?.item?.appearanceOn?.restrictedZones || [];
if (restrictedZones.length === 0) {
return "None";
}
return restrictedZones
.map((z) => `${z.label} (${z.id})`)
.sort()
.join(", ");
}
function ItemSupportFields({ item }) {
const { loading, error, data } = useQuery(
gql`
query ItemSupportFields($itemId: ID!) {
item(id: $itemId) {
id
manualSpecialColor {
id
}
explicitlyBodySpecific
}
}
`,
{
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: `ItemSupportFields-${new Date()}`,
}
);
const errorColor = useColorModeValue("red.500", "red.300");
return (
<>
{error && <Box color={errorColor}>{error.message}</Box>}
<ItemSupportSpecialColorFields
loading={loading}
error={error}
item={item}
manualSpecialColor={data?.item?.manualSpecialColor?.id}
/>
<ItemSupportPetCompatibilityRuleFields
loading={loading}
error={error}
item={item}
explicitlyBodySpecific={data?.item?.explicitlyBodySpecific}
/>
</>
);
}
function ItemSupportSpecialColorFields({
loading,
error,
item,
manualSpecialColor,
}) {
const { supportSecret } = useSupport();
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 onChange = React.useCallback(
(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,
},
},
}).catch((e) => {
// Ignore errors from the promise, because we'll handle them on render!
});
},
[item.id, mutate, supportSecret]
);
const nonStandardColors =
colorsData?.allColors?.filter((c) => !c.isStandard) || [];
nonStandardColors.sort((a, b) => a.name.localeCompare(b.name));
const linkColor = useColorModeValue("green.500", "green.300");
return (
<FormControl isInvalid={Boolean(error || colorsError || mutationError)}>
<FormLabel>Special color</FormLabel>
<Select
placeholder={
loading || colorsLoading
? "Loading…"
: "Default: Auto-detect from item description"
}
value={manualSpecialColor?.id}
isDisabled={mutationLoading}
icon={
loading || colorsLoading || mutationLoading ? (
<Spinner />
) : mutationData ? (
<CheckCircleIcon />
) : undefined
}
onChange={onChange}
>
{nonStandardColors.map((color) => (
<option key={color.id} value={color.id}>
{color.name}
</option>
))}
</Select>
{colorsError && (
<FormErrorMessage>{colorsError.message}</FormErrorMessage>
)}
{mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
)}
{!colorsError && !mutationError && (
<FormHelperText>
This controls which previews we show on the{" "}
<Link
href={`https://impress.openneo.net/items/${
item.id
}-${item.name.replace(/ /g, "-")}`}
color={linkColor}
isExternal
>
item page <ExternalLinkIcon />
</Link>
.
</FormHelperText>
)}
</FormControl>
);
}
function ItemSupportPetCompatibilityRuleFields({
loading,
error,
item,
explicitlyBodySpecific,
}) {
const { supportSecret } = useSupport();
const [
mutate,
{ loading: mutationLoading, error: mutationError, data: mutationData },
] = useMutation(gql`
mutation ItemSupportDrawerSetItemExplicitlyBodySpecific(
$itemId: ID!
$explicitlyBodySpecific: Boolean!
$supportSecret: String!
) {
setItemExplicitlyBodySpecific(
itemId: $itemId
explicitlyBodySpecific: $explicitlyBodySpecific
supportSecret: $supportSecret
) {
id
explicitlyBodySpecific
}
}
`);
const onChange = React.useCallback(
(e) => {
const explicitlyBodySpecific = e.target.value === "true";
mutate({
variables: {
itemId: item.id,
explicitlyBodySpecific,
supportSecret,
},
optimisticResponse: {
__typename: "Mutation",
setItemExplicitlyBodySpecific: {
__typename: "Item",
id: item.id,
explicitlyBodySpecific,
},
},
}).catch((e) => {
// Ignore errors from the promise, because we'll handle them on render!
});
},
[item.id, mutate, supportSecret]
);
return (
<FormControl isInvalid={Boolean(error || mutationError)}>
<FormLabel>Pet compatibility rule</FormLabel>
<Select
value={explicitlyBodySpecific ? "true" : "false"}
isDisabled={mutationLoading}
icon={
loading || mutationLoading ? (
<Spinner />
) : mutationData ? (
<CheckCircleIcon />
) : undefined
}
onChange={onChange}
>
{loading ? (
<option>Loading</option>
) : (
<>
<option value="false">
Default: Auto-detect whether this fits all pets
</option>
<option value="true">
Body specific: Always different for each pet body
</option>
</>
)}
</Select>
{mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
)}
{!mutationError && (
<FormHelperText>
By default, we assume Background-y zones fit all pets the same. When
items don't follow that rule, we can override it.
</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 ItemSupportAppearanceLayers({ item }) {
const outfitState = React.useContext(OutfitStateContext);
const { speciesId, colorId, pose, appearanceId } = outfitState;
const { error, visibleLayers } = useOutfitAppearance({
speciesId,
colorId,
pose,
appearanceId,
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();
const iconButtonBgColor = useColorModeValue("green.100", "green.300");
const iconButtonColor = useColorModeValue("green.800", "gray.900");
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={iconButtonBgColor}
color={iconButtonColor}
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;