Simplify page title & SSR for saved outfit
Now we can just use our usual pattern: preload some GraphQL data, and render the title and such in the page component itself! Whew!
This commit is contained in:
parent
e42f39f49b
commit
16c9e1a25d
3 changed files with 112 additions and 116 deletions
|
@ -1,96 +1,46 @@
|
||||||
// This is a copy of our higher-level catch-all page, but with some
|
|
||||||
// extra SSR for outfit sharing meta tags!
|
|
||||||
|
|
||||||
import Head from "next/head";
|
|
||||||
import { NextPageWithLayout } from "../_app";
|
import { NextPageWithLayout } from "../_app";
|
||||||
import WardrobePage from "../../src/app/WardrobePage";
|
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 { GetServerSideProps } from "next";
|
||||||
|
import { gql, loadGraphqlQuery } from "../../src/server/ssr-graphql";
|
||||||
|
|
||||||
type Outfit = {
|
const WardrobePageWrapper: NextPageWithLayout = () => {
|
||||||
id: string;
|
return <WardrobePage />;
|
||||||
name: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
type PageProps = {
|
|
||||||
outfit: Outfit;
|
|
||||||
};
|
|
||||||
|
|
||||||
const WardrobePageWrapper: NextPageWithLayout<PageProps> = ({ outfit }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{outfit.name || "Untitled outfit"} | Dress to Impress</title>
|
|
||||||
<OutfitMetaTags outfit={outfit} />
|
|
||||||
</Head>
|
|
||||||
<WardrobePage />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
WardrobePageWrapper.renderWithLayout = (children) => children;
|
WardrobePageWrapper.renderWithLayout = (children) => children;
|
||||||
|
|
||||||
function OutfitMetaTags({ outfit }: { outfit: Outfit }) {
|
|
||||||
const updatedAtTimestamp = Math.floor(
|
|
||||||
new Date(outfit.updatedAt).getTime() / 1000
|
|
||||||
);
|
|
||||||
const outfitUrl =
|
|
||||||
`https://impress-2020.openneo.net/outfits` +
|
|
||||||
`/${encodeURIComponent(outfit.id)}`;
|
|
||||||
const imageUrl =
|
|
||||||
`https://impress-outfit-images.openneo.net/outfits` +
|
|
||||||
`/${encodeURIComponent(outfit.id)}` +
|
|
||||||
`/v/${encodeURIComponent(updatedAtTimestamp)}` +
|
|
||||||
`/600.png`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<meta property="og:title" content={outfit.name || "Untitled outfit"} />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:image" content={imageUrl} />
|
|
||||||
<meta property="og:url" content={outfitUrl} />
|
|
||||||
<meta property="og:site_name" content="Dress to Impress" />
|
|
||||||
<meta
|
|
||||||
property="og:description"
|
|
||||||
content="A custom Neopets outfit, designed on Dress to Impress!"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
|
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
|
||||||
const outfitId = params?.outfitId;
|
const outfitId = params?.outfitId;
|
||||||
if (typeof outfitId !== "string") {
|
if (typeof outfitId !== "string") {
|
||||||
throw new Error(`assertion failed: outfitId route param is missing`);
|
throw new Error(`assertion failed: outfitId route param is missing`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const outfit = await loadOutfitData(outfitId);
|
const { data, errors, graphqlState } = await loadGraphqlQuery({
|
||||||
if (outfit == null) {
|
query: gql`
|
||||||
|
query OutfitsOutfitId_GetServerSideProps($outfitId: ID!) {
|
||||||
|
outfit(id: $outfitId) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: { outfitId },
|
||||||
|
});
|
||||||
|
if (errors) {
|
||||||
|
console.warn(
|
||||||
|
`[SSR: /outfits/[outfitId]] Skipping GraphQL preloading, got errors:`
|
||||||
|
);
|
||||||
|
for (const error of errors) {
|
||||||
|
console.warn(`[SSR: /outfits/[outfitId]]`, error);
|
||||||
|
}
|
||||||
|
return { props: { outfit: null, graphqlState: {} } };
|
||||||
|
}
|
||||||
|
if (data?.outfit == null) {
|
||||||
return { notFound: true };
|
return { notFound: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { props: { graphqlState } };
|
||||||
props: {
|
|
||||||
outfit: {
|
|
||||||
id: outfit.id,
|
|
||||||
name: outfit.name,
|
|
||||||
updatedAt: outfit.updatedAt.toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadOutfitData(id: string) {
|
|
||||||
const db = await connectToDb();
|
|
||||||
const [rows] = await db.query(`SELECT * FROM outfits WHERE id = ?;`, [id]);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeRow(rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WardrobePageWrapper;
|
export default WardrobePageWrapper;
|
||||||
|
|
|
@ -7,10 +7,10 @@ import SearchFooter from "./SearchFooter";
|
||||||
import SupportOnly from "./support/SupportOnly";
|
import SupportOnly from "./support/SupportOnly";
|
||||||
import useOutfitSaving from "./useOutfitSaving";
|
import useOutfitSaving from "./useOutfitSaving";
|
||||||
import useOutfitState, { OutfitStateContext } from "./useOutfitState";
|
import useOutfitState, { OutfitStateContext } from "./useOutfitState";
|
||||||
import { usePageTitle } from "../util";
|
|
||||||
import WardrobePageLayout from "./WardrobePageLayout";
|
import WardrobePageLayout from "./WardrobePageLayout";
|
||||||
import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
|
import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
const WardrobeDevHacks = loadable(() => import("./WardrobeDevHacks"));
|
const WardrobeDevHacks = loadable(() => import("./WardrobeDevHacks"));
|
||||||
|
|
||||||
|
@ -35,8 +35,6 @@ function WardrobePage() {
|
||||||
// <Prompt /> in this component to prevent navigating away before saving.
|
// <Prompt /> in this component to prevent navigating away before saving.
|
||||||
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
|
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
|
||||||
|
|
||||||
usePageTitle(outfitState.name || "Untitled outfit");
|
|
||||||
|
|
||||||
// TODO: I haven't found a great place for this error UI yet, and this case
|
// TODO: I haven't found a great place for this error UI yet, and this case
|
||||||
// isn't very common, so this lil toast notification seems good enough!
|
// isn't very common, so this lil toast notification seems good enough!
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
@ -78,44 +76,84 @@ function WardrobePage() {
|
||||||
// that need it, where it's more useful and more performant to access
|
// that need it, where it's more useful and more performant to access
|
||||||
// via context.
|
// via context.
|
||||||
return (
|
return (
|
||||||
<OutfitStateContext.Provider value={outfitState}>
|
<>
|
||||||
<SupportOnly>
|
<Head>
|
||||||
<WardrobeDevHacks />
|
<title>
|
||||||
</SupportOnly>
|
{outfitState.name || "Untitled outfit"} | Dress to Impress
|
||||||
|
</title>
|
||||||
|
{outfitState.id && <SavedOutfitMetaTags outfitState={outfitState} />}
|
||||||
|
</Head>
|
||||||
|
<OutfitStateContext.Provider value={outfitState}>
|
||||||
|
<SupportOnly>
|
||||||
|
<WardrobeDevHacks />
|
||||||
|
</SupportOnly>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
* TODO: This might unnecessarily block navigations that we don't
|
* TODO: This might unnecessarily block navigations that we don't
|
||||||
* necessarily need to, e.g., navigating back to Your Outfits while the
|
* necessarily need to, e.g., navigating back to Your Outfits while the
|
||||||
* save request is in flight. We could instead submit the save mutation
|
* save request is in flight. We could instead submit the save mutation
|
||||||
* immediately on client-side nav, and have each outfit save mutation
|
* immediately on client-side nav, and have each outfit save mutation
|
||||||
* install a `beforeunload` handler that ensures that you don't close
|
* install a `beforeunload` handler that ensures that you don't close
|
||||||
* the page altogether while it's in flight. But let's start simple and
|
* the page altogether while it's in flight. But let's start simple and
|
||||||
* see how annoying it actually is in practice lol
|
* see how annoying it actually is in practice lol
|
||||||
*/}
|
*/}
|
||||||
<Prompt
|
<Prompt
|
||||||
when={shouldBlockNavigation}
|
when={shouldBlockNavigation}
|
||||||
message="Are you sure you want to leave? Your changes might not be saved."
|
message="Are you sure you want to leave? Your changes might not be saved."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WardrobePageLayout
|
<WardrobePageLayout
|
||||||
previewAndControls={
|
previewAndControls={
|
||||||
<WardrobePreviewAndControls
|
<WardrobePreviewAndControls
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
outfitState={outfitState}
|
outfitState={outfitState}
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
itemsAndMaybeSearchPanel={
|
itemsAndMaybeSearchPanel={
|
||||||
<ItemsAndSearchPanels
|
<ItemsAndSearchPanels
|
||||||
loading={loading}
|
loading={loading}
|
||||||
outfitState={outfitState}
|
outfitState={outfitState}
|
||||||
outfitSaving={outfitSaving}
|
outfitSaving={outfitSaving}
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
searchFooter={<SearchFooter />}
|
searchFooter={<SearchFooter />}
|
||||||
|
/>
|
||||||
|
</OutfitStateContext.Provider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SavedOutfitMetaTags renders the meta tags that we use to render pretty
|
||||||
|
* share cards for social media for saved outfits!
|
||||||
|
*/
|
||||||
|
function SavedOutfitMetaTags({ outfitState }) {
|
||||||
|
const updatedAtTimestamp = Math.floor(
|
||||||
|
new Date(outfitState.updatedAt).getTime() / 1000
|
||||||
|
);
|
||||||
|
const imageUrl =
|
||||||
|
`https://impress-outfit-images.openneo.net/outfits` +
|
||||||
|
`/${encodeURIComponent(outfitState.id)}` +
|
||||||
|
`/v/${encodeURIComponent(updatedAtTimestamp)}` +
|
||||||
|
`/600.png`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<meta
|
||||||
|
property="og:title"
|
||||||
|
content={outfitState.name || "Untitled outfit"}
|
||||||
/>
|
/>
|
||||||
</OutfitStateContext.Provider>
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:image" content={imageUrl} />
|
||||||
|
<meta property="og:url" content={outfitState.url} />
|
||||||
|
<meta property="og:site_name" content="Dress to Impress" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="A custom Neopets outfit, designed on Dress to Impress!"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ function useOutfitState() {
|
||||||
outfit(id: $id) {
|
outfit(id: $id) {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
updatedAt
|
||||||
creator {
|
creator {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
@ -69,6 +70,7 @@ function useOutfitState() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const creator = outfitData?.outfit?.creator;
|
const creator = outfitData?.outfit?.creator;
|
||||||
|
const updatedAt = outfitData?.outfit?.updatedAt;
|
||||||
|
|
||||||
// We memoize this to make `outfitStateWithoutExtras` an even more reliable
|
// We memoize this to make `outfitStateWithoutExtras` an even more reliable
|
||||||
// stable object!
|
// stable object!
|
||||||
|
@ -94,9 +96,14 @@ function useOutfitState() {
|
||||||
// data isn't loaded yet, then this will be a customization state with
|
// data isn't loaded yet, then this will be a customization state with
|
||||||
// partial data, and that's okay.)
|
// partial data, and that's okay.)
|
||||||
let outfitState;
|
let outfitState;
|
||||||
if (urlOutfitState.id === localOutfitState.id) {
|
if (
|
||||||
|
urlOutfitState.id === localOutfitState.id &&
|
||||||
|
localOutfitState.speciesId != null &&
|
||||||
|
localOutfitState.colorId != null
|
||||||
|
) {
|
||||||
// Use the reducer state: they're both for the same saved outfit, or both
|
// Use the reducer state: they're both for the same saved outfit, or both
|
||||||
// for an unsaved outfit (null === null).
|
// for an unsaved outfit (null === null). But we don't use it when it's
|
||||||
|
// *only* got the ID, and no other fields yet.
|
||||||
console.debug("[useOutfitState] Choosing local outfit state");
|
console.debug("[useOutfitState] Choosing local outfit state");
|
||||||
outfitState = localOutfitState;
|
outfitState = localOutfitState;
|
||||||
} else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
|
} else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
|
||||||
|
@ -228,6 +235,7 @@ function useOutfitState() {
|
||||||
const outfitStateWithExtras = {
|
const outfitStateWithExtras = {
|
||||||
id,
|
id,
|
||||||
creator,
|
creator,
|
||||||
|
updatedAt,
|
||||||
zonesAndItems,
|
zonesAndItems,
|
||||||
incompatibleItems,
|
incompatibleItems,
|
||||||
name,
|
name,
|
||||||
|
|
Loading…
Reference in a new issue