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 ? ( + {recipe.title} + ) : ( +
+ ๐Ÿฝ๏ธ +
+ )} +
+
+

{recipe.title}

+
+ + ); + })} +
+
+ ) : ( +
+

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 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
+ {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 ( + <> + + + + + + Share collection + + Invite people to view or edit this collection. + + + + {/* Invite form */} +
+ setEmail(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }} + className="flex-1" + /> + + +
+ + {/* Members list */} +
+ {loading && ( +

Loading membersโ€ฆ

+ )} + {!loading && members.length === 0 && ( +

No members yet.

+ )} + {members.map((m) => ( +
+
+ {m.user.name} + {m.user.username && ( + + @{m.user.username} + + )} +
+ + {m.role} + + +
+ ))} +
+
+
+ + ); +} 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 ? ( +
+

{t("followEmpty")}

+
+ ) : 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 ( +
+