From 95371a11f5a9a1885ad8a70323b41c3d043f180f Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Wed, 4 Jun 2025 00:27:32 -0500 Subject: [PATCH 1/2] Add dynamic recommendation pages --- app/components/Recommendation.tsx | 2 +- app/routes/$slug.tsx | 116 ++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 app/routes/$slug.tsx diff --git a/app/components/Recommendation.tsx b/app/components/Recommendation.tsx index a504552..808f335 100644 --- a/app/components/Recommendation.tsx +++ b/app/components/Recommendation.tsx @@ -83,7 +83,7 @@ export function Recommendation({ recommendation }: { recommendation: HydratedRec {createdOn} diff --git a/app/routes/$slug.tsx b/app/routes/$slug.tsx new file mode 100644 index 0000000..275d18f --- /dev/null +++ b/app/routes/$slug.tsx @@ -0,0 +1,116 @@ +import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +import { isRouteErrorResponse, useNavigate } from "react-router"; +import { Recommendation } from "../components/Recommendation"; +import { getCollection } from "../lib/content"; +import { stores } from "../lib/stores.client"; +import { site } from "../lib/site"; +import type { HydratedRec } from "../lib/data"; +import { TokenButton } from "../components/Token"; +import type { Route } from "./+types/$slug"; + +export async function loader({ params, request }: Route.LoaderArgs) { + let collection = await getCollection("recommendations"); + if (import.meta.env.DEV && new URL(request.url).searchParams.has("drafts")) { + collection = [...collection, ...(await getCollection("drafts"))] as typeof collection; + } + const recs = collection.map((rec) => ({ + ...rec.data, + slug: rec.slug as string, + description: rec.body, + })); + const rec = recs.find((r) => r.slug === params.slug); + if (!rec) { + throw new Response("Not Found", { status: 404 }); + } + return { rec, url: request.url }; +} + +let isInitialRequest = true; +const CACHE_KEY = "recommendations"; + +export async function clientLoader({ params, serverLoader }: Route.ClientLoaderArgs) { + async function fetchData() { + const { rec } = await serverLoader(); + const cached = (await stores.cache.get(CACHE_KEY)) ?? []; + if (!cached.find((r) => r.slug === rec.slug)) { + await stores.cache.set(CACHE_KEY, [...cached, rec]); + } + return { rec }; + } + + async function fetchCached() { + const cached = await stores.cache.get(CACHE_KEY); + const rec = cached?.find((r) => r.slug === params.slug); + return rec ? { rec } : null; + } + + if (isInitialRequest) { + isInitialRequest = false; + return await fetchData(); + } + + return (await fetchCached()) ?? (await fetchData()); +} + +export const meta: Route.MetaFunction = ({ data }) => { + if (!data) return []; + const { rec, url } = data as { rec: HydratedRec; url: string }; + const title = `${rec.title} | ${site.title}`; + const description = rec.description.replace(/<[^>]*>/g, "").slice(0, 160); + const image = rec.image.startsWith("http") ? rec.image : new URL(rec.image, url).toString(); + return [ + { title }, + { name: "description", content: description }, + { name: "og:title", content: title }, + { name: "og:type", content: "article" }, + { name: "og:image", content: image }, + { name: "og:description", content: description }, + { name: "og:url", content: url }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: image }, + ]; +}; + +export default function Component({ loaderData }: Route.ComponentProps) { + return ( +
+
+
+ +
+ ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + const navigate = useNavigate(); + let message = "Oops!"; + let details = "An unexpected error occurred."; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = error.statusText || details; + console.error(error); + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + console.error(error); + } + + return ( +
+ +

+ {message} +

+

+ {details} +

+
+ navigate(0)} type="button"> +
Refresh
+
+
+
+ ); +} From 11b7f11680724694a4c9d5cc4f764937bb195102 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Wed, 4 Jun 2025 01:06:24 -0500 Subject: [PATCH 2/2] style: constrain card width --- app/components/Recommendation.tsx | 2 +- app/routes/$slug.tsx | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/components/Recommendation.tsx b/app/components/Recommendation.tsx index 808f335..2405a2e 100644 --- a/app/components/Recommendation.tsx +++ b/app/components/Recommendation.tsx @@ -28,7 +28,7 @@ export function Recommendation({ recommendation }: { recommendation: HydratedRec return (
{!!image && ( diff --git a/app/routes/$slug.tsx b/app/routes/$slug.tsx index 275d18f..64ad667 100644 --- a/app/routes/$slug.tsx +++ b/app/routes/$slug.tsx @@ -1,5 +1,5 @@ import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; -import { isRouteErrorResponse, useNavigate } from "react-router"; +import { isRouteErrorResponse, Link, useNavigate } from "react-router"; import { Recommendation } from "../components/Recommendation"; import { getCollection } from "../lib/content"; import { stores } from "../lib/stores.client"; @@ -78,7 +78,15 @@ export default function Component({ loaderData }: Route.ComponentProps) {
- + + Recommendations + +
+ +
); }