diff --git a/app/components/Recommendation.tsx b/app/components/Recommendation.tsx index a504552..2405a2e 100644 --- a/app/components/Recommendation.tsx +++ b/app/components/Recommendation.tsx @@ -28,7 +28,7 @@ export function Recommendation({ recommendation }: { recommendation: HydratedRec return (
{!!image && ( @@ -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..64ad667 --- /dev/null +++ b/app/routes/$slug.tsx @@ -0,0 +1,124 @@ +import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +import { isRouteErrorResponse, Link, 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 ( +
+
+
+ + Recommendations + +
+ +
+
+ ); +} + +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
+
+
+
+ ); +}