2020-04-25 00:22:49 -07:00
|
|
|
import React from "react";
|
2021-01-03 19:11:46 -08:00
|
|
|
import { ClassNames } from "@emotion/react";
|
2020-04-25 00:22:49 -07:00
|
|
|
import {
|
|
|
|
Box,
|
|
|
|
Editable,
|
|
|
|
EditablePreview,
|
|
|
|
EditableInput,
|
|
|
|
Flex,
|
|
|
|
IconButton,
|
|
|
|
Skeleton,
|
2020-09-01 17:17:45 -07:00
|
|
|
Tooltip,
|
2020-04-25 15:25:51 -07:00
|
|
|
VisuallyHidden,
|
2021-03-29 19:25:00 -07:00
|
|
|
Menu,
|
|
|
|
MenuButton,
|
|
|
|
MenuList,
|
|
|
|
MenuItem,
|
|
|
|
Portal,
|
2021-03-29 19:46:21 -07:00
|
|
|
Button,
|
2021-04-19 03:56:51 -07:00
|
|
|
useToast,
|
2021-04-28 15:04:18 -07:00
|
|
|
Spinner,
|
|
|
|
useColorModeValue,
|
2020-12-25 09:08:33 -08:00
|
|
|
} from "@chakra-ui/react";
|
2021-04-22 02:35:59 -07:00
|
|
|
import {
|
|
|
|
CheckIcon,
|
|
|
|
EditIcon,
|
|
|
|
QuestionIcon,
|
2021-04-28 15:04:18 -07:00
|
|
|
WarningTwoIcon,
|
2021-04-22 02:35:59 -07:00
|
|
|
} from "@chakra-ui/icons";
|
2020-04-25 00:22:49 -07:00
|
|
|
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
2021-04-19 03:56:51 -07:00
|
|
|
import { useHistory } from "react-router-dom";
|
2020-04-25 00:22:49 -07:00
|
|
|
|
2021-04-28 15:04:18 -07:00
|
|
|
import { Delay, Heading1, Heading2, useDebounce } from "../util";
|
2020-08-05 00:25:25 -07:00
|
|
|
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
2021-03-29 19:46:21 -07:00
|
|
|
import { BiRename } from "react-icons/bi";
|
|
|
|
import { IoCloudUploadOutline } from "react-icons/io5";
|
2021-03-29 19:25:00 -07:00
|
|
|
import { MdMoreVert } from "react-icons/md";
|
2021-03-29 19:50:34 -07:00
|
|
|
import useCurrentUser from "../components/useCurrentUser";
|
2021-04-19 03:56:51 -07:00
|
|
|
import gql from "graphql-tag";
|
2021-04-21 21:27:10 -07:00
|
|
|
import { useMutation } from "@apollo/client";
|
2021-04-22 02:35:59 -07:00
|
|
|
import { outfitStatesAreEqual } from "./useOutfitState";
|
2020-04-25 00:22:49 -07:00
|
|
|
|
2020-04-26 01:14:31 -07:00
|
|
|
/**
|
|
|
|
* ItemsPanel shows the items in the current outfit, and lets the user toggle
|
|
|
|
* between them! It also shows an editable outfit name, to help set context.
|
|
|
|
*
|
|
|
|
* Note that this component provides an effective 1 unit of padding around
|
|
|
|
* itself, which is uncommon in this app: we usually prefer to let parents
|
|
|
|
* control the spacing!
|
|
|
|
*
|
|
|
|
* This is because Item has padding, but it's generally not visible; so, to
|
|
|
|
* *look* aligned with the other elements like the headings, the headings need
|
|
|
|
* to have extra padding. Essentially: while the Items _do_ stretch out the
|
|
|
|
* full width of the container, it doesn't look like it!
|
|
|
|
*/
|
2020-04-25 00:22:49 -07:00
|
|
|
function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
|
2020-09-01 17:17:45 -07:00
|
|
|
const { zonesAndItems, incompatibleItems } = outfitState;
|
2020-04-25 00:22:49 -07:00
|
|
|
|
|
|
|
return (
|
2021-01-03 19:11:46 -08:00
|
|
|
<ClassNames>
|
|
|
|
{({ css }) => (
|
|
|
|
<Box>
|
|
|
|
<Box px="1">
|
|
|
|
<OutfitHeading
|
|
|
|
outfitState={outfitState}
|
|
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
|
|
/>
|
|
|
|
</Box>
|
|
|
|
<Flex direction="column">
|
|
|
|
{loading ? (
|
|
|
|
<ItemZoneGroupsSkeleton
|
|
|
|
itemCount={outfitState.allItemIds.length}
|
2020-09-01 17:17:45 -07:00
|
|
|
/>
|
2021-01-03 19:11:46 -08:00
|
|
|
) : (
|
|
|
|
<TransitionGroup component={null}>
|
|
|
|
{zonesAndItems.map(({ zoneLabel, items }) => (
|
|
|
|
<CSSTransition
|
|
|
|
key={zoneLabel}
|
|
|
|
{...fadeOutAndRollUpTransition(css)}
|
|
|
|
>
|
|
|
|
<ItemZoneGroup
|
|
|
|
zoneLabel={zoneLabel}
|
|
|
|
items={items}
|
|
|
|
outfitState={outfitState}
|
|
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
|
|
/>
|
|
|
|
</CSSTransition>
|
|
|
|
))}
|
|
|
|
{incompatibleItems.length > 0 && (
|
|
|
|
<ItemZoneGroup
|
|
|
|
zoneLabel="Incompatible"
|
|
|
|
afterHeader={
|
|
|
|
<Tooltip
|
|
|
|
label="These items don't fit this pet"
|
|
|
|
placement="top"
|
|
|
|
openDelay={100}
|
|
|
|
>
|
|
|
|
<QuestionIcon fontSize="sm" />
|
|
|
|
</Tooltip>
|
|
|
|
}
|
|
|
|
items={incompatibleItems}
|
|
|
|
outfitState={outfitState}
|
|
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
|
|
isDisabled
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</TransitionGroup>
|
2020-09-01 17:17:45 -07:00
|
|
|
)}
|
2021-01-03 19:11:46 -08:00
|
|
|
</Flex>
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</ClassNames>
|
2020-04-25 00:22:49 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-04-26 01:14:31 -07:00
|
|
|
/**
|
|
|
|
* ItemZoneGroup shows the items for a particular zone, and lets the user
|
|
|
|
* toggle between them.
|
|
|
|
*
|
|
|
|
* For each item, it renders a <label> with a visually-hidden radio button and
|
|
|
|
* the Item component (which will visually reflect the radio's state). This
|
|
|
|
* makes the list screen-reader- and keyboard-accessible!
|
|
|
|
*/
|
2020-09-01 17:17:45 -07:00
|
|
|
function ItemZoneGroup({
|
|
|
|
zoneLabel,
|
|
|
|
items,
|
|
|
|
outfitState,
|
|
|
|
dispatchToOutfit,
|
|
|
|
isDisabled = false,
|
|
|
|
afterHeader = null,
|
|
|
|
}) {
|
2020-04-26 01:14:31 -07:00
|
|
|
// onChange is fired when the radio button becomes checked, not unchecked!
|
2020-04-25 15:25:51 -07:00
|
|
|
const onChange = (e) => {
|
|
|
|
const itemId = e.target.value;
|
|
|
|
dispatchToOutfit({ type: "wearItem", itemId });
|
|
|
|
};
|
|
|
|
|
2020-04-26 01:14:31 -07:00
|
|
|
// Clicking the radio button when already selected deselects it - this is how
|
|
|
|
// you can select none!
|
|
|
|
const onClick = (e) => {
|
2020-04-25 15:25:51 -07:00
|
|
|
const itemId = e.target.value;
|
|
|
|
if (outfitState.wornItemIds.includes(itemId)) {
|
|
|
|
// We need the event handler to finish before this, so that simulated
|
|
|
|
// events don't just come back around and undo it - but we can't just
|
|
|
|
// solve that with `preventDefault`, because it breaks the radio's
|
|
|
|
// intended visual updates when we unwear. So, we `setTimeout` to do it
|
|
|
|
// after all event handlers resolve!
|
|
|
|
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-09-24 07:07:05 -07:00
|
|
|
const onRemove = React.useCallback(
|
|
|
|
(itemId) => {
|
|
|
|
dispatchToOutfit({ type: "removeItem", itemId });
|
|
|
|
},
|
|
|
|
[dispatchToOutfit]
|
|
|
|
);
|
|
|
|
|
2020-04-25 15:25:51 -07:00
|
|
|
return (
|
2021-01-03 19:11:46 -08:00
|
|
|
<ClassNames>
|
|
|
|
{({ css }) => (
|
|
|
|
<Box mb="10">
|
|
|
|
<Heading2 display="flex" alignItems="center" mx="1">
|
|
|
|
{zoneLabel}
|
|
|
|
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
|
|
|
|
</Heading2>
|
|
|
|
<ItemListContainer>
|
|
|
|
<TransitionGroup component={null}>
|
|
|
|
{items.map((item) => {
|
|
|
|
const itemNameId =
|
|
|
|
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
|
|
|
|
const itemNode = (
|
|
|
|
<Item
|
|
|
|
item={item}
|
|
|
|
itemNameId={itemNameId}
|
|
|
|
isWorn={
|
|
|
|
!isDisabled && outfitState.wornItemIds.includes(item.id)
|
|
|
|
}
|
|
|
|
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
|
|
|
onRemove={onRemove}
|
|
|
|
isDisabled={isDisabled}
|
|
|
|
/>
|
|
|
|
);
|
2020-09-01 17:17:45 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
return (
|
|
|
|
<CSSTransition
|
|
|
|
key={item.id}
|
|
|
|
{...fadeOutAndRollUpTransition(css)}
|
|
|
|
>
|
|
|
|
{isDisabled ? (
|
|
|
|
itemNode
|
|
|
|
) : (
|
|
|
|
<label>
|
|
|
|
<VisuallyHidden
|
|
|
|
as="input"
|
|
|
|
type="radio"
|
|
|
|
aria-labelledby={itemNameId}
|
|
|
|
name={zoneLabel}
|
|
|
|
value={item.id}
|
|
|
|
checked={outfitState.wornItemIds.includes(item.id)}
|
|
|
|
onChange={onChange}
|
|
|
|
onClick={onClick}
|
|
|
|
onKeyUp={(e) => {
|
|
|
|
if (e.key === " ") {
|
|
|
|
onClick(e);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
{itemNode}
|
|
|
|
</label>
|
|
|
|
)}
|
|
|
|
</CSSTransition>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</TransitionGroup>
|
|
|
|
</ItemListContainer>
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</ClassNames>
|
2020-04-26 01:14:31 -07:00
|
|
|
);
|
|
|
|
}
|
2020-04-25 23:17:59 -07:00
|
|
|
|
2020-05-19 14:43:21 -07:00
|
|
|
/**
|
|
|
|
* ItemZoneGroupSkeletons is a placeholder for when the items are loading.
|
|
|
|
*
|
|
|
|
* We try to match the approximate size of the incoming data! This is
|
|
|
|
* especially nice for when you start with a fresh pet from the homepage, so
|
|
|
|
* we don't show skeleton items that just clear away!
|
|
|
|
*/
|
|
|
|
function ItemZoneGroupsSkeleton({ itemCount }) {
|
|
|
|
const groups = [];
|
|
|
|
for (let i = 0; i < itemCount; i++) {
|
|
|
|
// NOTE: I initially wrote this to return groups of 3, which looks good for
|
|
|
|
// outfit shares I think, but looks bad for pet loading... once shares
|
|
|
|
// become a more common use case, it might be useful to figure out how
|
|
|
|
// to differentiate these cases and show 1-per-group for pets, but
|
|
|
|
// maybe more for built outfits?
|
|
|
|
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
|
|
|
|
}
|
|
|
|
return groups;
|
|
|
|
}
|
|
|
|
|
2020-04-26 01:14:31 -07:00
|
|
|
/**
|
|
|
|
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
|
|
|
|
*/
|
2020-05-19 14:43:21 -07:00
|
|
|
function ItemZoneGroupSkeleton({ itemCount }) {
|
2020-04-26 01:14:31 -07:00
|
|
|
return (
|
|
|
|
<Box mb="10">
|
|
|
|
<Delay>
|
2020-05-19 14:43:21 -07:00
|
|
|
<Skeleton
|
|
|
|
mx="1"
|
|
|
|
// 2.25rem font size, 1.25rem line height
|
|
|
|
height={`${2.25 * 1.25}rem`}
|
|
|
|
width="12rem"
|
|
|
|
/>
|
|
|
|
<ItemListSkeleton count={itemCount} />
|
2020-04-26 01:14:31 -07:00
|
|
|
</Delay>
|
|
|
|
</Box>
|
2020-04-25 15:25:51 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-04-28 15:47:59 -07:00
|
|
|
function useOutfitSaving(outfitState, dispatchToOutfit) {
|
2021-04-19 03:56:51 -07:00
|
|
|
const { isLoggedIn, id: currentUserId } = useCurrentUser();
|
|
|
|
const history = useHistory();
|
|
|
|
const toast = useToast();
|
|
|
|
|
2021-04-28 15:04:18 -07:00
|
|
|
// There's not a way to reset an Apollo mutation state to clear out the error
|
|
|
|
// when the outfit changes… so we track the error state ourselves!
|
|
|
|
const [saveError, setSaveError] = React.useState(null);
|
|
|
|
|
2021-04-22 02:35:59 -07:00
|
|
|
// Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
|
|
|
|
// to the server.
|
|
|
|
const isNewOutfit = outfitState.id == null;
|
|
|
|
|
|
|
|
// Whether this outfit's latest local changes have been saved to the server.
|
2021-04-28 15:47:59 -07:00
|
|
|
// And log it to the console!
|
2021-04-22 02:35:59 -07:00
|
|
|
const latestVersionIsSaved =
|
|
|
|
outfitState.savedOutfitState &&
|
2021-04-28 15:47:59 -07:00
|
|
|
outfitStatesAreEqual(
|
|
|
|
outfitState.outfitStateWithoutExtras,
|
|
|
|
outfitState.savedOutfitState
|
|
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
|
|
console.debug(
|
|
|
|
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
|
|
|
|
latestVersionIsSaved,
|
|
|
|
outfitState.outfitStateWithoutExtras,
|
|
|
|
outfitState.savedOutfitState
|
|
|
|
);
|
|
|
|
}, [
|
|
|
|
latestVersionIsSaved,
|
|
|
|
outfitState.outfitStateWithoutExtras,
|
|
|
|
outfitState.savedOutfitState,
|
|
|
|
]);
|
2021-04-19 03:56:51 -07:00
|
|
|
|
|
|
|
// Only logged-in users can save outfits - and they can only save new outfits,
|
|
|
|
// or outfits they created.
|
|
|
|
const canSaveOutfit =
|
2021-04-22 02:35:59 -07:00
|
|
|
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
|
2021-04-19 03:56:51 -07:00
|
|
|
|
|
|
|
const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation(
|
|
|
|
gql`
|
|
|
|
mutation UseOutfitSaving_SaveOutfit(
|
|
|
|
$id: ID # Optional, is null when saving new outfits.
|
|
|
|
$name: String # Optional, server may fill in a placeholder.
|
|
|
|
$speciesId: ID!
|
|
|
|
$colorId: ID!
|
|
|
|
$pose: Pose!
|
|
|
|
$wornItemIds: [ID!]!
|
|
|
|
$closetedItemIds: [ID!]!
|
|
|
|
) {
|
|
|
|
outfit: saveOutfit(
|
|
|
|
id: $id
|
|
|
|
name: $name
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: $pose
|
|
|
|
wornItemIds: $wornItemIds
|
|
|
|
closetedItemIds: $closetedItemIds
|
|
|
|
) {
|
|
|
|
id
|
|
|
|
name
|
|
|
|
petAppearance {
|
|
|
|
id
|
|
|
|
species {
|
|
|
|
id
|
|
|
|
}
|
|
|
|
color {
|
|
|
|
id
|
|
|
|
}
|
|
|
|
pose
|
|
|
|
}
|
|
|
|
wornItems {
|
|
|
|
id
|
|
|
|
}
|
|
|
|
closetedItems {
|
|
|
|
id
|
|
|
|
}
|
|
|
|
creator {
|
|
|
|
id
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
`,
|
|
|
|
{
|
|
|
|
context: { sendAuth: true },
|
2021-04-20 02:26:31 -07:00
|
|
|
update: (cache, { data: { outfit } }) => {
|
|
|
|
// After save, add this outfit to the current user's outfit list. This
|
|
|
|
// will help when navigating back to Your Outfits, to force a refresh.
|
|
|
|
// https://www.apollographql.com/docs/react/caching/cache-interaction/#example-updating-the-cache-after-a-mutation
|
|
|
|
cache.modify({
|
|
|
|
id: cache.identify(outfit.creator),
|
|
|
|
fields: {
|
2021-04-21 21:27:10 -07:00
|
|
|
outfits: (existingOutfitRefs = []) => {
|
2021-04-20 02:26:31 -07:00
|
|
|
const newOutfitRef = cache.writeFragment({
|
|
|
|
data: outfit,
|
|
|
|
fragment: gql`
|
|
|
|
fragment NewOutfit on Outfit {
|
|
|
|
id
|
|
|
|
}
|
|
|
|
`,
|
|
|
|
});
|
|
|
|
return [...existingOutfitRefs, newOutfitRef];
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
2021-04-28 15:47:59 -07:00
|
|
|
|
|
|
|
// Also, send a `reset` action, to show whatever the server returned.
|
|
|
|
// This is important for suffix changes to `name`, but can also be
|
|
|
|
// relevant for graceful failure when a bug causes a change not to
|
Fix infinite loop bug on initial outfit save
Oops, the sequence here was:
1) Save a new outfit
2) The debounced outfit state still contains id=null, which doesn't match the saved outfit, which triggers an auto-save
3) And now again, the debounced outfit state contains the _previous_ saved outfit ID, but the saved outfit has a _new_ ID, so we save the _previous_ outfit again
and back and forth forever.
Right, ok, simple change: if the saved outfit ID changes, reset the debounced state immediately, so it can't even be out of sync in the first place! (I also considered checking it in the condition, but I didn't really understand what the timing properties of being out of sync due to debouncing would be, and it seemed to not represent the reality I want.)
2021-04-28 16:08:31 -07:00
|
|
|
// persist. (But don't do it if it's not the current outfit anymore,
|
|
|
|
// we don't want laggy mutations to reset the outfit!)
|
|
|
|
if (outfit.id === outfitState.id) {
|
|
|
|
dispatchToOutfit({
|
|
|
|
type: "resetToSavedOutfitData",
|
|
|
|
savedOutfitData: outfit,
|
|
|
|
});
|
|
|
|
}
|
2021-04-20 02:26:31 -07:00
|
|
|
},
|
2021-04-19 03:56:51 -07:00
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2021-04-28 15:04:18 -07:00
|
|
|
const saveOutfitFromProvidedState = React.useCallback(
|
|
|
|
(outfitState) => {
|
|
|
|
sendSaveOutfitMutation({
|
|
|
|
variables: {
|
|
|
|
id: outfitState.id, // Optional, is null when saving new outfits
|
|
|
|
name: outfitState.name, // Optional, server may fill in a placeholder
|
|
|
|
speciesId: outfitState.speciesId,
|
|
|
|
colorId: outfitState.colorId,
|
|
|
|
pose: outfitState.pose,
|
2021-04-28 15:47:59 -07:00
|
|
|
wornItemIds: [...outfitState.wornItemIds],
|
|
|
|
closetedItemIds: [...outfitState.closetedItemIds],
|
2021-04-28 15:04:18 -07:00
|
|
|
},
|
2021-04-19 03:56:51 -07:00
|
|
|
})
|
2021-04-28 15:04:18 -07:00
|
|
|
.then(({ data: { outfit } }) => {
|
|
|
|
// Navigate to the new saved outfit URL. Our Apollo cache should pick
|
|
|
|
// up the data from this mutation response, and combine it with the
|
|
|
|
// existing cached data, to make this smooth without any loading UI.
|
|
|
|
history.push(`/outfits/${outfit.id}`);
|
|
|
|
})
|
|
|
|
.catch((e) => {
|
|
|
|
console.error(e);
|
|
|
|
setSaveError(e);
|
|
|
|
toast({
|
|
|
|
status: "error",
|
|
|
|
title: "Sorry, there was an error saving this outfit!",
|
|
|
|
description: "Maybe check your connection and try again.",
|
|
|
|
});
|
2021-04-19 03:56:51 -07:00
|
|
|
});
|
2021-04-28 15:04:18 -07:00
|
|
|
},
|
|
|
|
// It's important that this callback _doesn't_ change when the outfit
|
|
|
|
// changes, so that the auto-save effect is only responding to the
|
|
|
|
// debounced state!
|
|
|
|
[sendSaveOutfitMutation, history, toast]
|
|
|
|
);
|
|
|
|
|
|
|
|
const saveOutfit = React.useCallback(
|
|
|
|
() => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
|
|
|
|
[saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras]
|
|
|
|
);
|
|
|
|
|
|
|
|
// Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
|
|
|
|
// which only contains the basic fields, and will keep a stable object
|
|
|
|
// identity until actual changes occur. Then, save the outfit after the user
|
|
|
|
// has left it alone for long enough, so long as it's actually different
|
|
|
|
// than the saved state.
|
|
|
|
const debouncedOutfitState = useDebounce(
|
|
|
|
outfitState.outfitStateWithoutExtras,
|
Fix infinite loop bug on initial outfit save
Oops, the sequence here was:
1) Save a new outfit
2) The debounced outfit state still contains id=null, which doesn't match the saved outfit, which triggers an auto-save
3) And now again, the debounced outfit state contains the _previous_ saved outfit ID, but the saved outfit has a _new_ ID, so we save the _previous_ outfit again
and back and forth forever.
Right, ok, simple change: if the saved outfit ID changes, reset the debounced state immediately, so it can't even be out of sync in the first place! (I also considered checking it in the condition, but I didn't really understand what the timing properties of being out of sync due to debouncing would be, and it seemed to not represent the reality I want.)
2021-04-28 16:08:31 -07:00
|
|
|
2000,
|
|
|
|
{
|
|
|
|
// When the outfit ID changes, update the debounced state immediately!
|
|
|
|
forceReset: (debouncedOutfitState, newOutfitState) =>
|
|
|
|
debouncedOutfitState.id !== newOutfitState.id,
|
|
|
|
}
|
2021-04-28 15:04:18 -07:00
|
|
|
);
|
|
|
|
// HACK: This prevents us from auto-saving the outfit state that's still
|
|
|
|
// loading. I worry that this might not catch other loading scenarios
|
|
|
|
// though, like if the species/color/pose is in the GQL cache, but the
|
|
|
|
// items are still loading in... not sure where this would happen tho!
|
|
|
|
const debouncedOutfitStateIsSaveable =
|
|
|
|
debouncedOutfitState.speciesId &&
|
|
|
|
debouncedOutfitState.colorId &&
|
|
|
|
debouncedOutfitState.pose;
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (
|
|
|
|
!isNewOutfit &&
|
|
|
|
canSaveOutfit &&
|
|
|
|
debouncedOutfitStateIsSaveable &&
|
|
|
|
!outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
|
|
|
|
) {
|
|
|
|
console.info(
|
2021-04-28 15:47:59 -07:00
|
|
|
"[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
|
2021-04-28 15:04:18 -07:00
|
|
|
outfitState.savedOutfitState,
|
|
|
|
debouncedOutfitState
|
|
|
|
);
|
|
|
|
saveOutfitFromProvidedState(debouncedOutfitState);
|
|
|
|
}
|
|
|
|
}, [
|
|
|
|
isNewOutfit,
|
|
|
|
canSaveOutfit,
|
|
|
|
debouncedOutfitState,
|
|
|
|
debouncedOutfitStateIsSaveable,
|
|
|
|
outfitState.savedOutfitState,
|
|
|
|
saveOutfitFromProvidedState,
|
|
|
|
]);
|
|
|
|
|
|
|
|
// When the outfit changes, clear out the error state from previous saves.
|
|
|
|
// We'll send the mutation again after the debounce, and we don't want to
|
|
|
|
// show the error UI in the meantime!
|
|
|
|
React.useEffect(() => {
|
|
|
|
setSaveError(null);
|
|
|
|
}, [outfitState.outfitStateWithoutExtras]);
|
2021-04-19 03:56:51 -07:00
|
|
|
|
|
|
|
return {
|
|
|
|
canSaveOutfit,
|
2021-04-22 02:35:59 -07:00
|
|
|
isNewOutfit,
|
2021-04-19 03:56:51 -07:00
|
|
|
isSaving,
|
2021-04-22 02:35:59 -07:00
|
|
|
latestVersionIsSaved,
|
2021-04-28 15:04:18 -07:00
|
|
|
saveError,
|
2021-04-19 03:56:51 -07:00
|
|
|
saveOutfit,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-04-22 02:35:59 -07:00
|
|
|
/**
|
|
|
|
* OutfitSavingIndicator shows a Save button, or the "Saved" or "Saving" state,
|
|
|
|
* if the user can save this outfit. If not, this is empty!
|
|
|
|
*/
|
2021-04-28 15:47:59 -07:00
|
|
|
function OutfitSavingIndicator({ outfitState, dispatchToOutfit }) {
|
2021-04-22 02:35:59 -07:00
|
|
|
const {
|
|
|
|
canSaveOutfit,
|
|
|
|
isNewOutfit,
|
|
|
|
isSaving,
|
|
|
|
latestVersionIsSaved,
|
2021-04-28 15:04:18 -07:00
|
|
|
saveError,
|
2021-04-22 02:35:59 -07:00
|
|
|
saveOutfit,
|
2021-04-28 15:47:59 -07:00
|
|
|
} = useOutfitSaving(outfitState, dispatchToOutfit);
|
2021-04-22 02:35:59 -07:00
|
|
|
|
2021-04-28 15:04:18 -07:00
|
|
|
const errorTextColor = useColorModeValue("red.600", "red.400");
|
|
|
|
|
2021-04-22 02:35:59 -07:00
|
|
|
if (!canSaveOutfit) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isNewOutfit) {
|
|
|
|
return (
|
|
|
|
<Button
|
|
|
|
variant="outline"
|
|
|
|
size="sm"
|
|
|
|
isLoading={isSaving}
|
|
|
|
loadingText="Saving…"
|
|
|
|
leftIcon={
|
|
|
|
<Box
|
|
|
|
// Adjust the visual balance toward the cloud
|
|
|
|
marginBottom="-2px"
|
|
|
|
>
|
|
|
|
<IoCloudUploadOutline />
|
|
|
|
</Box>
|
|
|
|
}
|
|
|
|
onClick={saveOutfit}
|
|
|
|
data-test-id="wardrobe-save-outfit-button"
|
|
|
|
>
|
|
|
|
Save
|
|
|
|
</Button>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-04-28 15:04:18 -07:00
|
|
|
if (isSaving) {
|
|
|
|
return (
|
|
|
|
<Flex
|
|
|
|
align="center"
|
|
|
|
fontSize="xs"
|
|
|
|
data-test-id="wardrobe-outfit-is-saving-indicator"
|
|
|
|
>
|
|
|
|
<Spinner
|
|
|
|
size="xs"
|
|
|
|
marginRight="1.5"
|
|
|
|
// HACK: Not sure why my various centering things always feel wrong...
|
|
|
|
marginBottom="-2px"
|
|
|
|
/>
|
|
|
|
Saving…
|
|
|
|
</Flex>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-04-22 02:35:59 -07:00
|
|
|
if (latestVersionIsSaved) {
|
|
|
|
return (
|
2021-04-22 02:44:45 -07:00
|
|
|
<Flex
|
|
|
|
align="center"
|
|
|
|
fontSize="xs"
|
|
|
|
data-test-id="wardrobe-outfit-is-saved-indicator"
|
|
|
|
>
|
2021-04-22 02:35:59 -07:00
|
|
|
<CheckIcon
|
|
|
|
marginRight="1"
|
|
|
|
// HACK: Not sure why my various centering things always feel wrong...
|
|
|
|
marginBottom="-2px"
|
|
|
|
/>
|
|
|
|
Saved
|
|
|
|
</Flex>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-04-28 15:04:18 -07:00
|
|
|
if (saveError) {
|
|
|
|
return (
|
|
|
|
<Flex
|
|
|
|
align="center"
|
|
|
|
fontSize="xs"
|
|
|
|
data-test-id="wardrobe-outfit-save-error-indicator"
|
|
|
|
color={errorTextColor}
|
|
|
|
>
|
|
|
|
<WarningTwoIcon
|
|
|
|
marginRight="1"
|
|
|
|
// HACK: Not sure why my various centering things always feel wrong...
|
|
|
|
marginBottom="-2px"
|
|
|
|
/>
|
|
|
|
Error saving
|
|
|
|
</Flex>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// The most common way we'll hit this null is when the outfit is changing,
|
|
|
|
// but the debouncing isn't done yet, so it's not saving yet.
|
|
|
|
return null;
|
2021-04-22 02:35:59 -07:00
|
|
|
}
|
|
|
|
|
2020-04-26 01:14:31 -07:00
|
|
|
/**
|
|
|
|
* OutfitHeading is an editable outfit name, as a big pretty page heading!
|
2021-03-29 19:25:00 -07:00
|
|
|
* It also contains the outfit menu, for saving etc.
|
2020-04-26 01:14:31 -07:00
|
|
|
*/
|
2020-04-25 00:22:49 -07:00
|
|
|
function OutfitHeading({ outfitState, dispatchToOutfit }) {
|
|
|
|
return (
|
2021-03-29 19:25:00 -07:00
|
|
|
// The Editable wraps everything, including the menu, because the menu has
|
|
|
|
// a Rename option.
|
|
|
|
<Editable
|
|
|
|
// Make sure not to ever pass `undefined` into here, or else the
|
|
|
|
// component enters uncontrolled mode, and changing the value
|
|
|
|
// later won't fix it!
|
|
|
|
value={outfitState.name || ""}
|
|
|
|
placeholder="Untitled outfit"
|
|
|
|
onChange={(value) =>
|
|
|
|
dispatchToOutfit({ type: "rename", outfitName: value })
|
|
|
|
}
|
|
|
|
>
|
|
|
|
{({ onEdit }) => (
|
2021-03-29 19:46:21 -07:00
|
|
|
<Flex align="center" marginBottom="6">
|
|
|
|
<Box>
|
2021-03-29 19:25:00 -07:00
|
|
|
<Box role="group" d="inline-block" position="relative" width="100%">
|
|
|
|
<Heading1>
|
2021-04-16 03:04:39 -07:00
|
|
|
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
|
2021-03-29 19:25:00 -07:00
|
|
|
<EditableInput lineHeight="48px" />
|
|
|
|
</Heading1>
|
|
|
|
</Box>
|
|
|
|
</Box>
|
2021-03-29 19:46:21 -07:00
|
|
|
<Box width="4" flex="1 0 auto" />
|
2021-04-22 02:35:59 -07:00
|
|
|
<Box flex="0 0 auto">
|
2021-04-28 15:47:59 -07:00
|
|
|
<OutfitSavingIndicator
|
|
|
|
outfitState={outfitState}
|
|
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
|
|
/>
|
2021-04-22 02:35:59 -07:00
|
|
|
</Box>
|
|
|
|
<Box width="2" />
|
2021-03-29 19:25:00 -07:00
|
|
|
<Menu placement="bottom-end">
|
|
|
|
<MenuButton
|
|
|
|
as={IconButton}
|
|
|
|
variant="ghost"
|
|
|
|
icon={<MdMoreVert />}
|
|
|
|
aria-label="Outfit menu"
|
|
|
|
borderRadius="full"
|
|
|
|
fontSize="24px"
|
|
|
|
opacity="0.8"
|
|
|
|
/>
|
|
|
|
<Portal>
|
|
|
|
<MenuList>
|
2021-03-29 19:46:21 -07:00
|
|
|
{outfitState.id && (
|
|
|
|
<MenuItem icon={<EditIcon />} isDisabled>
|
|
|
|
Edit a copy <i>(Coming soon)</i>
|
|
|
|
</MenuItem>
|
|
|
|
)}
|
2021-03-29 19:25:00 -07:00
|
|
|
<MenuItem
|
2021-03-29 19:46:21 -07:00
|
|
|
icon={<BiRename />}
|
2021-03-29 19:25:00 -07:00
|
|
|
onClick={() => {
|
|
|
|
// Start the rename after a tick, so finishing up the click
|
|
|
|
// won't just immediately remove focus from the Editable.
|
|
|
|
setTimeout(onEdit, 0);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Rename
|
|
|
|
</MenuItem>
|
|
|
|
</MenuList>
|
|
|
|
</Portal>
|
|
|
|
</Menu>
|
|
|
|
</Flex>
|
2021-01-04 22:29:39 -08:00
|
|
|
)}
|
2021-03-29 19:25:00 -07:00
|
|
|
</Editable>
|
2020-04-25 00:22:49 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-04-26 01:14:31 -07:00
|
|
|
/**
|
2020-04-27 10:14:13 -07:00
|
|
|
* fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the
|
|
|
|
* fade-out and height decrease when an Item or ItemZoneGroup is removed.
|
|
|
|
*
|
|
|
|
* Note that this _cannot_ be implemented as a wrapper component that returns a
|
|
|
|
* CSSTransition. This is because the CSSTransition must be the direct child of
|
|
|
|
* the TransitionGroup, and a wrapper breaks the parent-child relationship.
|
|
|
|
*
|
2020-04-26 01:14:31 -07:00
|
|
|
* See react-transition-group docs for more info!
|
|
|
|
*/
|
2021-01-03 19:11:46 -08:00
|
|
|
const fadeOutAndRollUpTransition = (css) => ({
|
2020-04-27 10:14:13 -07:00
|
|
|
classNames: css`
|
|
|
|
&-exit {
|
|
|
|
opacity: 1;
|
|
|
|
height: auto;
|
|
|
|
}
|
2020-04-26 01:14:31 -07:00
|
|
|
|
2020-04-27 10:14:13 -07:00
|
|
|
&-exit-active {
|
|
|
|
opacity: 0;
|
|
|
|
height: 0 !important;
|
|
|
|
margin-top: 0 !important;
|
|
|
|
margin-bottom: 0 !important;
|
|
|
|
transition: all 0.5s;
|
|
|
|
}
|
|
|
|
`,
|
|
|
|
timeout: 500,
|
|
|
|
onExit: (e) => {
|
|
|
|
e.style.height = e.offsetHeight + "px";
|
|
|
|
},
|
2021-01-03 19:11:46 -08:00
|
|
|
});
|
2020-04-25 00:22:49 -07:00
|
|
|
|
|
|
|
export default ItemsPanel;
|