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:
Emi Matchu 2022-09-15 04:03:51 -07:00
parent e42f39f49b
commit 16c9e1a25d
3 changed files with 112 additions and 116 deletions

View file

@ -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;

View file

@ -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,6 +76,13 @@ 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 (
<>
<Head>
<title>
{outfitState.name || "Untitled outfit"} | Dress to Impress
</title>
{outfitState.id && <SavedOutfitMetaTags outfitState={outfitState} />}
</Head>
<OutfitStateContext.Provider value={outfitState}> <OutfitStateContext.Provider value={outfitState}>
<SupportOnly> <SupportOnly>
<WardrobeDevHacks /> <WardrobeDevHacks />
@ -116,6 +121,39 @@ function WardrobePage() {
searchFooter={<SearchFooter />} searchFooter={<SearchFooter />}
/> />
</OutfitStateContext.Provider> </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"}
/>
<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!"
/>
</>
); );
} }

View file

@ -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,