diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx
new file mode 100644
index 0000000..ee0fea0
--- /dev/null
+++ b/apps/web/app/(app)/collections/[id]/page.tsx
@@ -0,0 +1,62 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, collections, eq, and, or } from "@epicure/db";
+import { RecipeCard } from "@/components/recipe/recipe-card";
+import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
+import { ShareCollectionButton } from "@/components/collections/share-collection-button";
+
+type Params = { params: Promise<{ id: string }> };
+
+export const metadata: Metadata = { title: "Collection" };
+
+export default async function CollectionPage({ params }: Params) {
+ const { id } = await params;
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+
+ const col = await db.query.collections.findFirst({
+ where: and(
+ eq(collections.id, id),
+ or(eq(collections.userId, session.user.id), eq(collections.isPublic, true))
+ ),
+ with: { recipes: { with: { recipe: { with: { photos: true } } } } },
+ });
+
+ if (!col) notFound();
+
+ const isOwner = col.userId === session.user.id;
+
+ return (
+
+
+
+
{col.name}
+ {col.description &&
{col.description}
}
+
+ {col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} ยท {col.isPublic ? "Public" : "Private"}
+
+
+
+ {isOwner && }
+ {!isOwner && col.isPublic && (
+
+ )}
+
+
+
+ {col.recipes.length === 0 ? (
+
+
No recipes in this collection yet.
+
+ ) : (
+
+ {col.recipes.map(({ recipe }) => (
+ recipe &&
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/app/(app)/collections/page.tsx b/apps/web/app/(app)/collections/page.tsx
new file mode 100644
index 0000000..0e1470c
--- /dev/null
+++ b/apps/web/app/(app)/collections/page.tsx
@@ -0,0 +1,30 @@
+import type { Metadata } from "next";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, collections, eq, desc } from "@epicure/db";
+import { CollectionsPageContent } from "@/components/collections/collections-page-content";
+
+export const metadata: Metadata = { title: "Collections" };
+
+export default async function CollectionsPage() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+
+ const userCollections = await db.query.collections.findMany({
+ where: eq(collections.userId, session.user.id),
+ orderBy: desc(collections.updatedAt),
+ with: { recipes: { limit: 1 } },
+ });
+
+ return (
+ ({
+ id: col.id,
+ name: col.name,
+ description: col.description,
+ isPublic: col.isPublic,
+ recipeCount: col.recipes.length,
+ }))}
+ />
+ );
+}
diff --git a/apps/web/app/(app)/feed/page.tsx b/apps/web/app/(app)/feed/page.tsx
new file mode 100644
index 0000000..580919d
--- /dev/null
+++ b/apps/web/app/(app)/feed/page.tsx
@@ -0,0 +1,54 @@
+import type { Metadata } from "next";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, recipes, users, userFollows, eq, desc, inArray } from "@epicure/db";
+import { getPublicUrl } from "@/lib/storage";
+import { FeedPageContent } from "@/components/feed/feed-page-content";
+
+export const metadata: Metadata = { title: "Feed" };
+
+export default async function FeedPage() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+
+ const followedRows = await db
+ .select({ followingId: userFollows.followingId })
+ .from(userFollows)
+ .where(eq(userFollows.followerId, session.user.id));
+
+ const followedIds = followedRows.map((r) => r.followingId);
+
+ if (followedIds.length === 0) {
+ return ;
+ }
+
+ const feedRecipes = await db
+ .select({
+ id: recipes.id,
+ title: recipes.title,
+ description: recipes.description,
+ baseServings: recipes.baseServings,
+ prepMins: recipes.prepMins,
+ cookMins: recipes.cookMins,
+ difficulty: recipes.difficulty,
+ visibility: recipes.visibility,
+ aiGenerated: recipes.aiGenerated,
+ createdAt: recipes.createdAt,
+ authorId: recipes.authorId,
+ authorName: users.name,
+ authorUsername: users.username,
+ authorAvatarUrl: users.avatarUrl,
+ })
+ .from(recipes)
+ .innerJoin(users, eq(recipes.authorId, users.id))
+ .where(inArray(recipes.authorId, followedIds))
+ .orderBy(desc(recipes.createdAt))
+ .limit(40);
+
+ return (
+ ({ ...r, createdAt: r.createdAt.toISOString() }))}
+ />
+ );
+}
diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx
new file mode 100644
index 0000000..ab69b39
--- /dev/null
+++ b/apps/web/app/(app)/u/[username]/page.tsx
@@ -0,0 +1,165 @@
+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,
+ 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 { 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;
+ const isOwnProfile = session?.user.id === user.id;
+ if (session && !isOwnProfile) {
+ const followRow = await db.query.userFollows.findFirst({
+ where: and(
+ eq(userFollows.followerId, session.user.id),
+ eq(userFollows.followingId, user.id),
+ ),
+ });
+ isFollowing = !!followRow;
+ }
+
+ const initials = user.name
+ .split(" ")
+ .slice(0, 2)
+ .map((w) => w[0])
+ .join("")
+ .toUpperCase();
+
+ return (
+
+ {/* Profile header */}
+
+
+ {user.avatarUrl && }
+ {initials}
+
+
+
+
+
+
{user.name}
+
@{user.username}
+
+ {!isOwnProfile && session && (
+
+ )}
+
+
+ {user.bio && (
+
{user.bio}
+ )}
+
+
+
+ {recipeCount} recipes
+
+
+ {followerCount} followers
+
+
+ {followingCount} following
+
+
+
+
+
+ {/* Recipe grid */}
+ {publicRecipes.length > 0 ? (
+
+
Recipes
+
+ {publicRecipes.map((recipe) => {
+ const cover = recipe.photos[0];
+ return (
+
+
+ {cover ? (
+
})
+ ) : (
+
+ ๐ฝ๏ธ
+
+ )}
+
+
+
+ );
+ })}
+
+
+ ) : (
+
+
No public recipes yet.
+
+ )}
+
+ );
+}
diff --git a/apps/web/app/api/v1/collections/[id]/fork/route.ts b/apps/web/app/api/v1/collections/[id]/fork/route.ts
new file mode 100644
index 0000000..54a4b66
--- /dev/null
+++ b/apps/web/app/api/v1/collections/[id]/fork/route.ts
@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db, collections, collectionRecipes, eq, and, or } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ id: string }> };
+
+export async function POST(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ // Allow forking public collections or own collections
+ const source = await db.query.collections.findFirst({
+ where: and(
+ eq(collections.id, id),
+ or(eq(collections.isPublic, true), eq(collections.userId, session!.user.id))
+ ),
+ with: { recipes: { columns: { recipeId: true } } },
+ });
+
+ if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const newId = crypto.randomUUID();
+ await db.insert(collections).values({
+ id: newId,
+ userId: session!.user.id,
+ name: `${source.name} (fork)`,
+ description: source.description,
+ isPublic: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ if (source.recipes.length > 0) {
+ await db.insert(collectionRecipes).values(
+ source.recipes.map((r) => ({ collectionId: newId, recipeId: r.recipeId }))
+ );
+ }
+
+ return NextResponse.json({ id: newId }, { status: 201 });
+}
diff --git a/apps/web/app/api/v1/collections/[id]/members/route.ts b/apps/web/app/api/v1/collections/[id]/members/route.ts
new file mode 100644
index 0000000..b90d9b9
--- /dev/null
+++ b/apps/web/app/api/v1/collections/[id]/members/route.ts
@@ -0,0 +1,134 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, collections, collectionMembers, users, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ id: string }> };
+
+// โโโ GET /api/v1/collections/[id]/members โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// Owner only โ returns members joined with basic user info.
+export async function GET(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const { id } = await params;
+
+ // Verify ownership
+ const col = await db.query.collections.findFirst({
+ where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
+ });
+ if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const members = await db.query.collectionMembers.findMany({
+ where: eq(collectionMembers.collectionId, id),
+ with: { user: true },
+ });
+
+ const result = members.map((m) => ({
+ id: m.id,
+ userId: m.userId,
+ role: m.role,
+ createdAt: m.createdAt,
+ user: {
+ name: m.user.name,
+ username: m.user.username,
+ avatarUrl: m.user.avatarUrl,
+ },
+ }));
+
+ return NextResponse.json(result);
+}
+
+// โโโ POST /api/v1/collections/[id]/members โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// Owner only โ invite by email or userId.
+const InviteSchema = z
+ .object({
+ email: z.string().email().optional(),
+ userId: z.string().optional(),
+ role: z.enum(["viewer", "editor"]),
+ })
+ .refine((d) => d.email !== undefined || d.userId !== undefined, {
+ message: "Provide either email or userId",
+ });
+
+export async function POST(req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const { id } = await params;
+
+ // Verify ownership
+ const col = await db.query.collections.findFirst({
+ where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
+ });
+ if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = await req.json() as unknown;
+ const parsed = InviteSchema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const { email, userId, role } = parsed.data;
+
+ // Resolve target user
+ const targetUser = await db.query.users.findFirst({
+ where: email ? eq(users.email, email) : eq(users.id, userId!),
+ });
+ if (!targetUser) return NextResponse.json({ error: "User not found" }, { status: 404 });
+
+ // Prevent owner adding themselves
+ if (targetUser.id === session!.user.id) {
+ return NextResponse.json({ error: "Cannot invite yourself" }, { status: 400 });
+ }
+
+ // Check not already a member
+ const existing = await db.query.collectionMembers.findFirst({
+ where: and(
+ eq(collectionMembers.collectionId, id),
+ eq(collectionMembers.userId, targetUser.id),
+ ),
+ });
+ if (existing) return NextResponse.json({ error: "Already a member" }, { status: 409 });
+
+ const memberId = crypto.randomUUID();
+ await db.insert(collectionMembers).values({
+ id: memberId,
+ collectionId: id,
+ userId: targetUser.id,
+ role,
+ });
+
+ return NextResponse.json({ id: memberId }, { status: 201 });
+}
+
+// โโโ DELETE /api/v1/collections/[id]/members?memberId=โฆ โโโโโโโโโโโโโโโโโโโโโโ
+// Owner OR the member themselves can remove.
+export async function DELETE(req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const { id } = await params;
+ const memberId = req.nextUrl.searchParams.get("memberId");
+ if (!memberId) return NextResponse.json({ error: "memberId required" }, { status: 400 });
+
+ // Load member record
+ const member = await db.query.collectionMembers.findFirst({
+ where: and(eq(collectionMembers.id, memberId), eq(collectionMembers.collectionId, id)),
+ });
+ if (!member) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ // Load collection to check owner
+ const col = await db.query.collections.findFirst({
+ where: eq(collections.id, id),
+ });
+
+ const isOwner = col?.userId === session!.user.id;
+ const isSelf = member.userId === session!.user.id;
+
+ if (!isOwner && !isSelf) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ await db.delete(collectionMembers).where(eq(collectionMembers.id, memberId));
+
+ return new NextResponse(null, { status: 204 });
+}
diff --git a/apps/web/app/api/v1/collections/[id]/route.ts b/apps/web/app/api/v1/collections/[id]/route.ts
new file mode 100644
index 0000000..4ad63f4
--- /dev/null
+++ b/apps/web/app/api/v1/collections/[id]/route.ts
@@ -0,0 +1,80 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, collections, collectionRecipes, recipes, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ id: string }> };
+
+export async function GET(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ const col = await db.query.collections.findFirst({
+ where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
+ with: { recipes: { with: { recipe: { with: { photos: true } } } } },
+ });
+
+ if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ return NextResponse.json(col);
+}
+
+export async function PUT(req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ const existing = await db.query.collections.findFirst({
+ where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
+ });
+ if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = await req.json() as unknown;
+ const parsed = z.object({
+ name: z.string().min(1).max(100).optional(),
+ description: z.string().max(500).optional(),
+ isPublic: z.boolean().optional(),
+ addRecipeId: z.string().optional(),
+ removeRecipeId: z.string().optional(),
+ }).safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const data = parsed.data;
+ if (data.name || data.description !== undefined || data.isPublic !== undefined) {
+ await db.update(collections).set({
+ ...(data.name && { name: data.name }),
+ ...(data.description !== undefined && { description: data.description }),
+ ...(data.isPublic !== undefined && { isPublic: data.isPublic }),
+ updatedAt: new Date(),
+ }).where(eq(collections.id, id));
+ }
+
+ if (data.addRecipeId) {
+ const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, data.addRecipeId) });
+ if (recipe) {
+ await db.insert(collectionRecipes).values({ collectionId: id, recipeId: data.addRecipeId }).onConflictDoNothing();
+ }
+ }
+
+ if (data.removeRecipeId) {
+ await db.delete(collectionRecipes).where(
+ and(eq(collectionRecipes.collectionId, id), eq(collectionRecipes.recipeId, data.removeRecipeId))
+ );
+ }
+
+ return NextResponse.json({ updated: true });
+}
+
+export async function DELETE(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ const existing = await db.query.collections.findFirst({
+ where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
+ });
+ if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ await db.delete(collections).where(eq(collections.id, id));
+ return new NextResponse(null, { status: 204 });
+}
diff --git a/apps/web/app/api/v1/collections/route.ts b/apps/web/app/api/v1/collections/route.ts
new file mode 100644
index 0000000..52e58ee
--- /dev/null
+++ b/apps/web/app/api/v1/collections/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, collections, eq, desc } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+const Schema = z.object({
+ name: z.string().min(1).max(100),
+ description: z.string().max(500).optional(),
+ isPublic: z.boolean().default(false),
+});
+
+export async function GET(_req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const rows = await db.query.collections.findMany({
+ where: eq(collections.userId, session!.user.id),
+ orderBy: desc(collections.updatedAt),
+ with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
+ });
+
+ return NextResponse.json(rows);
+}
+
+export async function POST(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const body = await req.json() as unknown;
+ const parsed = Schema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const id = crypto.randomUUID();
+ await db.insert(collections).values({
+ id,
+ userId: session!.user.id,
+ name: parsed.data.name,
+ description: parsed.data.description,
+ isPublic: parsed.data.isPublic,
+ });
+
+ return NextResponse.json({ id }, { status: 201 });
+}
diff --git a/apps/web/app/api/v1/users/[username]/follow/route.ts b/apps/web/app/api/v1/users/[username]/follow/route.ts
new file mode 100644
index 0000000..4139eba
--- /dev/null
+++ b/apps/web/app/api/v1/users/[username]/follow/route.ts
@@ -0,0 +1,36 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db, users, userFollows, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ username: string }> };
+
+export async function POST(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { username } = await params;
+
+ const target = await db.query.users.findFirst({ where: eq(users.username, username) });
+ if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ if (target.id === session!.user.id) return NextResponse.json({ error: "Cannot follow yourself" }, { status: 400 });
+
+ await db.insert(userFollows)
+ .values({ followerId: session!.user.id, followingId: target.id })
+ .onConflictDoNothing();
+
+ return NextResponse.json({ following: true });
+}
+
+export async function DELETE(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { username } = await params;
+
+ const target = await db.query.users.findFirst({ where: eq(users.username, username) });
+ if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ await db.delete(userFollows).where(
+ and(eq(userFollows.followerId, session!.user.id), eq(userFollows.followingId, target.id))
+ );
+
+ return NextResponse.json({ following: false });
+}
diff --git a/apps/web/app/api/v1/users/[username]/route.ts b/apps/web/app/api/v1/users/[username]/route.ts
new file mode 100644
index 0000000..7d96c61
--- /dev/null
+++ b/apps/web/app/api/v1/users/[username]/route.ts
@@ -0,0 +1,57 @@
+import { NextRequest, NextResponse } from "next/server";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, users, recipes, userFollows, eq, and, count } from "@epicure/db";
+
+type Params = { params: Promise<{ username: string }> };
+
+export async function GET(req: NextRequest, { params }: Params) {
+ const { username } = await params;
+
+ const user = await db.query.users.findFirst({ where: eq(users.username, username) });
+ if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const [followerCountRow, followingCountRow, recipeCountRow] = 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"))),
+ ]);
+
+ let isFollowing = false;
+ try {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (session && session.user.id !== user.id) {
+ const followRow = await db.query.userFollows.findFirst({
+ where: and(
+ eq(userFollows.followerId, session.user.id),
+ eq(userFollows.followingId, user.id),
+ ),
+ });
+ isFollowing = !!followRow;
+ }
+ } catch {
+ // unauthenticated โ isFollowing stays false
+ }
+
+ return NextResponse.json({
+ id: user.id,
+ name: user.name,
+ username: user.username,
+ avatarUrl: user.avatarUrl,
+ bio: user.bio,
+ createdAt: user.createdAt,
+ followerCount: followerCountRow[0]?.count ?? 0,
+ followingCount: followingCountRow[0]?.count ?? 0,
+ recipeCount: recipeCountRow[0]?.count ?? 0,
+ isFollowing,
+ });
+}
diff --git a/apps/web/app/api/v1/users/me/model-prefs/route.ts b/apps/web/app/api/v1/users/me/model-prefs/route.ts
new file mode 100644
index 0000000..1a4e26e
--- /dev/null
+++ b/apps/web/app/api/v1/users/me/model-prefs/route.ts
@@ -0,0 +1,42 @@
+import { type NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { requireSession } from "@/lib/api-auth";
+import { db, userModelPrefs, eq } from "@epicure/db";
+
+const Schema = z.object({
+ textProvider: z.string().nullable().optional(),
+ textModel: z.string().nullable().optional(),
+ visionProvider: z.string().nullable().optional(),
+ visionModel: z.string().nullable().optional(),
+ mealPlanProvider: z.string().nullable().optional(),
+ mealPlanModel: z.string().nullable().optional(),
+});
+
+export async function GET() {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const prefs = await db.query.userModelPrefs.findFirst({
+ where: eq(userModelPrefs.userId, session!.user.id),
+ });
+
+ return NextResponse.json(prefs ?? null);
+}
+
+export async function PUT(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const body = Schema.safeParse(await req.json());
+ if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
+
+ await db
+ .insert(userModelPrefs)
+ .values({ id: crypto.randomUUID(), userId: session!.user.id, ...body.data, updatedAt: new Date() })
+ .onConflictDoUpdate({
+ target: userModelPrefs.userId,
+ set: { ...body.data, updatedAt: new Date() },
+ });
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/web/app/api/v1/users/me/nutrition-goals/route.ts b/apps/web/app/api/v1/users/me/nutrition-goals/route.ts
new file mode 100644
index 0000000..9636616
--- /dev/null
+++ b/apps/web/app/api/v1/users/me/nutrition-goals/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db, userNutritionGoals, eq } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+import { z } from "zod";
+
+const PutSchema = z.object({
+ caloriesKcal: z.number().int().min(0).optional(),
+ proteinG: z.number().int().min(0).optional(),
+ carbsG: z.number().int().min(0).optional(),
+ fatG: z.number().int().min(0).optional(),
+});
+
+export async function GET() {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const goals = await db.query.userNutritionGoals.findFirst({
+ where: eq(userNutritionGoals.userId, session!.user.id),
+ });
+
+ return NextResponse.json({ data: goals ?? null });
+}
+
+export async function PUT(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const parsed = PutSchema.safeParse(await req.json());
+ if (!parsed.success) {
+ return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
+ }
+
+ const body = parsed.data;
+ const userId = session!.user.id;
+
+ await db
+ .insert(userNutritionGoals)
+ .values({ id: crypto.randomUUID(), userId, ...body, updatedAt: new Date() })
+ .onConflictDoUpdate({
+ target: userNutritionGoals.userId,
+ set: { ...body, updatedAt: new Date() },
+ });
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts
new file mode 100644
index 0000000..9fff55c
--- /dev/null
+++ b/apps/web/app/api/v1/users/me/route.ts
@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, users, eq } from "@epicure/db";
+import { z } from "zod";
+
+const PatchSchema = z.object({
+ name: z.string().min(1).max(100).optional(),
+ locale: z.string().max(10).optional(),
+});
+
+export async function PATCH(req: Request) {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+ const body = PatchSchema.safeParse(await req.json());
+ if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
+
+ await db.update(users).set(body.data).where(eq(users.id, session.user.id));
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/web/components/collections/collections-page-content.tsx b/apps/web/components/collections/collections-page-content.tsx
new file mode 100644
index 0000000..b81398b
--- /dev/null
+++ b/apps/web/components/collections/collections-page-content.tsx
@@ -0,0 +1,56 @@
+"use client";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { FolderOpen } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { NewCollectionButton } from "@/components/social/new-collection-button";
+
+type Collection = {
+ id: string;
+ name: string;
+ description: string | null;
+ isPublic: boolean;
+ recipeCount: number;
+};
+
+type Props = {
+ collections: Collection[];
+};
+
+export function CollectionsPageContent({ collections }: Props) {
+ const t = useTranslations("collections");
+
+ return (
+
+
+
+
{t("title")}
+
{t("subtitle")}
+
+
+
+
+ {collections.length === 0 ? (
+
+ ) : (
+
+ {collections.map((col) => (
+
+
+
{col.name}
+ {col.isPublic && {t("public")}}
+
+ {col.description &&
{col.description}
}
+
+ {col.recipeCount !== 1 ? t("recipeCountPlural", { count: col.recipeCount }) : t("recipeCount", { count: col.recipeCount })}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/collections/fork-collection-button.tsx b/apps/web/components/collections/fork-collection-button.tsx
new file mode 100644
index 0000000..630a039
--- /dev/null
+++ b/apps/web/components/collections/fork-collection-button.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { GitFork } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
+
+export function ForkCollectionButton({ collectionId }: { collectionId: string }) {
+ const [forking, setForking] = useState(false);
+ const router = useRouter();
+
+ async function handleFork() {
+ setForking(true);
+ try {
+ const res = await fetch(`/api/v1/collections/${collectionId}/fork`, { method: "POST" });
+ if (!res.ok) {
+ const data = await res.json() as { error?: string };
+ throw new Error(data.error ?? "Fork failed");
+ }
+ const { id } = await res.json() as { id: string };
+ toast.success("Collection forked to your library");
+ router.push(`/collections/${id}`);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Fork failed");
+ setForking(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/collections/share-collection-button.tsx b/apps/web/components/collections/share-collection-button.tsx
new file mode 100644
index 0000000..73132b3
--- /dev/null
+++ b/apps/web/components/collections/share-collection-button.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import { useState } from "react";
+import { UserPlus, X } from "lucide-react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+
+type Role = "viewer" | "editor";
+
+interface Member {
+ id: string;
+ userId: string;
+ role: Role;
+ createdAt: string;
+ user: {
+ name: string;
+ username: string | null;
+ avatarUrl: string | null;
+ };
+}
+
+interface Props {
+ collectionId: string;
+}
+
+export function ShareCollectionButton({ collectionId }: Props) {
+ const [open, setOpen] = useState(false);
+ const [email, setEmail] = useState("");
+ const [role, setRole] = useState("viewer");
+ const [members, setMembers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [inviting, setInviting] = useState(false);
+
+ async function fetchMembers() {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/v1/collections/${collectionId}/members`);
+ if (!res.ok) throw new Error("Failed to load members");
+ const data = await res.json() as Member[];
+ setMembers(data);
+ } catch {
+ toast.error("Could not load members");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function handleOpenChange(next: boolean) {
+ setOpen(next);
+ if (next) {
+ void fetchMembers();
+ } else {
+ setEmail("");
+ setRole("viewer");
+ }
+ }
+
+ async function handleInvite() {
+ if (!email.trim()) {
+ toast.error("Enter an email address");
+ return;
+ }
+ setInviting(true);
+ try {
+ const res = await fetch(`/api/v1/collections/${collectionId}/members`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: email.trim(), role }),
+ });
+ if (res.status === 409) { toast.error("Already a member"); return; }
+ if (res.status === 404) { toast.error("User not found"); return; }
+ if (!res.ok) { toast.error("Could not invite user"); return; }
+ toast.success("Invitation sent");
+ setEmail("");
+ await fetchMembers();
+ } catch {
+ toast.error("Could not invite user");
+ } finally {
+ setInviting(false);
+ }
+ }
+
+ async function handleRemove(memberId: string) {
+ try {
+ const res = await fetch(
+ `/api/v1/collections/${collectionId}/members?memberId=${memberId}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok) { toast.error("Could not remove member"); return; }
+ setMembers((prev) => prev.filter((m) => m.id !== memberId));
+ toast.success("Member removed");
+ } catch {
+ toast.error("Could not remove member");
+ }
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/web/components/feed/feed-page-content.tsx b/apps/web/components/feed/feed-page-content.tsx
new file mode 100644
index 0000000..e9f2580
--- /dev/null
+++ b/apps/web/components/feed/feed-page-content.tsx
@@ -0,0 +1,155 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { Clock, Users, ChefHat, Flame, Heart } from "lucide-react";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { useLocale } from "@/lib/i18n/provider";
+
+type FeedRecipe = {
+ id: string;
+ title: string;
+ description: string | null;
+ baseServings: number;
+ prepMins: number | null;
+ cookMins: number | null;
+ difficulty: string | null;
+ aiGenerated: boolean;
+ createdAt: string;
+ authorId: string;
+ authorName: string;
+ authorUsername: string | null;
+ authorAvatarUrl: string | null;
+ visibility: string;
+ favoriteCount?: number;
+};
+
+type Props = {
+ followedCount: number;
+ feedRecipes: FeedRecipe[];
+};
+
+function RecipeCard({ recipe, locale }: { recipe: FeedRecipe; locale: string }) {
+ return (
+
+
+
+
+ {recipe.authorName.slice(0, 2).toUpperCase()}
+
+
+
+ {recipe.authorName}
+
+
+ {new Date(recipe.createdAt).toLocaleDateString(locale, { month: "short", day: "numeric" })}
+
+
+
+ {recipe.favoriteCount !== undefined && recipe.favoriteCount > 0 && (
+
+
+ {recipe.favoriteCount}
+
+ )}
+ {recipe.aiGenerated && AI}
+
+
+
+
+ {recipe.title}
+ {recipe.description && (
+ {recipe.description}
+ )}
+
+ {recipe.difficulty && {recipe.difficulty}}
+ {recipe.baseServings}
+ {recipe.prepMins && {recipe.prepMins}m}
+ {recipe.cookMins && {recipe.cookMins}m}
+
+
+
+ );
+}
+
+function TrendingTab() {
+ const { locale } = useLocale();
+ const t = useTranslations("feed");
+ const [recipes, setRecipes] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch("/api/v1/feed/trending")
+ .then((r) => r.json() as Promise<{ data: FeedRecipe[] }>)
+ .then(({ data }) => setRecipes(data))
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ if (loading) return {t("loading")}
;
+ if (recipes.length === 0) return {t("trendingEmpty")}
;
+
+ return (
+
+ {recipes.map((recipe) => (
+
+ ))}
+
+ );
+}
+
+export function FeedPageContent({ followedCount, feedRecipes }: Props) {
+ const t = useTranslations("feed");
+ const { locale } = useLocale();
+ const [tab, setTab] = useState<"following" | "trending">("following");
+
+ return (
+
+
{t("title")}
+
+ {/* Tabs */}
+
+
+
+
+
+ {tab === "following" ? (
+ followedCount === 0 ? (
+
+ ) : feedRecipes.length === 0 ? (
+
{t("noNew")}
+ ) : (
+
+ {feedRecipes.map((recipe) => (
+
+ ))}
+
+ )
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/social/comment-reactions.tsx b/apps/web/components/social/comment-reactions.tsx
new file mode 100644
index 0000000..7949929
--- /dev/null
+++ b/apps/web/components/social/comment-reactions.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+
+const REACTIONS: Record = {
+ like: "๐",
+ love: "โค๏ธ",
+ laugh: "๐",
+ wow: "๐ฎ",
+ sad: "๐ข",
+ fire: "๐ฅ",
+};
+
+type Props = {
+ recipeId: string;
+ commentId: string;
+ initialCounts?: Record;
+ initialUserReactions?: string[];
+};
+
+export function CommentReactions({ recipeId, commentId, initialCounts = {}, initialUserReactions = [] }: Props) {
+ const [counts, setCounts] = useState>(initialCounts);
+ const [userReactions, setUserReactions] = useState(initialUserReactions);
+ const [pending, setPending] = useState(null);
+
+ async function toggle(type: string) {
+ if (pending) return;
+ setPending(type);
+
+ // Optimistic update
+ const hasReacted = userReactions.includes(type);
+ setUserReactions((prev) =>
+ hasReacted ? prev.filter((r) => r !== type) : [...prev, type],
+ );
+ setCounts((prev) => ({
+ ...prev,
+ [type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? -1 : 1)),
+ }));
+
+ try {
+ const res = await fetch(`/api/v1/recipes/${recipeId}/comments/${commentId}/reactions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type }),
+ });
+
+ if (!res.ok) {
+ // Revert optimistic update
+ setUserReactions((prev) =>
+ hasReacted ? [...prev, type] : prev.filter((r) => r !== type),
+ );
+ setCounts((prev) => ({
+ ...prev,
+ [type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? 1 : -1)),
+ }));
+ if (res.status === 401) {
+ toast.error("Sign in to react to comments");
+ } else {
+ toast.error("Failed to update reaction");
+ }
+ return;
+ }
+
+ const data = await res.json() as { counts: Record; added: boolean };
+ setCounts(data.counts);
+ setUserReactions((prev) => {
+ if (data.added && !prev.includes(type)) return [...prev, type];
+ if (!data.added && prev.includes(type)) return prev.filter((r) => r !== type);
+ return prev;
+ });
+ } catch {
+ // Revert on network error
+ setUserReactions((prev) =>
+ hasReacted ? [...prev, type] : prev.filter((r) => r !== type),
+ );
+ setCounts((prev) => ({
+ ...prev,
+ [type]: Math.max(0, (prev[type] ?? 0) + (hasReacted ? 1 : -1)),
+ }));
+ toast.error("Failed to update reaction");
+ } finally {
+ setPending(null);
+ }
+ }
+
+ return (
+
+ {Object.entries(REACTIONS).map(([type, emoji]) => {
+ const reacted = userReactions.includes(type);
+ const cnt = counts[type] ?? 0;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/components/social/comments-section.tsx b/apps/web/components/social/comments-section.tsx
new file mode 100644
index 0000000..dc0cf87
--- /dev/null
+++ b/apps/web/components/social/comments-section.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { MessageCircle, Reply, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Separator } from "@/components/ui/separator";
+import { CommentReactions } from "@/components/social/comment-reactions";
+
+type Comment = {
+ id: string;
+ content: string;
+ parentId: string | null;
+ createdAt: string;
+ userId: string;
+ userName: string;
+ userUsername: string | null;
+ userAvatarUrl: string | null;
+};
+
+function timeAgo(dateStr: string) {
+ const diff = Date.now() - new Date(dateStr).getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+function CommentForm({
+ recipeId,
+ parentId,
+ onSubmit,
+ placeholder = "Add a commentโฆ",
+ onCancel,
+}: {
+ recipeId: string;
+ parentId?: string;
+ onSubmit: () => void;
+ placeholder?: string;
+ onCancel?: () => void;
+}) {
+ const [content, setContent] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ async function submit() {
+ if (!content.trim()) return;
+ setSubmitting(true);
+ try {
+ const res = await fetch(`/api/v1/recipes/${recipeId}/comments`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ content: content.trim(), parentId }),
+ });
+ if (!res.ok) { toast.error("Failed to post comment"); return; }
+ setContent("");
+ onSubmit();
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+function CommentItem({
+ comment,
+ replies,
+ currentUserId,
+ recipeId,
+ onRefresh,
+}: {
+ comment: Comment;
+ replies: Comment[];
+ currentUserId?: string;
+ recipeId: string;
+ onRefresh: () => void;
+}) {
+ const [showReply, setShowReply] = useState(false);
+ const isOwn = comment.userId === currentUserId;
+
+ async function deleteComment() {
+ const res = await fetch(`/api/v1/comments/${comment.id}`, { method: "DELETE" });
+ if (res.ok) { toast.success("Deleted"); onRefresh(); }
+ else toast.error("Failed to delete");
+ }
+
+ return (
+
+
+
+
+ {comment.userName.slice(0, 2).toUpperCase()}
+
+
+
+ {comment.userName}
+ {timeAgo(comment.createdAt)}
+
+
{comment.content}
+
+
+ {currentUserId && (
+
+ )}
+ {isOwn && (
+
+ )}
+
+ {showReply && (
+
{ setShowReply(false); onRefresh(); }}
+ placeholder="Replyโฆ"
+ onCancel={() => setShowReply(false)}
+ />
+ )}
+
+
+
+ {replies.length > 0 && (
+
+ {replies.map((reply) => (
+
+
+
+ {reply.userName.slice(0, 2).toUpperCase()}
+
+
+
+ {reply.userName}
+ {timeAgo(reply.createdAt)}
+
+
{reply.content}
+ {reply.userId === currentUserId && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function CommentsSection({
+ recipeId,
+ currentUserId,
+}: {
+ recipeId: string;
+ currentUserId?: string;
+}) {
+ const [comments, setComments] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ const res = await fetch(`/api/v1/recipes/${recipeId}/comments`);
+ if (res.ok) setComments(await res.json() as Comment[]);
+ setLoading(false);
+ }, [recipeId]);
+
+ useEffect(() => { void load(); }, [load]);
+
+ const topLevel = comments.filter((c) => !c.parentId);
+ const replies = (parentId: string) => comments.filter((c) => c.parentId === parentId);
+
+ return (
+
+
+
+
Comments
+ {!loading && ({comments.length})}
+
+
+ {currentUserId && (
+
+ )}
+
+ {loading ? (
+
Loadingโฆ
+ ) : topLevel.length === 0 ? (
+
No comments yet. Be the first!
+ ) : (
+
+ {topLevel.map((comment, i) => (
+
+ {i > 0 && }
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/social/favorite-button.tsx b/apps/web/components/social/favorite-button.tsx
new file mode 100644
index 0000000..21f506d
--- /dev/null
+++ b/apps/web/components/social/favorite-button.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useState } from "react";
+import { Heart } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export function FavoriteButton({
+ recipeId,
+ initialFavorited = false,
+}: {
+ recipeId: string;
+ initialFavorited?: boolean;
+}) {
+ const [favorited, setFavorited] = useState(initialFavorited);
+ const [loading, setLoading] = useState(false);
+
+ async function toggle() {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/v1/recipes/${recipeId}/favorite`, {
+ method: favorited ? "DELETE" : "POST",
+ });
+ if (!res.ok) return;
+ setFavorited(!favorited);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/social/follow-button.tsx b/apps/web/components/social/follow-button.tsx
new file mode 100644
index 0000000..88f9923
--- /dev/null
+++ b/apps/web/components/social/follow-button.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+
+export function FollowButton({
+ targetUserId: _targetUserId,
+ targetUsername,
+ initialFollowing = false,
+}: {
+ targetUserId: string;
+ targetUsername: string;
+ initialFollowing?: boolean;
+}) {
+ const [following, setFollowing] = useState(initialFollowing);
+ const [loading, setLoading] = useState(false);
+
+ async function toggle() {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/v1/users/${targetUsername}/follow`, {
+ method: following ? "DELETE" : "POST",
+ });
+ if (!res.ok) {
+ const err = await res.json() as { error?: string };
+ toast.error(err.error ?? "Failed");
+ return;
+ }
+ setFollowing(!following);
+ toast.success(following ? "Unfollowed" : "Following");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/social/new-collection-button.tsx b/apps/web/components/social/new-collection-button.tsx
new file mode 100644
index 0000000..ac0710c
--- /dev/null
+++ b/apps/web/components/social/new-collection-button.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useState } from "react";
+import { Plus } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+
+export function NewCollectionButton() {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [isPublic, setIsPublic] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ async function create() {
+ if (!name.trim()) return;
+ setSaving(true);
+ try {
+ const res = await fetch("/api/v1/collections", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: name.trim(), description: description.trim() || undefined, isPublic }),
+ });
+ if (!res.ok) { toast.error("Failed to create"); return; }
+ const { id } = await res.json() as { id: string };
+ toast.success("Collection created");
+ setOpen(false);
+ setName(""); setDescription(""); setIsPublic(false);
+ router.push(`/collections/${id}`);
+ router.refresh();
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/web/components/social/rating-stars.tsx b/apps/web/components/social/rating-stars.tsx
new file mode 100644
index 0000000..d5aa93c
--- /dev/null
+++ b/apps/web/components/social/rating-stars.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { Star } from "lucide-react";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+
+export function RatingStars({
+ recipeId,
+ initialScore = 0,
+ readonly = false,
+ size = "default",
+}: {
+ recipeId: string;
+ initialScore?: number;
+ readonly?: boolean;
+ size?: "sm" | "default";
+}) {
+ const [score, setScore] = useState(initialScore);
+ const [hovered, setHovered] = useState(0);
+ const [saving, setSaving] = useState(false);
+
+ async function rate(value: number) {
+ if (readonly || saving) return;
+ setSaving(true);
+ try {
+ const res = await fetch(`/api/v1/recipes/${recipeId}/rate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ score: value }),
+ });
+ if (!res.ok) {
+ const err = await res.json() as { error?: string };
+ toast.error(err.error ?? "Failed to rate");
+ return;
+ }
+ setScore(value);
+ toast.success("Rating saved");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-5 w-5";
+ const display = hovered || score;
+
+ return (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ );
+}