[WIP] Migrate outfit page, with known bug
Okay so there's a bug here where navigating directly to /outfits/new?species=X&color=Y will reset to a Blue Acara, because Next.js statically renders the Blue Acara on build, and then rehydrates a Blue Acara on load, and then updates the real page query in—and our state management for outfits doesn't *listen* to URL changes, it only *emits* them. It'd be good to consider like… changing that? It's tricky because our state model is… not simple, when you consider that we have both local state and URL state and saved-outfit state in play. But it could be done! But there might be another option too. I'll take a look at this after moving the home page, which will give me the chance to see what the experience navigating in from there is like!
This commit is contained in:
parent
5d28c36e8a
commit
eb602556bf
10 changed files with 167 additions and 86 deletions
|
@ -2,34 +2,38 @@
|
|||
// extra SSR for outfit sharing meta tags!
|
||||
|
||||
import Head from "next/head";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
import WardrobePage from "../../src/app/WardrobePage";
|
||||
// @ts-ignore: doesn't understand module.exports
|
||||
import connectToDb from "../../src/server/db";
|
||||
// @ts-ignore: doesn't understand module.exports
|
||||
import { normalizeRow } from "../../src/server/util";
|
||||
import { GetServerSideProps } from "next";
|
||||
|
||||
// import NextIndexWrapper from '../../src'
|
||||
type Outfit = {
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type PageProps = {
|
||||
outfit: Outfit;
|
||||
};
|
||||
|
||||
// next/dynamic is used to prevent breaking incompatibilities
|
||||
// with SSR from window.SOME_VAR usage, if this is not used
|
||||
// next/dynamic can be removed to take advantage of SSR/prerendering
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// try changing "ssr" to true below to test for incompatibilities, if
|
||||
// no errors occur the above static import can be used instead and the
|
||||
// below removed
|
||||
const App = dynamic(() => import("../../src/app/App"), { ssr: false });
|
||||
|
||||
export default function Page({ outfit, ...props }) {
|
||||
const WardrobePageWrapper: NextPageWithLayout<PageProps> = ({ outfit }) => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{outfit.name || "Untitled outfit"} | Dress to Impress</title>
|
||||
<OutfitMetaTags outfit={outfit} />
|
||||
</Head>
|
||||
<App {...props} />
|
||||
<WardrobePage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function OutfitMetaTags({ outfit }) {
|
||||
WardrobePageWrapper.renderWithLayout = (children) => children;
|
||||
|
||||
function OutfitMetaTags({ outfit }: { outfit: Outfit }) {
|
||||
const updatedAtTimestamp = Math.floor(
|
||||
new Date(outfit.updatedAt).getTime() / 1000
|
||||
);
|
||||
|
@ -57,8 +61,13 @@ function OutfitMetaTags({ outfit }) {
|
|||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps({ params }) {
|
||||
const outfit = await loadOutfitData(params.id);
|
||||
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
|
||||
const outfitId = params?.outfitId;
|
||||
if (typeof outfitId !== "string") {
|
||||
throw new Error(`assertion failed: outfitId route param is missing`);
|
||||
}
|
||||
|
||||
const outfit = await loadOutfitData(outfitId);
|
||||
if (outfit == null) {
|
||||
return { notFound: true };
|
||||
}
|
||||
|
@ -72,9 +81,9 @@ export async function getServerSideProps({ params }) {
|
|||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
async function loadOutfitData(id) {
|
||||
async function loadOutfitData(id: string) {
|
||||
const db = await connectToDb();
|
||||
const [rows] = await db.query(`SELECT * FROM outfits WHERE id = ?;`, [id]);
|
||||
if (rows.length === 0) {
|
||||
|
@ -83,3 +92,5 @@ async function loadOutfitData(id) {
|
|||
|
||||
return normalizeRow(rows[0]);
|
||||
}
|
||||
|
||||
export default WardrobePageWrapper;
|
|
@ -1,19 +0,0 @@
|
|||
// This is just a copy of our higher-level catch-all page.
|
||||
// That way, /outfits/new renders as normal, but /outfits/:slug
|
||||
// does the SSR thing!
|
||||
|
||||
// import NextIndexWrapper from '../../src'
|
||||
|
||||
// next/dynamic is used to prevent breaking incompatibilities
|
||||
// with SSR from window.SOME_VAR usage, if this is not used
|
||||
// next/dynamic can be removed to take advantage of SSR/prerendering
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// try changing "ssr" to true below to test for incompatibilities, if
|
||||
// no errors occur the above static import can be used instead and the
|
||||
// below removed
|
||||
const App = dynamic(() => import("../../src/app/App"), { ssr: false });
|
||||
|
||||
export default function Page(props) {
|
||||
return <App {...props} />;
|
||||
}
|
10
pages/outfits/new.tsx
Normal file
10
pages/outfits/new.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import WardrobePage from "../../src/app/WardrobePage";
|
||||
import type { NextPageWithLayout } from "../_app";
|
||||
|
||||
const WardrobePageWrapper: NextPageWithLayout = () => {
|
||||
return <WardrobePage />;
|
||||
};
|
||||
|
||||
WardrobePageWrapper.renderWithLayout = (children) => children;
|
||||
|
||||
export default WardrobePageWrapper;
|
|
@ -7,13 +7,9 @@ import {
|
|||
} from "react-router-dom";
|
||||
|
||||
import PageLayout from "./PageLayout";
|
||||
import WardrobePageLayout from "./WardrobePage/WardrobePageLayout";
|
||||
import { loadable } from "./util";
|
||||
|
||||
const HomePage = loadable(() => import("./HomePage"));
|
||||
const WardrobePage = loadable(() => import("./WardrobePage"), {
|
||||
fallback: <WardrobePageLayout />,
|
||||
});
|
||||
|
||||
/**
|
||||
* App is the entry point of our application. There's not a ton of exciting
|
||||
|
@ -27,12 +23,6 @@ function App() {
|
|||
<ScrollToTop />
|
||||
|
||||
<Switch>
|
||||
<Route path="/outfits/new">
|
||||
<WardrobePage />
|
||||
</Route>
|
||||
<Route path="/outfits/:id">
|
||||
<WardrobePage />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<PageLayout hideHomeLink>
|
||||
<HomePage />
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import Link from "next/link";
|
||||
import { loadable } from "../util";
|
||||
|
||||
import {
|
||||
|
@ -249,13 +249,13 @@ function ItemActionButton({ icon, label, to, onClick }) {
|
|||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Tooltip label={label} placement="top">
|
||||
<IconButton
|
||||
as={to ? Link : "button"}
|
||||
<LinkOrButton
|
||||
component={IconButton}
|
||||
href={to}
|
||||
icon={icon}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={css`
|
||||
opacity: 0;
|
||||
|
@ -286,6 +286,19 @@ function ItemActionButton({ icon, label, to, onClick }) {
|
|||
);
|
||||
}
|
||||
|
||||
function LinkOrButton({ href, component = Button, ...props }) {
|
||||
const ButtonComponent = component;
|
||||
if (href != null) {
|
||||
return (
|
||||
<Link href={href} passHref>
|
||||
<ButtonComponent as="a" {...props} />
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return <ButtonComponent {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemListContainer is a container for Item components! Wrap your Item
|
||||
* components in this to ensure a consistent list layout.
|
||||
|
|
|
@ -35,7 +35,6 @@ import {
|
|||
WarningTwoIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Delay,
|
||||
|
@ -50,6 +49,7 @@ import { IoCloudUploadOutline } from "react-icons/io5";
|
|||
import { MdMoreVert } from "react-icons/md";
|
||||
import { buildOutfitUrl } from "./useOutfitState";
|
||||
import { gql, useMutation } from "@apollo/client";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
/**
|
||||
* ItemsPanel shows the items in the current outfit, and lets the user toggle
|
||||
|
@ -455,7 +455,7 @@ function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
|
|||
function DeleteOutfitMenuItem({ outfitState }) {
|
||||
const { id, name } = outfitState;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const history = useHistory();
|
||||
const { push: pushHistory } = useRouter();
|
||||
|
||||
const [sendDeleteOutfitMutation, { loading, error }] = useMutation(
|
||||
gql`
|
||||
|
@ -506,7 +506,7 @@ function DeleteOutfitMenuItem({ outfitState }) {
|
|||
onClick={() =>
|
||||
sendDeleteOutfitMutation({ variables: { id } })
|
||||
.then(() => {
|
||||
history.push(`/your-outfits`);
|
||||
pushHistory(`/your-outfits`);
|
||||
})
|
||||
.catch((e) => {
|
||||
/* handled in error UI */
|
||||
|
|
|
@ -33,7 +33,7 @@ import {
|
|||
SettingsIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { MdPause, MdPlayArrow } from "react-icons/md";
|
||||
import { Link } from "react-router-dom";
|
||||
import Link from "next/link";
|
||||
|
||||
import { getBestImageUrlForLayer } from "../components/OutfitPreview";
|
||||
import HTML5Badge, { layerUsesHTML5 } from "../components/HTML5Badge";
|
||||
|
@ -318,14 +318,15 @@ function BackButton({ outfitState }) {
|
|||
outfitState.creator && outfitState.creator.id === currentUser.id;
|
||||
|
||||
return (
|
||||
<Link href={outfitBelongsToCurrentUser ? "/your-outfits" : "/"} passHref>
|
||||
<ControlButton
|
||||
as={Link}
|
||||
to={outfitBelongsToCurrentUser ? "/your-outfits" : "/"}
|
||||
as="a"
|
||||
icon={<ArrowBackIcon />}
|
||||
aria-label="Leave this outfit"
|
||||
d="inline-flex" // Not sure why <a> requires this to style right! ^^`
|
||||
data-test-id="wardrobe-nav-back-button"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React from "react";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
import { loadable } from "../util";
|
||||
|
||||
|
@ -11,6 +10,7 @@ import useOutfitState, { OutfitStateContext } from "./useOutfitState";
|
|||
import { usePageTitle } from "../util";
|
||||
import WardrobePageLayout from "./WardrobePageLayout";
|
||||
import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const WardrobeDevHacks = loadable(() => import("./WardrobeDevHacks"));
|
||||
|
||||
|
@ -119,4 +119,36 @@ function WardrobePage() {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt blocks client-side navigation via Next.js when the `when` prop is
|
||||
* true. This is our attempt at a drop-in replacement for the Prompt component
|
||||
* offered by react-router!
|
||||
*
|
||||
* Adapted from https://github.com/vercel/next.js/issues/2694#issuecomment-778225625
|
||||
*/
|
||||
function Prompt({ when, message }) {
|
||||
const router = useRouter();
|
||||
React.useEffect(() => {
|
||||
const handleWindowClose = (e) => {
|
||||
if (!when) return;
|
||||
e.preventDefault();
|
||||
return (e.returnValue = message);
|
||||
};
|
||||
const handleBrowseAway = () => {
|
||||
if (!when) return;
|
||||
if (window.confirm(message)) return;
|
||||
router.events.emit("routeChangeError");
|
||||
throw "routeChange aborted by <Prompt>.";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleWindowClose);
|
||||
router.events.on("routeChangeStart", handleBrowseAway);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleWindowClose);
|
||||
router.events.off("routeChangeStart", handleBrowseAway);
|
||||
};
|
||||
}, [when, message, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default WardrobePage;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useRouter } from "next/router";
|
||||
import { useDebounce } from "../util";
|
||||
import useCurrentUser from "../components/useCurrentUser";
|
||||
import gql from "graphql-tag";
|
||||
|
@ -9,7 +9,7 @@ import { outfitStatesAreEqual } from "./useOutfitState";
|
|||
|
||||
function useOutfitSaving(outfitState, dispatchToOutfit) {
|
||||
const { isLoggedIn, id: currentUserId } = useCurrentUser();
|
||||
const history = useHistory();
|
||||
const { pathname, push: pushHistory } = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
// There's not a way to reset an Apollo mutation state to clear out the error
|
||||
|
@ -158,8 +158,8 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
|
|||
// 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.
|
||||
if (history.location.pathname !== `/outfits/${outfit.id}`) {
|
||||
history.push(`/outfits/${outfit.id}`);
|
||||
if (pathname !== `/outfits/[outfitId]`) {
|
||||
pushHistory(`/outfits/${outfit.id}`);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
|
@ -175,7 +175,7 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
|
|||
// 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]
|
||||
[sendSaveOutfitMutation, pathname, pushHistory, toast]
|
||||
);
|
||||
|
||||
const saveOutfit = React.useCallback(
|
||||
|
|
|
@ -2,9 +2,9 @@ import React from "react";
|
|||
import gql from "graphql-tag";
|
||||
import produce, { enableMapSet } from "immer";
|
||||
import { useQuery, useApolloClient } from "@apollo/client";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
|
||||
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
enableMapSet();
|
||||
|
||||
|
@ -249,8 +249,13 @@ function useOutfitState() {
|
|||
};
|
||||
|
||||
// Keep the URL up-to-date. (We don't listen to it, though 😅)
|
||||
// TODO: Seems like we should hook this in with the actual router... I'm
|
||||
// avoiding it rn, but I'm worried Next.js won't necessarily play nice
|
||||
// with this hack, even though react-router did. Hard to predict!
|
||||
React.useEffect(() => {
|
||||
window.history.replaceState(null, "", url);
|
||||
if (typeof history !== "undefined") {
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
return {
|
||||
|
@ -369,39 +374,74 @@ const EMPTY_CUSTOMIZATION_STATE = {
|
|||
};
|
||||
|
||||
function useParseOutfitUrl() {
|
||||
const { id } = useParams();
|
||||
const { search } = useLocation();
|
||||
const { query } = useRouter();
|
||||
const { outfitId } = query;
|
||||
|
||||
// We memoize this to make `outfitStateWithoutExtras` an even more reliable
|
||||
// stable object!
|
||||
const memoizedOutfitState = React.useMemo(() => {
|
||||
// For the /outfits/:id page, ignore the query string, and just wait for the
|
||||
// outfit data to load in!
|
||||
if (id != null) {
|
||||
if (outfitId != null) {
|
||||
return {
|
||||
...EMPTY_CUSTOMIZATION_STATE,
|
||||
id,
|
||||
id: outfitId,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, parse the query string, and fill in default values for anything
|
||||
// not specified.
|
||||
const urlParams = new URLSearchParams(search);
|
||||
return {
|
||||
id: null,
|
||||
name: urlParams.get("name"),
|
||||
speciesId: urlParams.get("species") || "1",
|
||||
colorId: urlParams.get("color") || "8",
|
||||
pose: urlParams.get("pose") || "HAPPY_FEM",
|
||||
appearanceId: urlParams.get("state") || null,
|
||||
wornItemIds: new Set(urlParams.getAll("objects[]")),
|
||||
closetedItemIds: new Set(urlParams.getAll("closet[]")),
|
||||
name: getValueFromQuery(query.name),
|
||||
speciesId: getValueFromQuery(query.species) || "1",
|
||||
colorId: getValueFromQuery(query.color) || "8",
|
||||
pose: getValueFromQuery(query.pose) || "HAPPY_FEM",
|
||||
appearanceId: getValueFromQuery(query.state) || null,
|
||||
wornItemIds: new Set(getListFromQuery(query["objects[]"])),
|
||||
closetedItemIds: new Set(getListFromQuery(query["closet[]"])),
|
||||
};
|
||||
}, [id, search]);
|
||||
}, [outfitId, query]);
|
||||
|
||||
return memoizedOutfitState;
|
||||
}
|
||||
|
||||
/**
|
||||
* getValueFromQuery reads the given value from Next's `router.query` as a
|
||||
* single value. For example:
|
||||
*
|
||||
* ?foo=bar -> "bar" -> "bar"
|
||||
* ?foo=bar&foo=baz -> ["bar", "baz"] -> "bar"
|
||||
* ?lol=huh -> undefined -> null
|
||||
*/
|
||||
function getValueFromQuery(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
} else if (value != null) {
|
||||
return value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getListFromQuery reads the given value from Next's `router.query` as a list
|
||||
* of values. For example:
|
||||
*
|
||||
* ?foo=bar -> "bar" -> ["bar"]
|
||||
* ?foo=bar&foo=baz -> ["bar", "baz"] -> ["bar", "baz"]
|
||||
* ?lol=huh -> undefined -> []
|
||||
*/
|
||||
function getListFromQuery(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
} else if (value != null) {
|
||||
return [value];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getOutfitStateFromOutfitData(outfit) {
|
||||
if (!outfit) {
|
||||
return EMPTY_CUSTOMIZATION_STATE;
|
||||
|
@ -602,7 +642,10 @@ function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
|
|||
export function buildOutfitUrl(outfitState, { withoutOutfitId = false } = {}) {
|
||||
const { id } = outfitState;
|
||||
|
||||
const { origin } = window.location;
|
||||
const origin =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://impress-2020.openneo.net";
|
||||
|
||||
if (id && !withoutOutfitId) {
|
||||
return origin + `/outfits/${id}`;
|
||||
|
|
Loading…
Reference in a new issue