57c29f62b4
- user_blocks table (composite PK), Block/Unblock button on profiles. Blocking severs any existing follow relationship both ways and prevents the blocked party from following, commenting on the blocker's recipes, or the blocker from seeing their comments. - reports table (recipe/comment/user targets, pending/reviewed/ dismissed status). ReportButton on comments, admin review queue at /admin/reports with dismiss/mark-reviewed actions, audit-logged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
import { notFound } from "next/navigation";
|
|
import { headers } from "next/headers";
|
|
import Link from "next/link";
|
|
import { auth } from "@/lib/auth/server";
|
|
import {
|
|
db,
|
|
users,
|
|
recipes,
|
|
userFollows,
|
|
userBlocks,
|
|
eq,
|
|
and,
|
|
desc,
|
|
count,
|
|
} from "@epicure/db";
|
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { FollowButton } from "@/components/social/follow-button";
|
|
import { BlockButton } from "@/components/social/block-button";
|
|
import { getPublicUrl } from "@/lib/storage";
|
|
|
|
type Params = { params: Promise<{ username: string }> };
|
|
|
|
export async function generateMetadata({ params }: Params) {
|
|
const { username } = await params;
|
|
const user = await db.query.users.findFirst({ where: eq(users.username, username) });
|
|
return { title: user ? `${user.name} (@${user.username})` : "Profile" };
|
|
}
|
|
|
|
export default async function UserProfilePage({ params }: Params) {
|
|
const { username } = await params;
|
|
|
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
|
|
const user = await db.query.users.findFirst({ where: eq(users.username, username) });
|
|
if (!user) notFound();
|
|
|
|
const [followerCountRow, followingCountRow, recipeCountRow, publicRecipes] = await Promise.all([
|
|
db
|
|
.select({ count: count() })
|
|
.from(userFollows)
|
|
.where(eq(userFollows.followingId, user.id)),
|
|
db
|
|
.select({ count: count() })
|
|
.from(userFollows)
|
|
.where(eq(userFollows.followerId, user.id)),
|
|
db
|
|
.select({ count: count() })
|
|
.from(recipes)
|
|
.where(and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public"))),
|
|
db.query.recipes.findMany({
|
|
where: and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public")),
|
|
orderBy: desc(recipes.createdAt),
|
|
limit: 24,
|
|
with: {
|
|
photos: { orderBy: (t, { asc }) => asc(t.order), limit: 1 },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const followerCount = followerCountRow[0]?.count ?? 0;
|
|
const followingCount = followingCountRow[0]?.count ?? 0;
|
|
const recipeCount = recipeCountRow[0]?.count ?? 0;
|
|
|
|
let isFollowing = false;
|
|
let isBlocked = false;
|
|
const isOwnProfile = session?.user.id === user.id;
|
|
if (session && !isOwnProfile) {
|
|
const [followRow, blockRow] = await Promise.all([
|
|
db.query.userFollows.findFirst({
|
|
where: and(
|
|
eq(userFollows.followerId, session.user.id),
|
|
eq(userFollows.followingId, user.id),
|
|
),
|
|
}),
|
|
db.query.userBlocks.findFirst({
|
|
where: and(
|
|
eq(userBlocks.blockerId, session.user.id),
|
|
eq(userBlocks.blockedId, user.id),
|
|
),
|
|
}),
|
|
]);
|
|
isFollowing = !!followRow;
|
|
isBlocked = !!blockRow;
|
|
}
|
|
|
|
const initials = user.name
|
|
.split(" ")
|
|
.slice(0, 2)
|
|
.map((w) => w[0])
|
|
.join("")
|
|
.toUpperCase();
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-10">
|
|
{/* Profile header */}
|
|
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center">
|
|
<Avatar className="h-24 w-24 shrink-0">
|
|
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt={user.name} />}
|
|
<AvatarFallback className="text-2xl">{initials}</AvatarFallback>
|
|
</Avatar>
|
|
|
|
<div className="flex-1 space-y-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div>
|
|
<h1 className="text-2xl font-bold leading-tight">{user.name}</h1>
|
|
<p className="text-muted-foreground text-sm">@{user.username}</p>
|
|
</div>
|
|
{!isOwnProfile && session && (
|
|
<div className="flex items-center gap-2">
|
|
<FollowButton
|
|
targetUserId={user.id}
|
|
targetUsername={user.username!}
|
|
initialFollowing={isFollowing}
|
|
/>
|
|
<BlockButton targetUsername={user.username!} initialBlocked={isBlocked} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{user.bio && (
|
|
<p className="text-sm leading-relaxed max-w-prose">{user.bio}</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="secondary">
|
|
<span className="font-semibold mr-1">{recipeCount}</span> recipes
|
|
</Badge>
|
|
<Badge variant="secondary">
|
|
<span className="font-semibold mr-1">{followerCount}</span> followers
|
|
</Badge>
|
|
<Badge variant="secondary">
|
|
<span className="font-semibold mr-1">{followingCount}</span> following
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recipe grid */}
|
|
{publicRecipes.length > 0 ? (
|
|
<div className="space-y-4">
|
|
<h2 className="text-xl font-semibold">Recipes</h2>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
|
{publicRecipes.map((recipe) => {
|
|
const cover = recipe.photos[0];
|
|
return (
|
|
<Link
|
|
key={recipe.id}
|
|
href={`/r/${recipe.id}`}
|
|
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
|
|
>
|
|
<div className="aspect-square bg-muted overflow-hidden">
|
|
{cover ? (
|
|
<img
|
|
src={getPublicUrl(cover.storageKey)}
|
|
alt={recipe.title}
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-3xl">
|
|
🍽️
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="p-2">
|
|
<p className="text-sm font-medium leading-tight line-clamp-2">{recipe.title}</p>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-16 text-muted-foreground">
|
|
<p className="text-lg">No public recipes yet.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|