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
+
+
+
+ );
+}