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:
parent
6749d19f9e
commit
1f5a9d60a2
5 changed files with 136 additions and 50 deletions
|
@ -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 />
|
||||||
|
|
|
@ -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)`,
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue