From 2748c4ad21cbc001bb238341b7f9d8f68c546963 Mon Sep 17 00:00:00 2001 From: Atharva Yadav Date: Mon, 12 Oct 2026 11:48:12 +0000 Subject: [PATCH 1/6] Added Authentication, google and email signin, database for likes, comments --- .github/workflows/ci.yml | 3 + morya-techcoder/.env.example | 9 + morya-techcoder/CLAUDE.md | 20 +- morya-techcoder/README.md | 8 +- morya-techcoder/app/(site)/account/actions.ts | 33 ++ morya-techcoder/app/(site)/account/page.tsx | 58 +++ .../app/(site)/auth/callback/route.ts | 52 +++ .../app/(site)/auth/forgot-password/page.tsx | 29 ++ .../app/(site)/auth/sign-in/page.tsx | 48 +++ .../app/(site)/auth/sign-up/page.tsx | 39 ++ .../app/(site)/auth/update-password/page.tsx | 22 ++ .../app/(site)/blog/[slug]/layout.tsx | 10 +- .../app/(site)/blog/[slug]/page.tsx | 8 +- morya-techcoder/app/(site)/privacy/page.tsx | 12 +- morya-techcoder/app/(site)/terms/page.tsx | 6 +- .../app/api/articles/[slug]/comments/route.ts | 42 ++ .../api/articles/[slug]/engagement/route.ts | 43 ++ .../app/api/articles/[slug]/like/route.ts | 43 ++ .../app/api/comments/[id]/report/route.ts | 33 ++ .../app/api/comments/[id]/route.ts | 42 ++ .../components/auth/AccountMenu.tsx | 149 +++++++ .../components/auth/AuthProvider.tsx | 106 +++++ morya-techcoder/components/auth/AuthUi.tsx | 98 +++++ .../components/auth/ForgotPasswordForm.tsx | 66 ++++ .../components/auth/GoogleButton.tsx | 122 ++++++ .../components/auth/ProfileForm.tsx | 71 ++++ .../components/auth/SignInForm.tsx | 103 +++++ .../components/auth/SignUpForm.tsx | 118 ++++++ .../components/auth/UpdatePasswordForm.tsx | 82 ++++ .../engagement/ArticleActionBar.tsx | 96 +++++ .../components/engagement/CommentItem.tsx | 135 +++++++ .../engagement/EngagementProvider.tsx | 162 ++++++++ .../components/engagement/Responses.tsx | 184 +++++++++ morya-techcoder/components/layout/Navbar.tsx | 3 + .../components/layout/SiteShell.tsx | 39 +- morya-techcoder/components/ui/UserAvatar.tsx | 55 +++ morya-techcoder/docs/README.md | 4 +- morya-techcoder/docs/architecture.md | 16 +- morya-techcoder/docs/auth-and-engagement.md | 263 ++++++++++++ morya-techcoder/docs/deployment.md | 13 +- morya-techcoder/docs/diagrams.md | 10 +- morya-techcoder/docs/folder-structure.md | 28 +- morya-techcoder/docs/newsletter.md | 3 +- morya-techcoder/lib/auth.ts | 25 ++ morya-techcoder/lib/engagement/server.ts | 122 ++++++ .../lib/engagement/validation.test.ts | 96 +++++ morya-techcoder/lib/engagement/validation.ts | 109 +++++ morya-techcoder/lib/supabase/client.ts | 11 + morya-techcoder/lib/supabase/config.ts | 12 + morya-techcoder/lib/supabase/server.ts | 46 +++ morya-techcoder/next.config.ts | 16 +- morya-techcoder/package-lock.json | 115 ++++++ morya-techcoder/package.json | 5 +- morya-techcoder/proxy.ts | 41 ++ morya-techcoder/skills-lock.json | 23 ++ morya-techcoder/supabase/.gitignore | 8 + morya-techcoder/supabase/config.toml | 5 + ...2090000_reader_accounts_likes_comments.sql | 373 ++++++++++++++++++ ...20000_sync_profiles_from_auth_metadata.sql | 97 +++++ .../supabase/tests/database/rls.test.sql | 176 +++++++++ morya-techcoder/tsconfig.json | 1 + morya-techcoder/types/engagement.ts | 22 ++ 62 files changed, 3739 insertions(+), 50 deletions(-) create mode 100644 morya-techcoder/app/(site)/account/actions.ts create mode 100644 morya-techcoder/app/(site)/account/page.tsx create mode 100644 morya-techcoder/app/(site)/auth/callback/route.ts create mode 100644 morya-techcoder/app/(site)/auth/forgot-password/page.tsx create mode 100644 morya-techcoder/app/(site)/auth/sign-in/page.tsx create mode 100644 morya-techcoder/app/(site)/auth/sign-up/page.tsx create mode 100644 morya-techcoder/app/(site)/auth/update-password/page.tsx create mode 100644 morya-techcoder/app/api/articles/[slug]/comments/route.ts create mode 100644 morya-techcoder/app/api/articles/[slug]/engagement/route.ts create mode 100644 morya-techcoder/app/api/articles/[slug]/like/route.ts create mode 100644 morya-techcoder/app/api/comments/[id]/report/route.ts create mode 100644 morya-techcoder/app/api/comments/[id]/route.ts create mode 100644 morya-techcoder/components/auth/AccountMenu.tsx create mode 100644 morya-techcoder/components/auth/AuthProvider.tsx create mode 100644 morya-techcoder/components/auth/AuthUi.tsx create mode 100644 morya-techcoder/components/auth/ForgotPasswordForm.tsx create mode 100644 morya-techcoder/components/auth/GoogleButton.tsx create mode 100644 morya-techcoder/components/auth/ProfileForm.tsx create mode 100644 morya-techcoder/components/auth/SignInForm.tsx create mode 100644 morya-techcoder/components/auth/SignUpForm.tsx create mode 100644 morya-techcoder/components/auth/UpdatePasswordForm.tsx create mode 100644 morya-techcoder/components/engagement/ArticleActionBar.tsx create mode 100644 morya-techcoder/components/engagement/CommentItem.tsx create mode 100644 morya-techcoder/components/engagement/EngagementProvider.tsx create mode 100644 morya-techcoder/components/engagement/Responses.tsx create mode 100644 morya-techcoder/components/ui/UserAvatar.tsx create mode 100644 morya-techcoder/docs/auth-and-engagement.md create mode 100644 morya-techcoder/lib/auth.ts create mode 100644 morya-techcoder/lib/engagement/server.ts create mode 100644 morya-techcoder/lib/engagement/validation.test.ts create mode 100644 morya-techcoder/lib/engagement/validation.ts create mode 100644 morya-techcoder/lib/supabase/client.ts create mode 100644 morya-techcoder/lib/supabase/config.ts create mode 100644 morya-techcoder/lib/supabase/server.ts create mode 100644 morya-techcoder/proxy.ts create mode 100644 morya-techcoder/skills-lock.json create mode 100644 morya-techcoder/supabase/.gitignore create mode 100644 morya-techcoder/supabase/config.toml create mode 100644 morya-techcoder/supabase/migrations/20261012090000_reader_accounts_likes_comments.sql create mode 100644 morya-techcoder/supabase/migrations/20261012120000_sync_profiles_from_auth_metadata.sql create mode 100644 morya-techcoder/supabase/tests/database/rls.test.sql create mode 100644 morya-techcoder/types/engagement.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 125c574..25c9031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Unit tests + run: npm test + # Also validates every MDX post compiles and every static route # (articles, share images, sitemap) prerenders. - name: Build diff --git a/morya-techcoder/.env.example b/morya-techcoder/.env.example index a0a1f44..6621474 100644 --- a/morya-techcoder/.env.example +++ b/morya-techcoder/.env.example @@ -20,3 +20,12 @@ NEXT_PUBLIC_SITE_URL= # on a real deployment — storage is `local`, so it would expose an # unauthenticated filesystem write API. See docs/keystatic.md. ENABLE_KEYSTATIC= + +# Reader accounts, likes and responses (Supabase). Both values are public and +# safe in the browser — Row Level Security protects the data. Without them the +# site works; sign-in, likes and responses are simply hidden. +# Hosted project: Dashboard → Project Settings → API Keys. +# The app never uses the service-role/secret key — do not add it here. +# Rebuild after changing these: they're inlined into the client bundle and CSP. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= diff --git a/morya-techcoder/CLAUDE.md b/morya-techcoder/CLAUDE.md index 98f7949..cf564f4 100644 --- a/morya-techcoder/CLAUDE.md +++ b/morya-techcoder/CLAUDE.md @@ -5,7 +5,8 @@ **TechCoder** is an MDX-based tech publishing platform: Next.js 16 (App Router) + React 19 + Tailwind CSS v4, with [Keystatic](https://keystatic.com) (a Git-based CMS) as a visual editor on top of plain `.mdx`/`.json` files. -Static site generation, no database. +Static site generation. Content is never stored in a database. Supabase backs +only the optional reader accounts, likes and responses. **This file is a map, not the manual.** Full, current documentation lives in [`docs/`](./docs/README.md) — read [docs/README.md](./docs/README.md) first, @@ -19,7 +20,10 @@ misleading. ## Fast orientation - Content lives in Git: `content/posts/*.mdx` (articles) and - `content/authors/*.json` (bylines). No database. + `content/authors/*.json` (bylines). Never move it into a database. The only + database is Supabase, for optional reader accounts, likes and responses + ([docs/auth-and-engagement.md](./docs/auth-and-engagement.md)). It loads + client-side, so articles stay static. - One-way data flow: `content/loader.ts` (data) → server components → client components (props only). Never import `content/loader.ts` into a client component — it's `server-only`. @@ -80,8 +84,8 @@ short version, because they're easy to reintroduce without realizing it: - An author slug on a post is resolved to a full record in `lib/author.ts`; unset falls back to `DEFAULT_AUTHOR_SLUG` (`team-techcoder`). - CI (`.github/workflows/ci.yml` at the Git root) runs `npm run lint`, - `npm run typecheck` (`next typegen && tsc --noEmit`) and `npm run build`. - Run all three before finishing a change. + `npm run typecheck` (`next typegen && tsc --noEmit`), `npm test` and + `npm run build`. Run them all before finishing a change. - Reading time is always computed from the body (`calcReadingTime`, 238 wpm) — never add it as a frontmatter field. - `npm run dev` after a large dependency change (e.g. bumping @@ -96,4 +100,10 @@ welcome email (`RESEND_AUDIENCE_ID` is optional, for older Resend workspaces), `NEXT_PUBLIC_SITE_URL` sets the public origin when not on Vercel, and `ENABLE_KEYSTATIC=true` re-enables `/keystatic` in a local production build (`next build && next start`) — never set it on a real deployment while -storage is `local`. +storage is `local`. `NEXT_PUBLIC_SUPABASE_URL` + +`NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` enable reader accounts. Without them +that UI hides itself. Never add the Supabase service-role/secret key: every +write runs as the signed-in reader under RLS. Schema changes go in a new file +in `supabase/migrations/` (apply with `npx supabase db push`), and +`npx supabase test db --linked` must still pass. There is no local Supabase +stack. diff --git a/morya-techcoder/README.md b/morya-techcoder/README.md index f970088..63180c7 100644 --- a/morya-techcoder/README.md +++ b/morya-techcoder/README.md @@ -3,7 +3,8 @@ A developer-first publication — technical blogs, AI news, and programming tutorials — built with Next.js 16, React 19, and Tailwind CSS v4. Content is authored in MDX and edited visually with [Keystatic](https://keystatic.com), a -Git-based CMS. No database. +Git-based CMS. Content never touches a database; optional reader accounts +(likes and responses) use Supabase. ## Quick start @@ -21,8 +22,9 @@ npm run dev - Content editor: Everything works out of the box with no `.env` at all — the newsletter signup -form is the only feature that needs one, and it fails closed (shows an error, -doesn't crash) without it. See [`.env.example`](./.env.example) for what each +form and optional reader accounts (likes/responses, via Supabase — see +[docs/auth-and-engagement.md](./docs/auth-and-engagement.md)) are the only +features that need one, and both fail closed without it. See [`.env.example`](./.env.example) for what each variable does. If you deploy anywhere other than Vercel, set `NEXT_PUBLIC_SITE_URL` (e.g. `https://techcoder.tech`), or canonical URLs and the sitemap will point at `http://localhost:3000`. diff --git a/morya-techcoder/app/(site)/account/actions.ts b/morya-techcoder/app/(site)/account/actions.ts new file mode 100644 index 0000000..6905a78 --- /dev/null +++ b/morya-techcoder/app/(site)/account/actions.ts @@ -0,0 +1,33 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient, getVerifiedUser } from "@/lib/supabase/server"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; +import { normalizeDisplayName } from "@/lib/engagement/validation"; + +export type ProfileFormState = { status: "idle" | "saved" | "error"; message?: string }; + +/** Updates the signed-in reader's display name (the only editable field). */ +export async function updateDisplayName(_prev: ProfileFormState, formData: FormData): Promise { + if (!isSupabaseConfigured) return { status: "error", message: "Accounts are not available right now." }; + + const name = normalizeDisplayName(formData.get("displayName")); + if (!name.ok) return { status: "error", message: name.error }; + + const supabase = await createClient(); + const user = await getVerifiedUser(supabase); + if (!user) return { status: "error", message: "Your session has expired. Please sign in again." }; + + const { data, error } = await supabase + .from("profiles") + .update({ display_name: name.value }) + .eq("id", user.id) + .select("id"); + if (error || !data?.length) { + if (error) console.error("[account] update profile:", error.code, error.message); + return { status: "error", message: "Couldn't save your name. Please try again." }; + } + + revalidatePath("/account"); + return { status: "saved", message: "Saved." }; +} diff --git a/morya-techcoder/app/(site)/account/page.tsx b/morya-techcoder/app/(site)/account/page.tsx new file mode 100644 index 0000000..4ca8cc7 --- /dev/null +++ b/morya-techcoder/app/(site)/account/page.tsx @@ -0,0 +1,58 @@ +import { redirect } from "next/navigation"; +import { AuthShell, TextLink } from "@/components/auth/AuthUi"; +import { ProfileForm, SignOutButton } from "@/components/auth/ProfileForm"; +import UserAvatar from "@/components/ui/UserAvatar"; +import { authMetadata } from "@/lib/auth"; +import { createClient, getVerifiedUser } from "@/lib/supabase/server"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; + +export const metadata = authMetadata("Your profile"); + +/** Minimal profile: avatar, display name (editable), email, password, sign out. */ +export default async function AccountPage() { + if (!isSupabaseConfigured) redirect("/auth/sign-in?error=unavailable"); + const supabase = await createClient(); + const user = await getVerifiedUser(supabase); + if (!user) redirect("/auth/sign-in?next=%2Faccount"); + + const { data: profile } = await supabase + .from("profiles") + .select("display_name, avatar_url") + .eq("id", user.id) + .maybeSingle(); + const displayName = profile?.display_name ?? "Reader"; + const hasPassword = user.identities?.some((identity) => identity.provider === "email") ?? false; + + return ( + +
+ +
+

