Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 57 additions & 17 deletions app/(app)/[username]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
import { serverApi } from "@/server/trpc/caller";
import { JsonLd } from "@/components/JsonLd";
import { getArticleSchema, getBreadcrumbSchema } from "@/lib/structured-data";
import { ogPostImage } from "@/lib/og/url";

// Bare host for a link's "via {source}" chip, e.g. "anthropic.com".
const hostFromUrl = (value?: string | null): string | undefined => {
if (!value) return undefined;
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
};

type Props = { params: Promise<{ username: string; slug: string }> };

Expand Down Expand Up @@ -68,6 +79,7 @@ async function getUserPostUncached(
authorImage: user.image,
authorUsername: user.username,
authorBio: user.bio,
authorJobTitle: user.jobTitle,
})
.from(posts)
.leftJoin(user, eq(posts.authorId, user.id))
Expand Down Expand Up @@ -111,6 +123,7 @@ async function getUserPostUncached(
image: postRecord.authorImage,
username: postRecord.authorUsername,
bio: postRecord.authorBio,
jobTitle: postRecord.authorJobTitle,
},
};
}
Expand Down Expand Up @@ -145,6 +158,7 @@ async function getUserLinkPostUncached(username: string, postSlug: string) {
authorImage: user.image,
authorUsername: user.username,
authorBio: user.bio,
authorJobTitle: user.jobTitle,
})
.from(posts)
.leftJoin(user, eq(posts.authorId, user.id))
Expand Down Expand Up @@ -182,6 +196,7 @@ async function getUserLinkPostUncached(username: string, postSlug: string) {
image: linkPost.authorImage,
username: linkPost.authorUsername,
bio: linkPost.authorBio,
jobTitle: linkPost.authorJobTitle,
},
};
}
Expand Down Expand Up @@ -369,6 +384,16 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
}
const tags = userPost.tags.map((tag) => tag.tag.title);
const authorName = userPost.user.name || "Unknown";
const postOgImage = ogPostImage({
kind: "article",
title: userPost.title,
authorName,
authorRole: userPost.user.jobTitle,
authorKey: userPost.user.username ?? authorName,
tags,
readMins: userPost.readTimeMins,
updatedAt: userPost.updatedAt,
});

