Show saved outfits in wardrobe page!

Still a pretty limited early version, no saving _back_ to the server. But you can click from the Your Outfits page and see the outfit for real! :3 We have a WIPCallout explaining the basics.
This commit is contained in:
Emi Matchu 2021-01-05 06:29:39 +00:00
parent 6749d19f9e
commit 1f5a9d60a2
5 changed files with 136 additions and 50 deletions

View file

@ -89,6 +89,9 @@ function App() {
<Route path="/outfits/new"> <Route path="/outfits/new">
<WardrobePage /> <WardrobePage />
</Route> </Route>
<Route path="/outfits/:id">
<WardrobePage />
</Route>
<Route path="/user/:userId/items"> <Route path="/user/:userId/items">
<PageLayout> <PageLayout>
<UserItemsPage /> <UserItemsPage />

View file

@ -2,6 +2,7 @@ import React from "react";
import { Box, Center, Flex, Wrap, WrapItem } from "@chakra-ui/react"; import { Box, Center, Flex, Wrap, WrapItem } from "@chakra-ui/react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { Link } from "react-router-dom";
import { ErrorMessage, Heading1, useCommonStyles } from "./util"; import { ErrorMessage, Heading1, useCommonStyles } from "./util";
import { import {
@ -112,8 +113,8 @@ function OutfitCard({ outfit }) {
width="calc(150px + 2em)" width="calc(150px + 2em)"
backgroundColor={brightBackground} backgroundColor={brightBackground}
transition="all 0.2s" transition="all 0.2s"
as="a" as={Link}
href={`https://impress.openneo.net/outfits/${outfit.id}`} to={`/outfits/${outfit.id}`}
_hover={{ transform: `scale(1.05)` }} _hover={{ transform: `scale(1.05)` }}
_focus={{ _focus={{
transform: `scale(1.05)`, transform: `scale(1.05)`,

View file

@ -16,6 +16,7 @@ import { CSSTransition, TransitionGroup } from "react-transition-group";
import { Delay, Heading1, Heading2 } from "../util"; import { Delay, Heading1, Heading2 } from "../util";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import WIPCallout from "../components/WIPCallout";
/** /**
* ItemsPanel shows the items in the current outfit, and lets the user toggle * ItemsPanel shows the items in the current outfit, and lets the user toggle
@ -241,41 +242,52 @@ function ItemZoneGroupSkeleton({ itemCount }) {
*/ */
function OutfitHeading({ outfitState, dispatchToOutfit }) { function OutfitHeading({ outfitState, dispatchToOutfit }) {
return ( return (
<Box> <Flex align="flex-start" justify="space-between">
<Box role="group" d="inline-block" position="relative" width="100%"> <Box marginRight="4">
<Heading1 mb="6"> <Box role="group" d="inline-block" position="relative" width="100%">
<Editable <Heading1 mb="6">
value={outfitState.name} <Editable
placeholder="Untitled outfit" value={outfitState.name}
onChange={(value) => placeholder="Untitled outfit"
dispatchToOutfit({ type: "rename", outfitName: value }) onChange={(value) =>
} dispatchToOutfit({ type: "rename", outfitName: value })
> }
{({ isEditing, onEdit }) => ( >
<Flex align="flex-top"> {({ isEditing, onEdit }) => (
<EditablePreview /> <Flex align="flex-top">
<EditableInput /> <EditablePreview />
{!isEditing && ( <EditableInput />
<Box {!isEditing && (
opacity="0" <Box
transition="opacity 0.5s" opacity="0"
_groupHover={{ opacity: "1" }} transition="opacity 0.5s"
onClick={onEdit} _groupHover={{ opacity: "1" }}
> onClick={onEdit}
<IconButton >
icon={<EditIcon />} <IconButton
variant="link" icon={<EditIcon />}
aria-label="Edit outfit name" variant="link"
title="Edit outfit name" aria-label="Edit outfit name"
/> title="Edit outfit name"
</Box> />
)} </Box>
</Flex> )}
)} </Flex>
</Editable> )}
</Heading1> </Editable>
</Heading1>
</Box>
</Box> </Box>
</Box> {outfitState.id && (
<WIPCallout
details={`To save a new version of this outfit, use Classic DTI. But you can still play around in here for now!`}
marginTop="1"
placement="bottom-end"
>
Saved outfits are WIP!
</WIPCallout>
)}
</Flex>
); );
} }

View file

@ -2,6 +2,7 @@ import React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import produce, { enableMapSet } from "immer"; import produce, { enableMapSet } from "immer";
import { useQuery, useApolloClient } from "@apollo/client"; import { useQuery, useApolloClient } from "@apollo/client";
import { useParams } from "react-router-dom";
import { itemAppearanceFragment } from "../components/useOutfitAppearance"; import { itemAppearanceFragment } from "../components/useOutfitAppearance";
@ -11,21 +12,72 @@ export const OutfitStateContext = React.createContext(null);
function useOutfitState() { function useOutfitState() {
const apolloClient = useApolloClient(); const apolloClient = useApolloClient();
const initialState = parseOutfitUrl(); const initialState = useParseOutfitUrl();
const [state, dispatchToOutfit] = React.useReducer( const [state, dispatchToOutfit] = React.useReducer(
outfitStateReducer(apolloClient), outfitStateReducer(apolloClient),
initialState initialState
); );
const { name, speciesId, colorId, pose, appearanceId } = state; const { id, name, speciesId, colorId, pose, appearanceId } = state;
// It's more convenient to manage these as a Set in state, but most callers // It's more convenient to manage these as a Set in state, but most callers
// will find it more convenient to access them as arrays! e.g. for `.map()` // will find it more convenient to access them as arrays! e.g. for `.map()`
const wornItemIds = Array.from(state.wornItemIds); const wornItemIds = Array.from(state.wornItemIds);
const closetedItemIds = Array.from(state.closetedItemIds); const closetedItemIds = Array.from(state.closetedItemIds);
const allItemIds = [...state.wornItemIds, ...state.closetedItemIds]; const allItemIds = [...state.wornItemIds, ...state.closetedItemIds];
const { loading, error, data } = useQuery(
// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
// about the outfit. We'll use it to initialize the local state.
const { loading: outfitLoading, error: outfitError } = useQuery(
gql`
query OutfitStateSavedOutfit($id: ID!) {
outfit(id: $id) {
id
name
petAppearance {
species {
id
}
color {
id
}
pose
}
wornItems {
id
}
closetedItems {
id
}
# TODO: Consider pre-loading some fields, instead of doing them in
# follow-up queries?
}
}
`,
{
variables: { id },
skip: id == null,
onCompleted: (outfitData) => {
const outfit = outfitData.outfit;
dispatchToOutfit({
type: "reset",
name: outfit.name,
speciesId: outfit.petAppearance.species.id,
colorId: outfit.petAppearance.color.id,
pose: outfit.petAppearance.pose,
wornItemIds: outfit.wornItems.map((item) => item.id),
closetedItemIds: outfit.closetedItems.map((item) => item.id),
});
},
}
);
const {
loading: itemsLoading,
error: itemsError,
data: itemsData,
} = useQuery(
gql` gql`
query OutfitStateItems( query OutfitStateItems(
$allItemIds: [ID!]! $allItemIds: [ID!]!
@ -69,11 +121,14 @@ function useOutfitState() {
`, `,
{ {
variables: { allItemIds, speciesId, colorId }, variables: { allItemIds, speciesId, colorId },
skip: allItemIds.length === 0, // Skip if this outfit has no items, as an optimization; or if we don't
// have the species/color ID loaded yet because we're waiting on the
// saved outfit to load.
skip: allItemIds.length === 0 || speciesId == null || colorId == null,
} }
); );
const resultItems = data?.items || []; const resultItems = itemsData?.items || [];
// Okay, time for some big perf hacks! Lower down in the app, we use // Okay, time for some big perf hacks! Lower down in the app, we use
// React.memo to avoid re-rendering Item components if the items haven't // React.memo to avoid re-rendering Item components if the items haven't
@ -123,6 +178,7 @@ function useOutfitState() {
const url = buildOutfitUrl(state); const url = buildOutfitUrl(state);
const outfitState = { const outfitState = {
id,
zonesAndItems, zonesAndItems,
incompatibleItems, incompatibleItems,
name, name,
@ -141,7 +197,12 @@ function useOutfitState() {
window.history.replaceState(null, "", url); window.history.replaceState(null, "", url);
}, [url]); }, [url]);
return { loading, error, outfitState, dispatchToOutfit }; return {
loading: outfitLoading || itemsLoading,
error: outfitError || itemsError,
outfitState,
dispatchToOutfit,
};
} }
const outfitStateReducer = (apolloClient) => (baseState, action) => { const outfitStateReducer = (apolloClient) => (baseState, action) => {
@ -249,9 +310,12 @@ const outfitStateReducer = (apolloClient) => (baseState, action) => {
} }
}; };
function parseOutfitUrl() { function useParseOutfitUrl() {
const { id } = useParams();
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
return { return {
id: id,
name: urlParams.get("name"), name: urlParams.get("name"),
speciesId: urlParams.get("species"), speciesId: urlParams.get("species"),
colorId: urlParams.get("color"), colorId: urlParams.get("color"),
@ -440,6 +504,7 @@ function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
function buildOutfitUrl(state) { function buildOutfitUrl(state) {
const { const {
id,
name, name,
speciesId, speciesId,
colorId, colorId,
@ -449,6 +514,12 @@ function buildOutfitUrl(state) {
closetedItemIds, closetedItemIds,
} = state; } = state;
const { origin, pathname } = window.location;
if (id) {
return origin + `/outfits/${id}`;
}
const params = new URLSearchParams({ const params = new URLSearchParams({
name: name || "", name: name || "",
species: speciesId, species: speciesId,
@ -467,9 +538,7 @@ function buildOutfitUrl(state) {
params.append("state", appearanceId); params.append("state", appearanceId);
} }
const { origin, pathname } = window.location; return origin + pathname + "?" + params.toString();
const url = origin + pathname + "?" + params.toString();
return url;
} }
export default useOutfitState; export default useOutfitState;

View file

@ -6,7 +6,7 @@ import { useCommonStyles } from "../util";
import WIPXweeImg from "../images/wip-xwee.png"; import WIPXweeImg from "../images/wip-xwee.png";
import WIPXweeImg2x from "../images/wip-xwee@2x.png"; import WIPXweeImg2x from "../images/wip-xwee@2x.png";
function WIPCallout({ children, details }) { function WIPCallout({ children, details, placement = "bottom", ...props }) {
const { brightBackground } = useCommonStyles(); const { brightBackground } = useCommonStyles();
let content = ( let content = (
@ -22,6 +22,7 @@ function WIPCallout({ children, details }) {
paddingRight="4" paddingRight="4"
paddingY="1" paddingY="1"
fontSize="sm" fontSize="sm"
{...props}
> >
<Box <Box
as="img" as="img"
@ -47,10 +48,10 @@ function WIPCallout({ children, details }) {
content = ( content = (
<Tooltip <Tooltip
label={<Box textAlign="center">{details}</Box>} label={<Box textAlign="center">{details}</Box>}
placement="bottom" placement={placement}
shouldWrapChildren shouldWrapChildren
> >
{content} <Box cursor="help">{content}</Box>
</Tooltip> </Tooltip>
); );
} }