From 39fd56ba82ccf7bde20a3b2c248d019c72d16295 Mon Sep 17 00:00:00 2001 From: xrendan Date: Thu, 23 Jul 2026 12:22:24 -0600 Subject: [PATCH 1/3] Add draft preview to posts and builders Posts and builders now prime the admin OAuth access token before fetching (like memos), so signed-in admins can preview unpublished content and see a DRAFT banner. The banner shows only for genuine drafts (no publish date, or one scheduled in the future). Moves DraftPreviewBanner out of the memos route into components/auth so all three content types can share it. Builders now reads publishedAt from the API (York Factory exposes it in the builder serializer) to make the genuine-draft distinction. --- src/app/builders/[slug]/page.tsx | 43 ++++++++++++++++++- src/app/memos/[slug]/page.tsx | 2 +- src/app/posts/[slug]/page.tsx | 42 +++++++++++++++++- .../auth}/DraftPreviewBanner.tsx | 0 src/lib/api/builders.ts | 3 ++ 5 files changed, 87 insertions(+), 3 deletions(-) rename src/{app/memos/[slug] => components/auth}/DraftPreviewBanner.tsx (100%) diff --git a/src/app/builders/[slug]/page.tsx b/src/app/builders/[slug]/page.tsx index ca4d2d26..afa8d37b 100644 --- a/src/app/builders/[slug]/page.tsx +++ b/src/app/builders/[slug]/page.tsx @@ -3,6 +3,20 @@ import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import { fetchBuilder } from "@/lib/api/builders"; +import { DraftPreviewBanner } from "@/components/auth/DraftPreviewBanner"; +import { setAccessToken } from "@/lib/auth-token"; +import { getCurrentUser, getAccessTokenCookie } from "@/lib/auth"; + +// Draft preview is gated on the signed-in user actually being an admin (live +// from /me), never on a baked cookie. When they are, we hand apiFetch the +// access token so it fetches drafts; otherwise the request store stays empty +// and only published content is returned. +async function resolveAccessToken(): Promise { + const user = await getCurrentUser(); + const token = user?.admin ? await getAccessTokenCookie() : undefined; + setAccessToken(token); + return token; +} export async function generateMetadata({ params, @@ -10,6 +24,8 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; + // Prime the request-scoped token so draft metadata resolves for admins. + await resolveAccessToken(); try { const builder = await fetchBuilder(slug); return { @@ -37,19 +53,44 @@ export default async function BuilderPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; + + const accessToken = await resolveAccessToken(); + let builder; try { builder = await fetchBuilder(slug); } catch { - notFound(); + if (!accessToken) notFound(); + + return ( +
+
+

404

+

Builder not found

+

+ This builder doesn't exist or hasn't been published yet. +

+ +
+
+ ); } if (builder.slug !== slug) { permanentRedirect(`/builders/${builder.slug}`); } + // A builder is a draft when it has no publish date, or one scheduled in the + // future. The preview banner is for genuine drafts only — not every builder + // an admin happens to be viewing. + const isDraft = + !builder.publishedAt || new Date(builder.publishedAt) > new Date(); + return (
+ {accessToken && isDraft && ( + + )}
{ + const user = await getCurrentUser(); + const token = user?.admin ? await getAccessTokenCookie() : undefined; + setAccessToken(token); + return token; +} export async function generateStaticParams() { try { @@ -25,6 +39,8 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; + // Prime the request-scoped token so draft metadata resolves for admins. + await resolveAccessToken(); let post; try { post = await fetchPost(slug); @@ -65,17 +81,38 @@ export default async function PostDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; + + const accessToken = await resolveAccessToken(); + let post; try { post = await fetchPost(slug); } catch { - notFound(); + if (!accessToken) notFound(); + + return ( +
+
+

404

+

Post not found

+

+ This post doesn't exist or hasn't been published yet. +

+ +
+
+ ); } if (post.slug !== slug) { permanentRedirect(`/posts/${post.slug}`); } + // A post is a draft when it has no publish date, or one scheduled in the + // future. The preview banner is for genuine drafts only — not every post an + // admin happens to be viewing. + const isDraft = !post.publishedAt || new Date(post.publishedAt) > new Date(); + const date = new Date(post.publishedAt || post.createdAt).toLocaleDateString( "en-CA", { year: "numeric", month: "long", day: "numeric" }, @@ -102,6 +139,9 @@ export default async function PostDetailPage({ return (
+ {accessToken && isDraft && ( + + )}