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
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:
@@ -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,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
10
thoughts-frontend/app/(auth)/login/layout.tsx
Normal file
10
thoughts-frontend/app/(auth)/login/layout.tsx
Normal 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;
|
||||||
|
}
|
||||||
10
thoughts-frontend/app/(auth)/register/layout.tsx
Normal file
10
thoughts-frontend/app/(auth)/register/layout.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
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",
|
title: "Thoughts",
|
||||||
description: "A social network for sharing 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({
|
||||||
|
|||||||
@@ -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,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user