diff --git a/src/app/App.js b/src/app/App.js index cfa0ee2..3c1635a 100644 --- a/src/app/App.js +++ b/src/app/App.js @@ -89,6 +89,9 @@ function App() { + + + diff --git a/src/app/UserOutfitsPage.js b/src/app/UserOutfitsPage.js index 5c645e0..d7c4a47 100644 --- a/src/app/UserOutfitsPage.js +++ b/src/app/UserOutfitsPage.js @@ -2,6 +2,7 @@ import React from "react"; import { Box, Center, Flex, Wrap, WrapItem } from "@chakra-ui/react"; import gql from "graphql-tag"; import { useQuery } from "@apollo/client"; +import { Link } from "react-router-dom"; import { ErrorMessage, Heading1, useCommonStyles } from "./util"; import { @@ -112,8 +113,8 @@ function OutfitCard({ outfit }) { width="calc(150px + 2em)" backgroundColor={brightBackground} transition="all 0.2s" - as="a" - href={`https://impress.openneo.net/outfits/${outfit.id}`} + as={Link} + to={`/outfits/${outfit.id}`} _hover={{ transform: `scale(1.05)` }} _focus={{ transform: `scale(1.05)`, diff --git a/src/app/WardrobePage/ItemsPanel.js b/src/app/WardrobePage/ItemsPanel.js index 705fc10..e010656 100644 --- a/src/app/WardrobePage/ItemsPanel.js +++ b/src/app/WardrobePage/ItemsPanel.js @@ -16,6 +16,7 @@ import { CSSTransition, TransitionGroup } from "react-transition-group"; import { Delay, Heading1, Heading2 } from "../util"; import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; +import WIPCallout from "../components/WIPCallout"; /** * ItemsPanel shows the items in the current outfit, and lets the user toggle @@ -241,41 +242,52 @@ function ItemZoneGroupSkeleton({ itemCount }) { */ function OutfitHeading({ outfitState, dispatchToOutfit }) { return ( - - - - - dispatchToOutfit({ type: "rename", outfitName: value }) - } - > - {({ isEditing, onEdit }) => ( - - - - {!isEditing && ( - - } - variant="link" - aria-label="Edit outfit name" - title="Edit outfit name" - /> - - )} - - )} - - + + + + + + dispatchToOutfit({ type: "rename", outfitName: value }) + } + > + {({ isEditing, onEdit }) => ( + + + + {!isEditing && ( + + } + variant="link" + aria-label="Edit outfit name" + title="Edit outfit name" + /> + + )} + + )} + + + - + {outfitState.id && ( + + Saved outfits are WIP! + + )} + ); } diff --git a/src/app/WardrobePage/useOutfitState.js b/src/app/WardrobePage/useOutfitState.js index a10e457..1017b94 100644 --- a/src/app/WardrobePage/useOutfitState.js +++ b/src/app/WardrobePage/useOutfitState.js @@ -2,6 +2,7 @@ import React from "react"; import gql from "graphql-tag"; import produce, { enableMapSet } from "immer"; import { useQuery, useApolloClient } from "@apollo/client"; +import { useParams } from "react-router-dom"; import { itemAppearanceFragment } from "../components/useOutfitAppearance"; @@ -11,21 +12,72 @@ export const OutfitStateContext = React.createContext(null); function useOutfitState() { const apolloClient = useApolloClient(); - const initialState = parseOutfitUrl(); + const initialState = useParseOutfitUrl(); const [state, dispatchToOutfit] = React.useReducer( outfitStateReducer(apolloClient), 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 // will find it more convenient to access them as arrays! e.g. for `.map()` const wornItemIds = Array.from(state.wornItemIds); const closetedItemIds = Array.from(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` query OutfitStateItems( $allItemIds: [ID!]! @@ -69,11 +121,14 @@ function useOutfitState() { `, { 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 // React.memo to avoid re-rendering Item components if the items haven't @@ -123,6 +178,7 @@ function useOutfitState() { const url = buildOutfitUrl(state); const outfitState = { + id, zonesAndItems, incompatibleItems, name, @@ -141,7 +197,12 @@ function useOutfitState() { window.history.replaceState(null, "", url); }, [url]); - return { loading, error, outfitState, dispatchToOutfit }; + return { + loading: outfitLoading || itemsLoading, + error: outfitError || itemsError, + outfitState, + dispatchToOutfit, + }; } 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); + return { + id: id, name: urlParams.get("name"), speciesId: urlParams.get("species"), colorId: urlParams.get("color"), @@ -440,6 +504,7 @@ function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) { function buildOutfitUrl(state) { const { + id, name, speciesId, colorId, @@ -449,6 +514,12 @@ function buildOutfitUrl(state) { closetedItemIds, } = state; + const { origin, pathname } = window.location; + + if (id) { + return origin + `/outfits/${id}`; + } + const params = new URLSearchParams({ name: name || "", species: speciesId, @@ -467,9 +538,7 @@ function buildOutfitUrl(state) { params.append("state", appearanceId); } - const { origin, pathname } = window.location; - const url = origin + pathname + "?" + params.toString(); - return url; + return origin + pathname + "?" + params.toString(); } export default useOutfitState; diff --git a/src/app/components/WIPCallout.js b/src/app/components/WIPCallout.js index 6ac0065..bf2491f 100644 --- a/src/app/components/WIPCallout.js +++ b/src/app/components/WIPCallout.js @@ -6,7 +6,7 @@ import { useCommonStyles } from "../util"; import WIPXweeImg from "../images/wip-xwee.png"; import WIPXweeImg2x from "../images/wip-xwee@2x.png"; -function WIPCallout({ children, details }) { +function WIPCallout({ children, details, placement = "bottom", ...props }) { const { brightBackground } = useCommonStyles(); let content = ( @@ -22,6 +22,7 @@ function WIPCallout({ children, details }) { paddingRight="4" paddingY="1" fontSize="sm" + {...props} > {details}} - placement="bottom" + placement={placement} shouldWrapChildren > - {content} + {content} ); }