Still just read-only stuff, but now you can look at all the different poses we have for a species/color! Soon I'll make the pose/glitched stuff editable :3 Some sizable refactors here to add the ability to specify appearance ID as well as pose… most of the app still doesn't use it, it's mostly just lil extra logic to make it win if it's available! (The rationale for making it an override, rather than always tracking appearance ID, is that it gets really inconvenient in practice to //wait// on looking up the appearance ID in order to start loading various queries. Species/color/pose is a more intuitive key, and works better and faster when the canonical appearance is what you want!)
535 lines
14 KiB
JavaScript
535 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 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>
|
|
{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 = useSupportSecret();
|
|
|
|
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 = useSupportSecret();
|
|
|
|
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;
|