From a123c0b8cc0da87a58a64342831991530ae76024 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 01:38:59 +0200 Subject: [PATCH] feat(frontend): rich OG metadata + dynamic page titles across all routes --- thoughts-frontend/app/(auth)/layout.tsx | 6 +++ thoughts-frontend/app/(auth)/login/layout.tsx | 10 +++++ .../app/(auth)/register/layout.tsx | 10 +++++ thoughts-frontend/app/layout.tsx | 21 +++++++++- thoughts-frontend/app/page.tsx | 6 +++ thoughts-frontend/app/remote-actor/page.tsx | 38 +++++++++++++++++++ thoughts-frontend/app/search/page.tsx | 16 ++++++++ .../app/settings/profile/page.tsx | 6 +++ thoughts-frontend/app/tags/[tagName]/page.tsx | 22 +++++++++++ .../app/thoughts/[thoughtId]/page.tsx | 38 +++++++++++++++++++ .../app/users/[username]/page.tsx | 35 +++++++++++++++++ 11 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 thoughts-frontend/app/(auth)/login/layout.tsx create mode 100644 thoughts-frontend/app/(auth)/register/layout.tsx diff --git a/thoughts-frontend/app/(auth)/layout.tsx b/thoughts-frontend/app/(auth)/layout.tsx index d948319..004d951 100644 --- a/thoughts-frontend/app/(auth)/layout.tsx +++ b/thoughts-frontend/app/(auth)/layout.tsx @@ -1,4 +1,10 @@ // app/(auth)/layout.tsx +import type { Metadata } from "next"; + +export const metadata: Metadata = { + openGraph: { type: "website" }, +}; + export default function AuthLayout({ children, }: { diff --git a/thoughts-frontend/app/(auth)/login/layout.tsx b/thoughts-frontend/app/(auth)/login/layout.tsx new file mode 100644 index 0000000..07c8d60 --- /dev/null +++ b/thoughts-frontend/app/(auth)/login/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Sign in", + description: "Sign in to your Thoughts account", +}; + +export default function LoginLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/thoughts-frontend/app/(auth)/register/layout.tsx b/thoughts-frontend/app/(auth)/register/layout.tsx new file mode 100644 index 0000000..8e40d55 --- /dev/null +++ b/thoughts-frontend/app/(auth)/register/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Join Thoughts", + description: "Create an account on Thoughts and connect across the Fediverse", +}; + +export default function RegisterLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/thoughts-frontend/app/layout.tsx b/thoughts-frontend/app/layout.tsx index 9290d85..4771eb7 100644 --- a/thoughts-frontend/app/layout.tsx +++ b/thoughts-frontend/app/layout.tsx @@ -7,8 +7,25 @@ import localFont from "next/font/local"; import InstallPrompt from "@/components/install-prompt"; export const metadata: Metadata = { - title: "Thoughts", - description: "A social network for sharing thoughts", + title: { + default: "Thoughts", + template: "%s · Thoughts", + }, + description: + "A federated social network for short-form thoughts. Follow people across Mastodon, Pixelfed, and the wider Fediverse.", + openGraph: { + type: "website", + siteName: "Thoughts", + title: "Thoughts", + description: + "A federated social network for short-form thoughts. Follow people across the Fediverse.", + }, + twitter: { + card: "summary", + title: "Thoughts", + description: + "A federated social network for short-form thoughts. Follow people across the Fediverse.", + }, }; const frutiger = localFont({ diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index 039cd02..add13a3 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { cookies } from "next/headers"; import { getFeed, @@ -26,6 +27,11 @@ import { } from "@/components/ui/pagination"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Home", + description: "Your home timeline — thoughts from people you follow", +}; + export default async function Home({ searchParams, }: { diff --git a/thoughts-frontend/app/remote-actor/page.tsx b/thoughts-frontend/app/remote-actor/page.tsx index d8b9bce..d725a43 100644 --- a/thoughts-frontend/app/remote-actor/page.tsx +++ b/thoughts-frontend/app/remote-actor/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { cookies } from "next/headers"; import { getMe, lookupRemoteActor, getRemoteActorPosts, Me } from "@/lib/api"; @@ -7,6 +8,43 @@ interface RemoteActorPageProps { searchParams: Promise<{ handle?: string }>; } +function stripHtml(html: string) { + return html.replace(/<[^>]*>/g, "").trim(); +} + +export async function generateMetadata({ + searchParams, +}: RemoteActorPageProps): Promise { + const { handle } = await searchParams; + if (!handle) return { title: "Profile" }; + + const token = (await cookies()).get("auth_token")?.value ?? null; + const actor = await lookupRemoteActor(handle, token).catch(() => null); + if (!actor) return { title: handle }; + + const name = actor.displayName || actor.handle; + const description = actor.bio + ? stripHtml(actor.bio).slice(0, 160) + : `${name} on the Fediverse. Follow from Thoughts.`; + + return { + title: `${name} (${actor.handle})`, + description, + openGraph: { + type: "profile", + title: `${name} (${actor.handle})`, + description, + images: actor.avatarUrl ? [{ url: actor.avatarUrl }] : [], + }, + twitter: { + card: "summary", + title: `${name} · Thoughts`, + description, + images: actor.avatarUrl ? [actor.avatarUrl] : [], + }, + }; +} + export default async function RemoteActorPage({ searchParams, }: RemoteActorPageProps) { diff --git a/thoughts-frontend/app/search/page.tsx b/thoughts-frontend/app/search/page.tsx index 1414c0e..0b8b6ec 100644 --- a/thoughts-frontend/app/search/page.tsx +++ b/thoughts-frontend/app/search/page.tsx @@ -1,5 +1,21 @@ +import type { Metadata } from "next"; import { cookies } from "next/headers"; import { getMe, search, lookupRemoteActor, User } from "@/lib/api"; + +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise<{ q?: string }>; +}): Promise { + const { q } = await searchParams; + const title = q ? `Search: "${q}"` : "Search"; + return { + title, + description: q + ? `Search results for "${q}" on Thoughts` + : "Search for people and thoughts on Thoughts", + }; +} import { UserListCard } from "@/components/user-list-card"; import { RemoteUserCard } from "@/components/remote-user-card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; diff --git a/thoughts-frontend/app/settings/profile/page.tsx b/thoughts-frontend/app/settings/profile/page.tsx index f0d6b12..dbf77df 100644 --- a/thoughts-frontend/app/settings/profile/page.tsx +++ b/thoughts-frontend/app/settings/profile/page.tsx @@ -1,5 +1,11 @@ // app/settings/profile/page.tsx +import type { Metadata } from "next"; import { cookies } from "next/headers"; + +export const metadata: Metadata = { + title: "Edit profile", + description: "Update your Thoughts profile", +}; import { redirect } from "next/navigation"; import { getMe } from "@/lib/api"; import { EditProfileForm } from "@/components/edit-profile-form"; diff --git a/thoughts-frontend/app/tags/[tagName]/page.tsx b/thoughts-frontend/app/tags/[tagName]/page.tsx index 5626551..a244a89 100644 --- a/thoughts-frontend/app/tags/[tagName]/page.tsx +++ b/thoughts-frontend/app/tags/[tagName]/page.tsx @@ -1,6 +1,28 @@ // app/tags/[tagName]/page.tsx +import type { Metadata } from "next"; import { cookies } from "next/headers"; import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ tagName: string }>; +}): Promise { + const { tagName } = await params; + return { + title: `#${tagName}`, + description: `Thoughts tagged with #${tagName}`, + openGraph: { + title: `#${tagName} · Thoughts`, + description: `Thoughts tagged with #${tagName}`, + }, + twitter: { + card: "summary", + title: `#${tagName} · Thoughts`, + description: `Thoughts tagged with #${tagName}`, + }, + }; +} import { buildThoughtThreads } from "@/lib/utils"; import { ThoughtThread } from "@/components/thought-thread"; import { notFound } from "next/navigation"; diff --git a/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx b/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx index b9ca875..1d87f14 100644 --- a/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx +++ b/thoughts-frontend/app/thoughts/[thoughtId]/page.tsx @@ -1,5 +1,7 @@ +import type { Metadata } from "next"; import { cookies } from "next/headers"; import { + getThoughtById, getThoughtThread, getUserProfile, getMe, @@ -14,6 +16,42 @@ interface ThoughtPageProps { params: Promise<{ thoughtId: string }>; } +function stripHtml(html: string) { + return html.replace(/<[^>]*>/g, "").trim(); +} + +export async function generateMetadata({ + params, +}: ThoughtPageProps): Promise { + const { thoughtId } = await params; + const thought = await getThoughtById(thoughtId, null).catch(() => null); + if (!thought) return { title: "Thought" }; + + const author = thought.author.displayName || thought.author.username; + const preview = stripHtml(thought.content).slice(0, 120); + const description = preview || `A thought by ${author}`; + + return { + title: `${author}: "${preview.slice(0, 60)}${preview.length > 60 ? "…" : ""}"`, + description, + openGraph: { + type: "article", + title: `${author} on Thoughts`, + description, + images: thought.author.avatarUrl + ? [{ url: thought.author.avatarUrl }] + : [], + publishedTime: thought.createdAt.toISOString(), + }, + twitter: { + card: "summary", + title: `${author} on Thoughts`, + description, + images: thought.author.avatarUrl ? [thought.author.avatarUrl] : [], + }, + }; +} + function collectAuthors(thread: ThoughtThreadType): string[] { const authors = new Set([thread.author.username]); for (const reply of thread.replies) { diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index dec722c..776b0de 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { getFollowersList, getFollowingList, @@ -7,6 +8,40 @@ import { getUserThoughts, Me, } from "@/lib/api"; + +interface ProfilePageProps { + params: Promise<{ username: string }>; +} + +export async function generateMetadata({ + params, +}: ProfilePageProps): Promise { + const { username } = await params; + const user = await getUserProfile(username, null).catch(() => null); + if (!user) return { title: username }; + + const name = user.displayName || user.username; + const description = + user.bio || + `Follow ${name} on Thoughts and across the Fediverse.`; + + return { + title: `${name} (@${user.username})`, + description, + openGraph: { + type: "profile", + title: `${name} (@${user.username})`, + description, + images: user.avatarUrl ? [{ url: user.avatarUrl }] : [], + }, + twitter: { + card: "summary", + title: `${name} (@${user.username})`, + description, + images: user.avatarUrl ? [user.avatarUrl] : [], + }, + }; +} import { UserAvatar } from "@/components/user-avatar"; import { Calendar, Settings } from "lucide-react"; import { Card } from "@/components/ui/card";