{displayName}

+

{user.email}

+
+
+ +
+ +
+ +
+
+
Email
+
{user.email}
+
+
+
Password
+
+ {hasPassword ? "Change password" : "Set a password"} +
+
+
+ +
+ +
+
+ ); +} diff --git a/morya-techcoder/app/(site)/auth/callback/route.ts b/morya-techcoder/app/(site)/auth/callback/route.ts new file mode 100644 index 0000000..4e0979a --- /dev/null +++ b/morya-techcoder/app/(site)/auth/callback/route.ts @@ -0,0 +1,52 @@ +import { NextResponse, type NextRequest } from "next/server"; +import type { EmailOtpType } from "@supabase/supabase-js"; +import { createClient } from "@/lib/supabase/server"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; +import { safeNextPath } from "@/lib/engagement/validation"; + +const OTP_TYPES: ReadonlySet = new Set(["email", "signup", "recovery", "email_change", "invite", "magiclink"]); + +// Relative Location: the browser resolves it against the URL it actually used, +// so the session cookie and the destination always share a host (proxies and +// tunnels can rewrite the Host header the server sees). +function redirectTo(path: string) { + return new NextResponse(null, { status: 303, headers: { Location: path } }); +} + +/** + * Lands every auth redirect: Google OAuth and default (PKCE) email links send + * `?code=`; the token-hash email templates (docs/auth-and-engagement.md) send + * `?token_hash=&type=`. Either way the session cookie is set here and the + * reader continues to the sanitized `next` path. + */ +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const next = safeNextPath(searchParams.get("next")); + const code = searchParams.get("code"); + const tokenHash = searchParams.get("token_hash"); + const type = searchParams.get("type"); + + const fail = (reason: string) => { + const params = new URLSearchParams({ error: reason }); + if (next !== "/") params.set("next", next); + return redirectTo(`/auth/sign-in?${params}`); + }; + + if (!isSupabaseConfigured) return fail("unavailable"); + // Provider-side errors (e.g. the user cancelled Google consent). + if (searchParams.get("error")) return fail("cancelled"); + + const supabase = await createClient(); + + if (code) { + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (error) return fail("link"); + } else if (tokenHash && type && OTP_TYPES.has(type)) { + const { error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type: type as EmailOtpType }); + if (error) return fail("link"); + } else { + return fail("link"); + } + + return redirectTo(next); +} diff --git a/morya-techcoder/app/(site)/auth/forgot-password/page.tsx b/morya-techcoder/app/(site)/auth/forgot-password/page.tsx new file mode 100644 index 0000000..d437f1f --- /dev/null +++ b/morya-techcoder/app/(site)/auth/forgot-password/page.tsx @@ -0,0 +1,29 @@ +import { AuthShell, FormMessage, TextLink, withNext } from "@/components/auth/AuthUi"; +import ForgotPasswordForm from "@/components/auth/ForgotPasswordForm"; +import { authMetadata, readNext, type AuthSearchParams } from "@/lib/auth"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; + +export const metadata = authMetadata("Reset password"); + +export default async function ForgotPasswordPage({ searchParams }: { searchParams: AuthSearchParams }) { + const next = await readNext(searchParams); + + return ( + + Remembered it? Back to sign in + + } + > + {isSupabaseConfigured ? ( + + ) : ( + Accounts are not available right now. + )} + + ); +} diff --git a/morya-techcoder/app/(site)/auth/sign-in/page.tsx b/morya-techcoder/app/(site)/auth/sign-in/page.tsx new file mode 100644 index 0000000..940211e --- /dev/null +++ b/morya-techcoder/app/(site)/auth/sign-in/page.tsx @@ -0,0 +1,48 @@ +import { AuthShell, FormMessage, TextLink, withNext } from "@/components/auth/AuthUi"; +import { GoogleButton, OrDivider } from "@/components/auth/GoogleButton"; +import SignInForm from "@/components/auth/SignInForm"; +import { authMetadata, readNext, redirectIfSignedIn, type AuthSearchParams } from "@/lib/auth"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; + +export const metadata = authMetadata("Sign in"); + +const CALLBACK_ERRORS: Record = { + link: "That sign-in link is invalid or has expired. Please try again.", + cancelled: "Sign-in was cancelled.", + unavailable: "Accounts are not available right now.", +}; + +export default async function SignInPage({ searchParams }: { searchParams: AuthSearchParams }) { + const next = await readNext(searchParams); + await redirectIfSignedIn(next); + const errorKey = (await searchParams).error; + const callbackError = typeof errorKey === "string" ? CALLBACK_ERRORS[errorKey] : undefined; + + return ( + + New here? Create an account + + } + > + {!isSupabaseConfigured ? ( + {CALLBACK_ERRORS.unavailable} + ) : ( + <> + {callbackError && ( +
+ {callbackError} +
+ )} + + + + + )} +
+ ); +} diff --git a/morya-techcoder/app/(site)/auth/sign-up/page.tsx b/morya-techcoder/app/(site)/auth/sign-up/page.tsx new file mode 100644 index 0000000..a4987e6 --- /dev/null +++ b/morya-techcoder/app/(site)/auth/sign-up/page.tsx @@ -0,0 +1,39 @@ +import { AuthShell, FormMessage, TextLink, withNext } from "@/components/auth/AuthUi"; +import { GoogleButton, OrDivider } from "@/components/auth/GoogleButton"; +import SignUpForm from "@/components/auth/SignUpForm"; +import { authMetadata, readNext, redirectIfSignedIn, type AuthSearchParams } from "@/lib/auth"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; + +export const metadata = authMetadata("Create account"); + +export default async function SignUpPage({ searchParams }: { searchParams: AuthSearchParams }) { + const next = await readNext(searchParams); + await redirectIfSignedIn(next); + + return ( + + Already have an account? Sign in + + } + > + {!isSupabaseConfigured ? ( + Accounts are not available right now. + ) : ( + <> + + + +

+ By creating an account you agree to our Terms and{" "} + Privacy Policy. +

+ + )} +
+ ); +} diff --git a/morya-techcoder/app/(site)/auth/update-password/page.tsx b/morya-techcoder/app/(site)/auth/update-password/page.tsx new file mode 100644 index 0000000..a75c857 --- /dev/null +++ b/morya-techcoder/app/(site)/auth/update-password/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/navigation"; +import { AuthShell } from "@/components/auth/AuthUi"; +import UpdatePasswordForm from "@/components/auth/UpdatePasswordForm"; +import { authMetadata } from "@/lib/auth"; +import { createClient, getVerifiedUser } from "@/lib/supabase/server"; +import { isSupabaseConfigured } from "@/lib/supabase/config"; + +export const metadata = authMetadata("Choose a new password"); + +/** Reached from the password-reset email (via /auth/callback) or the profile page. */ +export default async function UpdatePasswordPage() { + if (!isSupabaseConfigured) redirect("/auth/sign-in?error=unavailable"); + const supabase = await createClient(); + const user = await getVerifiedUser(supabase); + if (!user) redirect("/auth/sign-in?error=link"); + + return ( + + + + ); +} diff --git a/morya-techcoder/app/(site)/blog/[slug]/layout.tsx b/morya-techcoder/app/(site)/blog/[slug]/layout.tsx index 379469f..a108735 100644 --- a/morya-techcoder/app/(site)/blog/[slug]/layout.tsx +++ b/morya-techcoder/app/(site)/blog/[slug]/layout.tsx @@ -1,5 +1,6 @@ import { JetBrains_Mono } from "next/font/google"; import ReadingLayer from "@/components/reading/ReadingLayer"; +import { EngagementProvider } from "@/components/engagement/EngagementProvider"; import "./article.css"; const jetbrainsMono = JetBrains_Mono({ @@ -12,15 +13,20 @@ const jetbrainsMono = JetBrains_Mono({ /** * Article route layout — scopes JetBrains Mono + immersive reading chrome * (progress / floating TOC) so Framer Motion never ships on home or list pages. + * Also hosts the likes/responses island, which loads client-side so the + * article HTML stays static. */ -export default function ArticleLayout({ +export default async function ArticleLayout({ children, + params, }: Readonly<{ children: React.ReactNode; + params: Promise<{ slug: string }>; }>) { + const { slug } = await params; return (
- {children} + {children} {/* Outside the article content tree so fixed chrome isn't trapped in main's stacking context. Self-gates on reading context. diff --git a/morya-techcoder/app/(site)/blog/[slug]/page.tsx b/morya-techcoder/app/(site)/blog/[slug]/page.tsx index 2d00167..2e2e11a 100644 --- a/morya-techcoder/app/(site)/blog/[slug]/page.tsx +++ b/morya-techcoder/app/(site)/blog/[slug]/page.tsx @@ -18,6 +18,8 @@ import SetReadingChrome from "@/components/reading/SetReadingChrome"; import DifficultyBadge from "@/components/ui/DifficultyBadge"; import CategoryBadge from "@/components/ui/CategoryBadge"; import RatingStars from "@/components/ui/RatingStars"; +import ArticleActionBar from "@/components/engagement/ArticleActionBar"; +import Responses from "@/components/engagement/Responses"; type Props = { params: Promise<{ slug: string }>; @@ -268,7 +270,7 @@ export default async function BlogDetailPage({ params }: Props) {
{mdxContent}
{/* Tags */} -
+
{post.tags.map((tag) => ( + + {/* Byline */} {author.bio && (
@@ -309,6 +313,8 @@ export default async function BlogDetailPage({ params }: Props) { )}
+ + {(adjacent.newer || adjacent.older) && (