diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4368e0 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# Database +DATABASE_URL=postgresql://epicure:epicure@localhost:5432/epicure + +# Redis +REDIS_URL=redis://localhost:6379 + +# Storage (MinIO / S3-compatible) +STORAGE_ENDPOINT=http://localhost:9000 +STORAGE_ACCESS_KEY=minioadmin +STORAGE_SECRET_KEY=minioadmin +STORAGE_BUCKET=epicure-uploads +STORAGE_REGION=us-east-1 + +# Auth (generate with: openssl rand -base64 32) +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 + +# OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# SMTP (leave blank to log emails to console in dev) +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM=Epicure + +# Stripe (optional — webhook stub only) +STRIPE_WEBHOOK_SECRET= + +# AI Providers (configure at least one) +OPENROUTER_API_KEY= +OPENROUTER_DEFAULT_MODEL=google/gemini-flash-1.5 +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +OLLAMA_BASE_URL=http://localhost:11434 diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/web/app/api/docs/route.ts b/apps/web/app/api/docs/route.ts new file mode 100644 index 0000000..4dc4e5a --- /dev/null +++ b/apps/web/app/api/docs/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; + +const HTML = ` + + + + + Epicure API Docs + + + + +
+ + + +`; + +export async function GET() { + return new NextResponse(HTML, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} diff --git a/apps/web/app/api/v1/ai-keys/[provider]/route.ts b/apps/web/app/api/v1/ai-keys/[provider]/route.ts new file mode 100644 index 0000000..1b14fb6 --- /dev/null +++ b/apps/web/app/api/v1/ai-keys/[provider]/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { db, userAiKeys, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ provider: string }> }; + +export async function DELETE(_req: Request, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + + const { provider } = await params; + + await db.delete(userAiKeys).where( + and(eq(userAiKeys.userId, session!.user.id), eq(userAiKeys.provider, provider)) + ); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/ai-keys/route.ts b/apps/web/app/api/v1/ai-keys/route.ts new file mode 100644 index 0000000..6e0acd6 --- /dev/null +++ b/apps/web/app/api/v1/ai-keys/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { db, userAiKeys, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { encrypt } from "@/lib/encrypt"; + +const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const; + +const PostSchema = z.object({ + provider: z.enum(VALID_PROVIDERS), + apiKey: z.string().min(1).max(500), +}); + +export async function GET() { + const { session, response } = await requireSession(); + if (response) return response; + + const keys = await db.query.userAiKeys.findMany({ + where: eq(userAiKeys.userId, session!.user.id), + columns: { provider: true, createdAt: true }, + }); + + return NextResponse.json({ keys }); +} + +export async function POST(req: Request) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = PostSchema.safeParse(await req.json()); + if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 }); + + const { provider, apiKey } = body.data; + const userId = session!.user.id; + + const existing = await db.query.userAiKeys.findFirst({ + where: and(eq(userAiKeys.userId, userId), eq(userAiKeys.provider, provider)), + }); + + const encryptedKey = encrypt(apiKey); + + if (existing) { + await db.update(userAiKeys) + .set({ encryptedKey }) + .where(and(eq(userAiKeys.userId, userId), eq(userAiKeys.provider, provider))); + } else { + await db.insert(userAiKeys).values({ + id: crypto.randomUUID(), + userId, + provider, + encryptedKey, + }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/comments/[id]/route.ts b/apps/web/app/api/v1/comments/[id]/route.ts new file mode 100644 index 0000000..5371850 --- /dev/null +++ b/apps/web/app/api/v1/comments/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, comments, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PUT(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const comment = await db.query.comments.findFirst({ where: eq(comments.id, id) }); + if (!comment || comment.userId !== session!.user.id) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const body = await req.json() as unknown; + const parsed = z.object({ content: z.string().min(1).max(5000) }).safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + await db.update(comments).set({ content: parsed.data.content, updatedAt: new Date() }).where(eq(comments.id, id)); + 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 comment = await db.query.comments.findFirst({ where: eq(comments.id, id) }); + if (!comment) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (comment.userId !== session!.user.id && session!.user.role === "user") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await db.delete(comments).where(eq(comments.id, id)); + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/feed/route.ts b/apps/web/app/api/v1/feed/route.ts new file mode 100644 index 0000000..a535a5a --- /dev/null +++ b/apps/web/app/api/v1/feed/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, recipes, users, userFollows, eq } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { desc, inArray } from "@epicure/db"; + +export async function GET(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const { searchParams } = new URL(req.url); + const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50); + const offset = parseInt(searchParams.get("offset") ?? "0"); + + // Get IDs of users the current user follows + 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 NextResponse.json({ data: [], limit, offset, message: "Follow some users to see their recipes here." }); + } + + 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, + createdAt: recipes.createdAt, + updatedAt: recipes.updatedAt, + authorId: recipes.authorId, + authorName: users.name, + authorUsername: users.username, + authorAvatarUrl: users.avatarUrl, + }) + .from(recipes) + .innerJoin(users, eq(recipes.authorId, users.id)) + .where((t) => inArray(t.authorId, followedIds)) + .orderBy(desc(recipes.createdAt)) + .limit(limit) + .offset(offset); + + return NextResponse.json({ data: feedRecipes, limit, offset }); +} diff --git a/apps/web/app/api/v1/feed/trending/route.ts b/apps/web/app/api/v1/feed/trending/route.ts new file mode 100644 index 0000000..db33087 --- /dev/null +++ b/apps/web/app/api/v1/feed/trending/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, recipes, users, favorites, eq, desc, sql, and, gte } from "@epicure/db"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50); + + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const trending = 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, + favoriteCount: sql`cast(count(${favorites.recipeId}) as int)`, + }) + .from(recipes) + .innerJoin(users, eq(recipes.authorId, users.id)) + .leftJoin( + favorites, + and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, sevenDaysAgo)) + ) + .where(eq(recipes.visibility, "public")) + .groupBy(recipes.id, users.id) + .orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt)) + .limit(limit); + + return NextResponse.json({ + data: trending.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })), + }); +} diff --git a/apps/web/app/api/v1/openapi.json/route.ts b/apps/web/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..89480f9 --- /dev/null +++ b/apps/web/app/api/v1/openapi.json/route.ts @@ -0,0 +1,8 @@ +import { generateOpenApiSpec } from "@/lib/openapi"; + +export async function GET() { + const spec = generateOpenApiSpec(); + return Response.json(spec, { + headers: { "Access-Control-Allow-Origin": "*" }, + }); +} diff --git a/apps/web/app/api/v1/upload/presign/route.ts b/apps/web/app/api/v1/upload/presign/route.ts new file mode 100644 index 0000000..4d0086c --- /dev/null +++ b/apps/web/app/api/v1/upload/presign/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { requireSession } from "@/lib/api-auth"; +import { createPresignedUploadUrl } from "@/lib/storage"; + +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"] as const; +type AllowedType = (typeof ALLOWED_TYPES)[number]; + +const Schema = z.object({ + contentType: z.string().refine((t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t), { + message: "Content type must be jpeg, png, webp, or avif", + }), + recipeId: z.string().uuid(), +}); + +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", issues: parsed.error.issues }, { status: 400 }); + } + + const ext = parsed.data.contentType.split("/")[1] ?? "jpg"; + const key = `recipes/${parsed.data.recipeId}/photos/${session!.user.id}-${crypto.randomUUID()}.${ext}`; + const url = await createPresignedUploadUrl(key, parsed.data.contentType); + + return NextResponse.json({ url, key }); +} diff --git a/apps/web/app/docs/route.ts b/apps/web/app/docs/route.ts new file mode 100644 index 0000000..6c52489 --- /dev/null +++ b/apps/web/app/docs/route.ts @@ -0,0 +1,15 @@ +export async function GET() { + const html = ` + + + Epicure API Reference + + + + + + + +`; + return new Response(html, { headers: { "Content-Type": "text/html" } }); +} diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/apps/web/app/favicon.ico differ diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..5618f09 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function RootPage() { + redirect("/recipes"); +} diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx new file mode 100644 index 0000000..45c56fd --- /dev/null +++ b/apps/web/app/r/[id]/page.tsx @@ -0,0 +1,172 @@ +import type { Metadata } from "next"; +import Script from "next/script"; +import { notFound } from "next/navigation"; +import { headers } from "next/headers"; +import Link from "next/link"; +import { Clock, Users, ChefHat, Globe } from "lucide-react"; +import { auth } from "@/lib/auth/server"; +import { db, recipes, eq } from "@epicure/db"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { ServingScaler } from "@/components/recipe/serving-scaler"; +import { getPublicUrl } from "@/lib/storage"; +import { cn } from "@/lib/utils"; + +type Params = { params: Promise<{ id: string }> }; + +export async function generateMetadata({ params }: Params): Promise { + const { id } = await params; + const recipe = await db.query.recipes.findFirst({ + where: (r, { and, eq }) => and(eq(r.id, id), eq(r.visibility, "public")), + }); + if (!recipe) return {}; + return { + title: recipe.title, + description: recipe.description ?? undefined, + openGraph: { + title: recipe.title, + description: recipe.description ?? undefined, + type: "article", + }, + }; +} + +const DIETARY_LABELS: Record = { + vegan: "Vegan", vegetarian: "Vegetarian", glutenFree: "Gluten-free", + dairyFree: "Dairy-free", nutFree: "Nut-free", halal: "Halal", kosher: "Kosher", +}; + +export default async function PublicRecipePage({ params }: Params) { + const { id } = await params; + const session = await auth.api.getSession({ headers: await headers() }).catch(() => null); + + const recipe = await db.query.recipes.findFirst({ + where: (r, { and, eq, ne }) => and(eq(r.id, id), ne(r.visibility, "private")), + with: { + author: true, + ingredients: { orderBy: (t, { asc }) => asc(t.order) }, + steps: { orderBy: (t, { asc }) => asc(t.order) }, + photos: { orderBy: (t, { asc }) => asc(t.order) }, + }, + }); + + if (!recipe) notFound(); + + const cover = recipe.photos.find((p) => p.isCover) ?? recipe.photos[0]; + const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); + const activeTags = Object.entries(recipe.dietaryTags ?? {}) + .filter(([, v]) => v).map(([k]) => DIETARY_LABELS[k]).filter(Boolean); + + const jsonLd = { + "@context": "https://schema.org", + "@type": "Recipe", + name: recipe.title, + description: recipe.description, + author: { "@type": "Person", name: recipe.author.name }, + recipeYield: String(recipe.baseServings), + prepTime: recipe.prepMins ? `PT${recipe.prepMins}M` : undefined, + cookTime: recipe.cookMins ? `PT${recipe.cookMins}M` : undefined, + recipeIngredient: recipe.ingredients.map((i) => + [i.quantity, i.unit, i.rawName].filter(Boolean).join(" ") + ), + recipeInstructions: recipe.steps.map((s) => ({ + "@type": "HowToStep", + text: s.instruction, + })), + image: cover ? [getPublicUrl(cover.storageKey)] : undefined, + }; + + return ( + <> +