feat(frontend): rich OG metadata + dynamic page titles across all routes
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Has been cancelled
test / unit (pull_request) Has been cancelled
test / integration (pull_request) Has been cancelled

This commit is contained in:
2026-05-15 01:38:59 +02:00
parent 71a0f55c93
commit a123c0b8cc
11 changed files with 206 additions and 2 deletions

View File

@@ -1,4 +1,10 @@
// app/(auth)/layout.tsx // app/(auth)/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
openGraph: { type: "website" },
};
export default function AuthLayout({ export default function AuthLayout({
children, children,
}: { }: {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -7,8 +7,25 @@ import localFont from "next/font/local";
import InstallPrompt from "@/components/install-prompt"; import InstallPrompt from "@/components/install-prompt";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Thoughts", title: {
description: "A social network for sharing thoughts", 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({ const frutiger = localFont({

View File

@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { import {
getFeed, getFeed,
@@ -26,6 +27,11 @@ import {
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import { redirect } from "next/navigation"; 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({ export default async function Home({
searchParams, searchParams,
}: { }: {

View File

@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getMe, lookupRemoteActor, getRemoteActorPosts, Me } from "@/lib/api"; import { getMe, lookupRemoteActor, getRemoteActorPosts, Me } from "@/lib/api";
@@ -7,6 +8,43 @@ interface RemoteActorPageProps {
searchParams: Promise<{ handle?: string }>; searchParams: Promise<{ handle?: string }>;
} }
function stripHtml(html: string) {
return html.replace(/<[^>]*>/g, "").trim();
}
export async function generateMetadata({
searchParams,
}: RemoteActorPageProps): Promise<Metadata> {
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({ export default async function RemoteActorPage({
searchParams, searchParams,
}: RemoteActorPageProps) { }: RemoteActorPageProps) {

View File

@@ -1,5 +1,21 @@
import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getMe, search, lookupRemoteActor, User } from "@/lib/api"; import { getMe, search, lookupRemoteActor, User } from "@/lib/api";
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}): Promise<Metadata> {
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 { UserListCard } from "@/components/user-list-card";
import { RemoteUserCard } from "@/components/remote-user-card"; import { RemoteUserCard } from "@/components/remote-user-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

View File

@@ -1,5 +1,11 @@
// app/settings/profile/page.tsx // app/settings/profile/page.tsx
import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
export const metadata: Metadata = {
title: "Edit profile",
description: "Update your Thoughts profile",
};
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getMe } from "@/lib/api"; import { getMe } from "@/lib/api";
import { EditProfileForm } from "@/components/edit-profile-form"; import { EditProfileForm } from "@/components/edit-profile-form";

View File

@@ -1,6 +1,28 @@
// app/tags/[tagName]/page.tsx // app/tags/[tagName]/page.tsx
import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api"; import { getThoughtsByTag, getUserProfile, getMe, Me, User } from "@/lib/api";
export async function generateMetadata({
params,
}: {
params: Promise<{ tagName: string }>;
}): Promise<Metadata> {
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 { buildThoughtThreads } from "@/lib/utils";
import { ThoughtThread } from "@/components/thought-thread"; import { ThoughtThread } from "@/components/thought-thread";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { import {
getThoughtById,
getThoughtThread, getThoughtThread,
getUserProfile, getUserProfile,
getMe, getMe,
@@ -14,6 +16,42 @@ interface ThoughtPageProps {
params: Promise<{ thoughtId: string }>; params: Promise<{ thoughtId: string }>;
} }
function stripHtml(html: string) {
return html.replace(/<[^>]*>/g, "").trim();
}
export async function generateMetadata({
params,
}: ThoughtPageProps): Promise<Metadata> {
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[] { function collectAuthors(thread: ThoughtThreadType): string[] {
const authors = new Set<string>([thread.author.username]); const authors = new Set<string>([thread.author.username]);
for (const reply of thread.replies) { for (const reply of thread.replies) {

View File

@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { import {
getFollowersList, getFollowersList,
getFollowingList, getFollowingList,
@@ -7,6 +8,40 @@ import {
getUserThoughts, getUserThoughts,
Me, Me,
} from "@/lib/api"; } from "@/lib/api";
interface ProfilePageProps {
params: Promise<{ username: string }>;
}
export async function generateMetadata({
params,
}: ProfilePageProps): Promise<Metadata> {
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 { UserAvatar } from "@/components/user-avatar";
import { Calendar, Settings } from "lucide-react"; import { Calendar, Settings } from "lucide-react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";