return {
title: `${userPost.title} | by ${authorName} | Codú`,
Expand All @@ -386,18 +411,13 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
url: `/${userPost.user.username ?? username}/${userPost.slug}`,
publishedTime: userPost.published ?? undefined,
modifiedTime: userPost.updatedAt ?? undefined,
images: [
`/og?title=${encodeURIComponent(
userPost.title,
)}&readTime=${userPost.readTimeMins}&author=${encodeURIComponent(
authorName,
)}&date=${userPost.updatedAt}`,
],
images: [postOgImage],
siteName: "Codú",
},
twitter: {
card: "summary_large_image",
description: userPost.excerpt ?? undefined,
images: [`/og?title=${encodeURIComponent(userPost.title)}`],
images: [postOgImage],
},
alternates: {
// Cross-posted content points at the original; native posts
Expand All @@ -413,6 +433,15 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
if (userArticle && userArticle.user) {
const tags = userArticle.tags?.map((t) => t.tag.title) || [];
const articleAuthorName = userArticle.user.name || "Unknown";
const articleOgImage = ogPostImage({
kind: "article",
title: userArticle.title,
authorName: articleAuthorName,
authorKey: userArticle.user.username ?? articleAuthorName,
tags,
readMins: userArticle.readTimeMins || 5,
updatedAt: userArticle.updatedAt,
});

return {
title: `${userArticle.title} | by ${articleAuthorName} | Codú`,
Expand All @@ -428,18 +457,13 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
url: `/${userArticle.user.username ?? username}/${userArticle.slug}`,
publishedTime: userArticle.published ?? undefined,
modifiedTime: userArticle.updatedAt ?? undefined,
images: [
`/og?title=${encodeURIComponent(
userArticle.title,
)}&readTime=${userArticle.readTimeMins || 5}&author=${encodeURIComponent(
userArticle.user.name || "",
)}&date=${userArticle.updatedAt}`,
],
images: [articleOgImage],
siteName: "Codú",
},
twitter: {
card: "summary_large_image",
description: userArticle.excerpt || "",
images: [`/og?title=${encodeURIComponent(userArticle.title)}`],
images: [articleOgImage],
},
alternates: {
canonical:
Expand All @@ -452,6 +476,17 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
const userLinkPost = await getUserLinkPost(username, slug);
if (userLinkPost && userLinkPost.user) {
const linkAuthorName = userLinkPost.user.name || "Unknown";
const linkOgImage = ogPostImage({
kind: "link",
title: userLinkPost.title,
authorName: linkAuthorName,
authorRole: userLinkPost.user.jobTitle,
authorKey: userLinkPost.user.username ?? linkAuthorName,
tags: userLinkPost.tags.map((t) => t.tag.title),
source: hostFromUrl(userLinkPost.externalUrl),
cover: userLinkPost.coverImage,
updatedAt: userLinkPost.updatedAt,
});

return {
title: `${userLinkPost.title} | shared by ${linkAuthorName} | Codú`,
Expand All @@ -463,9 +498,14 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
openGraph: {
title: userLinkPost.title,
description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`,
images: userLinkPost.coverImage ? [userLinkPost.coverImage] : undefined,
images: [linkOgImage],
siteName: "Codú",
},
twitter: {
card: "summary_large_image",
description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`,
images: [linkOgImage],
},
// Member-shared links keep Codú as canonical (aggregated links point to source).
alternates: {
canonical: `/${userLinkPost.user.username ?? username}/${userLinkPost.slug}`,
Expand Down
32 changes: 28 additions & 4 deletions app/(app)/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import Content from "./_usernameClient";
import { getServerAuthSession } from "@/server/auth";
import { type Metadata } from "next";
import { db } from "@/server/db";
import { feed_sources } from "@/server/db/schema";
import { eq, sql } from "drizzle-orm";
import { feed_sources, follow } from "@/server/db/schema";
import { count, eq, sql } from "drizzle-orm";
import { JsonLd } from "@/components/JsonLd";
import { getProfilePageSchema } from "@/lib/structured-data";
import { ogProfileImage } from "@/lib/og/url";

type Props = { params: Promise<{ username: string }> };

Expand All @@ -18,9 +19,15 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
// Case-insensitive handle resolution (GitHub-style) on lower(username).
const profile = await db.query.user.findFirst({
columns: {
id: true,
bio: true,
name: true,
username: true,
location: true,
jobTitle: true,
topics: true,
createdAt: true,
updatedAt: true,
},
where: (users) => sql`lower(${users.username}) = ${username.toLowerCase()}`,
});
Expand All @@ -32,6 +39,23 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
const title = `${name || handle} (@${handle}) | Codú`;
const description = `${name || handle}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`;

const [followerRow] = await db
.select({ value: count() })
.from(follow)
.where(eq(follow.followingId, profile.id));
const joined = `Joined ${new Date(profile.createdAt).toLocaleString("en-US", { month: "short", year: "numeric" })}`;
const ogImage = ogProfileImage({
name: name || handle,
key: handle,
role: profile.jobTitle,
location: profile.location,
bio,
followers: followerRow?.value ?? 0,
joined,
interests: profile.topics,
updatedAt: profile.updatedAt,
});

return {
title,
description,
Expand All @@ -44,7 +68,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
type: "profile",
images: [
{
url: "/images/og/home-og.png",
url: ogImage,
width: 1200,
height: 630,
alt: `${name || username}'s profile on Codú`,
Expand All @@ -56,7 +80,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
card: "summary_large_image",
title,
description,
images: ["/images/og/home-og.png"],
images: [ogImage],
},
};
}
Expand Down
18 changes: 12 additions & 6 deletions app/(app)/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { headers } from "next/headers";
import { notFound, permanentRedirect } from "next/navigation";
import { type Metadata } from "next";
import { SITE_ORIGIN } from "@/config/site";
import { ogPostImage } from "@/lib/og/url";
import { getServerAuthSession } from "@/server/auth";
import { db } from "@/server/db";
import { posts, user, post_tags, tag, comments } from "@/server/db/schema";
Expand Down Expand Up @@ -171,6 +172,14 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
}

const authorName = post.user.name || "Unknown";
const ogImage = ogPostImage({
kind: "discussion",
title: post.title,
authorName,
authorKey: post.user.username ?? authorName,
tags: post.tags.map((t) => t.tag.title),
updatedAt: post.updatedAt,
});

return {
title: `${post.title} — Discussion | Codú`,
Expand All @@ -187,15 +196,12 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
url: canonical,
publishedTime: post.published ?? undefined,
modifiedTime: post.updatedAt ?? undefined,
images: [
`/og?title=${encodeURIComponent(
post.title,
)}&author=${encodeURIComponent(authorName)}&date=${post.updatedAt}`,
],
images: [ogImage],
},
twitter: {
card: "summary_large_image",
description: post.excerpt ?? undefined,
images: [`/og?title=${encodeURIComponent(post.title)}`],
images: [ogImage],
},
};
}
Expand Down
18 changes: 14 additions & 4 deletions app/(app)/discussions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import Content from "./_client";
import { getServerAuthSession } from "@/server/auth";
import { serverApi } from "@/server/trpc/caller";
import { ogMainImage } from "@/lib/og/url";

const ogDescription =
"Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI.";
const ogImage = ogMainImage("discussions");

export const metadata = {
title: "Discussions — Codú",
description:
"Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI.",
description: ogDescription,
// Canonical to the bare path so ?sort/?filter param variants don't get indexed.
alternates: { canonical: "/discussions" },
openGraph: {
title: "Discussions — Codú",
description:
"Ask questions, swap patterns, and get unstuck. The place to learn out loud with other builders working with AI.",
description: ogDescription,
type: "website",
siteName: "Codú",
images: [ogImage],
},
twitter: {
card: "summary_large_image",
title: "Discussions — Codú",
description: ogDescription,
images: [ogImage],
},
};

Expand Down
68 changes: 62 additions & 6 deletions app/(app)/jobs/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,66 @@
import { type Metadata } from "next";
import { and, eq } from "drizzle-orm";
import { db } from "@/server/db";
import { job } from "@/server/db/schema";
import { ogJobImage } from "@/lib/og/url";
import JobDetailClient from "./_client";

export default async function JobDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
type Props = { params: Promise<{ slug: string }> };

const JOB_TYPE_LABEL: Record<string, string> = {
"full-time": "Full-time",
"part-time": "Part-time",
freelancer: "Freelance",
other: "Contract",
};

export async function generateMetadata(props: Props): Promise<Metadata> {
const { slug } = await props.params;

const [listing] = await db
.select()
.from(job)
.where(and(eq(job.slug, slug), eq(job.status, "active")))
.limit(1);

if (!listing) {
return { title: "Job Not Found" };
}

const title = `${listing.jobTitle} at ${listing.companyName} | Codú`;
const description =
`${listing.jobTitle} — ${listing.companyName}. ${listing.jobLocation}.`.trim();
const ogImage = ogJobImage({
company: listing.companyName,
role: listing.jobTitle,
location: listing.jobLocation,
jobType: JOB_TYPE_LABEL[listing.type] ?? "Full-time",
tags: listing.tags,
featured: listing.featured,
updatedAt: listing.updatedAt,
});

return {
title,
description,
alternates: { canonical: `/jobs/${listing.slug}` },
openGraph: {
title,
description,
type: "website",
siteName: "Codú",
images: [ogImage],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [ogImage],
},
};
}

export default async function JobDetailPage(props: Props) {
const { slug } = await props.params;
return <JobDetailClient slug={slug} />;
}
Loading
Loading