feat: misc — cooking mode, OpenAPI docs, email, storage, upload, homepage
Cooking mode step-by-step view. OpenAPI 3.1 spec auto-generated from Zod schemas. Email lib (resend). Redis client. S3 storage helper. Photo upload endpoint. Landing page. Short recipe URL redirect /r/[id]. API docs page.
This commit is contained in:
@@ -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 <noreply@epicure.app>
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const HTML = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Epicure API Docs</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
|
<style>
|
||||||
|
body { margin: 0; }
|
||||||
|
.swagger-ui .topbar { background-color: #18181b; }
|
||||||
|
.swagger-ui .topbar .download-url-wrapper .select-label select { border-color: #52525b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
SwaggerUIBundle({
|
||||||
|
url: "/api/v1/openapi.json",
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
||||||
|
layout: "BaseLayout",
|
||||||
|
deepLinking: true,
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return new NextResponse(HTML, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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<number>`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() })),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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": "*" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export async function GET() {
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Epicure API Reference</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script id="api-reference" data-url="/api/v1/openapi.json"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect("/recipes");
|
||||||
|
}
|
||||||
@@ -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<Metadata> {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Script id="recipe-jsonld" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-8">
|
||||||
|
{/* Breadcrumb-style nav */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
<span>Public recipe by</span>
|
||||||
|
<Link href={`/u/${recipe.author.username ?? recipe.author.id}`} className="hover:text-foreground font-medium">
|
||||||
|
{recipe.author.name}
|
||||||
|
</Link>
|
||||||
|
{session && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Link href={`/recipes/${id}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
View in your library
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!session && (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>Sign up free</Link>
|
||||||
|
<Link href="/login" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>Log in</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
|
||||||
|
{recipe.description && (
|
||||||
|
<p className="text-muted-foreground leading-relaxed">{recipe.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
{recipe.difficulty && <Badge>{recipe.difficulty}</Badge>}
|
||||||
|
<span className="flex items-center gap-1"><Users className="h-4 w-4" />{recipe.baseServings} servings</span>
|
||||||
|
{recipe.prepMins && <span className="flex items-center gap-1"><Clock className="h-4 w-4" />{recipe.prepMins}m prep</span>}
|
||||||
|
{recipe.cookMins && <span className="flex items-center gap-1"><ChefHat className="h-4 w-4" />{recipe.cookMins}m cook</span>}
|
||||||
|
{totalMins > 0 && recipe.prepMins && recipe.cookMins && <span>({totalMins}m total)</span>}
|
||||||
|
</div>
|
||||||
|
{activeTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{activeTags.map((tag) => <Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cover && (
|
||||||
|
<div className="aspect-video overflow-hidden rounded-xl bg-muted">
|
||||||
|
<img src={getPublicUrl(cover.storageKey)} alt={recipe.title} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{recipe.ingredients.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Ingredients</h2>
|
||||||
|
<ServingScaler
|
||||||
|
baseServings={recipe.baseServings}
|
||||||
|
ingredients={recipe.ingredients.map((ing) => ({
|
||||||
|
id: ing.id, rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, note: ing.note, order: ing.order,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipe.steps.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold">Instructions</h2>
|
||||||
|
<ol className="space-y-6">
|
||||||
|
{recipe.steps.map((step, i) => (
|
||||||
|
<li key={step.id} className="flex gap-4">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">{i + 1}</div>
|
||||||
|
<p className="flex-1 pt-1 leading-relaxed">{step.instruction}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!session && (
|
||||||
|
<div className="rounded-xl border bg-muted/40 p-6 text-center space-y-3">
|
||||||
|
<p className="font-semibold">Want to save this recipe?</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Create a free account to save recipes, scale servings, and build your own library.</p>
|
||||||
|
<Link href="/signup" className={cn(buttonVariants())}>Get started free</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { X, ChevronLeft, ChevronRight, List, Mic, MicOff, Play, Square, HelpCircle, Clock } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useLocale } from "@/lib/i18n/provider";
|
||||||
|
|
||||||
|
type Step = { id: string; instruction: string; timerSeconds: number | null };
|
||||||
|
type Ingredient = { rawName: string; quantity: string | null; unit: string | null };
|
||||||
|
|
||||||
|
type TimerState = {
|
||||||
|
remaining: number;
|
||||||
|
running: boolean;
|
||||||
|
initial: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VOICE_COMMANDS: Record<string, Record<string, string[]>> = {
|
||||||
|
en: {
|
||||||
|
next: ["next", "continue"],
|
||||||
|
prev: ["previous", "back", "go back"],
|
||||||
|
timer: ["timer", "start"],
|
||||||
|
stop: ["stop", "pause"],
|
||||||
|
reset: ["reset"],
|
||||||
|
repeat: ["repeat", "again"],
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
next: ["suivant", "continuer", "prochain"],
|
||||||
|
prev: ["précédent", "retour", "reculer"],
|
||||||
|
timer: ["minuteur", "démarrer", "lancer"],
|
||||||
|
stop: ["arrêter", "pause", "stop"],
|
||||||
|
reset: ["réinitialiser", "recommencer"],
|
||||||
|
repeat: ["répéter", "encore"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPEECH_LANG: Record<string, string> = {
|
||||||
|
en: "en-US",
|
||||||
|
fr: "fr-FR",
|
||||||
|
};
|
||||||
|
|
||||||
|
function beep(freq = 880, duration = 0.15, vol = 0.3) {
|
||||||
|
try {
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
gain.gain.setValueAtTime(vol, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||||
|
osc.start(ctx.currentTime);
|
||||||
|
osc.stop(ctx.currentTime + duration);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(s: number) {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CookingMode({
|
||||||
|
recipeId,
|
||||||
|
recipeTitle,
|
||||||
|
steps,
|
||||||
|
ingredients,
|
||||||
|
}: {
|
||||||
|
recipeId: string;
|
||||||
|
recipeTitle: string;
|
||||||
|
steps: Step[];
|
||||||
|
ingredients: Ingredient[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations("cookingMode");
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [showIngredients, setShowIngredients] = useState(false);
|
||||||
|
const [voiceActive, setVoiceActive] = useState(false);
|
||||||
|
const [voiceStatus, setVoiceStatus] = useState("");
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
|
// Parallel timers: keyed by step index
|
||||||
|
const [timers, setTimers] = useState<Map<number, TimerState>>(() => new Map());
|
||||||
|
|
||||||
|
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||||
|
const recognitionRef = useRef<InstanceType<typeof window.SpeechRecognition> | null>(null);
|
||||||
|
|
||||||
|
const step = steps[current]!;
|
||||||
|
const isLast = current === steps.length - 1;
|
||||||
|
const isFirst = current === 0;
|
||||||
|
|
||||||
|
const currentTimer = timers.get(current);
|
||||||
|
const otherRunningTimers = [...timers.entries()].filter(([idx, t]) => idx !== current && t.running);
|
||||||
|
|
||||||
|
// Wake Lock
|
||||||
|
useEffect(() => {
|
||||||
|
navigator.wakeLock?.request("screen").then((lock) => { wakeLockRef.current = lock; }).catch(() => {});
|
||||||
|
return () => { wakeLockRef.current?.release().catch(() => {}); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Master tick: decrement all running timers once per second
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setTimers((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [stepIdx, timer] of next) {
|
||||||
|
if (!timer.running) continue;
|
||||||
|
changed = true;
|
||||||
|
const rem = timer.remaining - 1;
|
||||||
|
if (rem <= 3 && rem > 0) beep(660 + (3 - rem) * 110, 0.1);
|
||||||
|
if (rem <= 0) {
|
||||||
|
next.set(stepIdx, { ...timer, remaining: 0, running: false });
|
||||||
|
beep(880, 0.3);
|
||||||
|
setTimeout(() => beep(1100, 0.3), 350);
|
||||||
|
setTimeout(() => beep(1320, 0.5), 700);
|
||||||
|
} else {
|
||||||
|
next.set(stepIdx, { ...timer, remaining: rem });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function startTimer(stepIdx = current) {
|
||||||
|
const s = steps[stepIdx];
|
||||||
|
if (!s?.timerSeconds) return;
|
||||||
|
setTimers((prev) => {
|
||||||
|
const existing = prev.get(stepIdx);
|
||||||
|
const remaining = existing && existing.remaining > 0 ? existing.remaining : s.timerSeconds!;
|
||||||
|
if (existing?.running) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(stepIdx, { remaining, running: true, initial: s.timerSeconds! });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer(stepIdx = current) {
|
||||||
|
setTimers((prev) => {
|
||||||
|
const t = prev.get(stepIdx);
|
||||||
|
if (!t || !t.running) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(stepIdx, { ...t, running: false });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTimer(stepIdx = current) {
|
||||||
|
setTimers((prev) => {
|
||||||
|
const s = steps[stepIdx];
|
||||||
|
if (!s?.timerSeconds) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(stepIdx);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissTimer(stepIdx: number) {
|
||||||
|
setTimers((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(stepIdx);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
if (!isLast) setCurrent((c) => c + 1);
|
||||||
|
}, [isLast]);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
if (!isFirst) setCurrent((c) => c - 1);
|
||||||
|
}, [isFirst]);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.target !== document.body) return;
|
||||||
|
if (e.key === "?") { setShowHelp((v) => !v); return; }
|
||||||
|
if (e.key === "Escape") { if (showHelp) { setShowHelp(false); return; } router.push(`/recipes/${recipeId}`); }
|
||||||
|
if (showHelp) return;
|
||||||
|
if (e.key === "ArrowRight" || e.key === " ") { e.preventDefault(); goNext(); }
|
||||||
|
if (e.key === "ArrowLeft") goPrev();
|
||||||
|
if (e.key === "t" || e.key === "T") startTimer();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [goNext, goPrev, recipeId, showHelp]);
|
||||||
|
|
||||||
|
// Voice recognition
|
||||||
|
function toggleVoice() {
|
||||||
|
const SR = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
||||||
|
if (!SR) { setVoiceStatus(t("voiceNotSupported")); return; }
|
||||||
|
|
||||||
|
if (voiceActive) {
|
||||||
|
recognitionRef.current?.stop();
|
||||||
|
recognitionRef.current = null;
|
||||||
|
setVoiceActive(false);
|
||||||
|
setVoiceStatus("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = new SR();
|
||||||
|
rec.continuous = true;
|
||||||
|
rec.interimResults = false;
|
||||||
|
rec.lang = SPEECH_LANG[locale] ?? "en-US";
|
||||||
|
|
||||||
|
rec.onresult = (e: SpeechRecognitionEvent) => {
|
||||||
|
const transcript = e.results[e.results.length - 1]![0]!.transcript.toLowerCase().trim();
|
||||||
|
setVoiceStatus(`"${transcript}"`);
|
||||||
|
const cmds = VOICE_COMMANDS[locale] ?? VOICE_COMMANDS.en!;
|
||||||
|
if (cmds.next!.some(k => transcript.includes(k))) goNext();
|
||||||
|
else if (cmds.prev!.some(k => transcript.includes(k))) goPrev();
|
||||||
|
else if (cmds.timer!.some(k => transcript.includes(k))) startTimer();
|
||||||
|
else if (cmds.stop!.some(k => transcript.includes(k))) stopTimer();
|
||||||
|
else if (cmds.reset!.some(k => transcript.includes(k))) resetTimer();
|
||||||
|
else if (cmds.repeat!.some(k => transcript.includes(k))) setVoiceStatus(t("stepRepeatPrefix", { n: current + 1 }) + step.instruction.slice(0, 60) + "…");
|
||||||
|
setTimeout(() => setVoiceStatus(""), 3000);
|
||||||
|
};
|
||||||
|
rec.onerror = () => setVoiceStatus(t("voiceError"));
|
||||||
|
rec.onend = () => { if (voiceActive) rec.start(); };
|
||||||
|
|
||||||
|
rec.start();
|
||||||
|
recognitionRef.current = rec;
|
||||||
|
setVoiceActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-background flex flex-col select-none">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/recipes/${recipeId}`)}
|
||||||
|
className="rounded-full p-2 hover:bg-muted transition-colors"
|
||||||
|
title="Exit (Esc)"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wider">{t("cooking")}</p>
|
||||||
|
<h1 className="font-semibold text-sm leading-tight">{recipeTitle}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleVoice}
|
||||||
|
className={cn("rounded-full p-2 transition-colors", voiceActive ? "bg-primary text-primary-foreground" : "hover:bg-muted")}
|
||||||
|
title={voiceActive ? t("stopVoice") : t("enableVoice")}
|
||||||
|
>
|
||||||
|
{voiceActive ? <Mic className="h-5 w-5" /> : <MicOff className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowIngredients((v) => !v)}
|
||||||
|
className={cn("rounded-full p-2 transition-colors", showIngredients ? "bg-primary text-primary-foreground" : "hover:bg-muted")}
|
||||||
|
title={t("toggleIngredients")}
|
||||||
|
>
|
||||||
|
<List className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHelp((v) => !v)}
|
||||||
|
className={cn("rounded-full p-2 transition-colors", showHelp ? "bg-primary text-primary-foreground" : "hover:bg-muted")}
|
||||||
|
title="Shortcuts & voice commands (?)"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-1 bg-muted shrink-0">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${((current + 1) / steps.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main area */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Step content */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center px-8 py-12 gap-8">
|
||||||
|
{/* Step number */}
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("stepOf", { current: current + 1, total: steps.length })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instruction */}
|
||||||
|
<p className="text-2xl sm:text-3xl leading-relaxed text-center font-medium max-w-2xl">
|
||||||
|
{step.instruction}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Timer for current step */}
|
||||||
|
{step.timerSeconds && (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"text-5xl font-mono font-bold tabular-nums",
|
||||||
|
currentTimer?.running && currentTimer.remaining <= 10 && "text-destructive"
|
||||||
|
)}>
|
||||||
|
{formatTime(currentTimer?.remaining ?? step.timerSeconds)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!currentTimer?.running ? (
|
||||||
|
<button
|
||||||
|
onClick={() => startTimer()}
|
||||||
|
disabled={currentTimer?.remaining === 0}
|
||||||
|
className="flex items-center gap-2 rounded-full px-5 py-2.5 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
||||||
|
title="Start timer (T)"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
{currentTimer?.remaining === 0 ? t("timerDone") : t("startTimer")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => stopTimer()}
|
||||||
|
className="flex items-center gap-2 rounded-full px-5 py-2.5 bg-muted text-sm font-medium hover:bg-muted/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
{t("pause")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{currentTimer && currentTimer.remaining !== step.timerSeconds && (
|
||||||
|
<button onClick={() => resetTimer()} className="rounded-full px-4 py-2.5 text-sm text-muted-foreground hover:bg-muted transition-colors">
|
||||||
|
{t("reset")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voice status */}
|
||||||
|
{voiceStatus && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">{voiceStatus}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ingredients drawer */}
|
||||||
|
{showIngredients && (
|
||||||
|
<aside className="w-72 border-l bg-muted/20 overflow-y-auto py-6 px-5 shrink-0">
|
||||||
|
<h2 className="font-semibold mb-4 text-sm uppercase tracking-wider text-muted-foreground">{t("ingredients")}</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{ingredients.map((ing, i) => (
|
||||||
|
<li key={i} className="text-sm flex gap-2">
|
||||||
|
<span className="text-muted-foreground shrink-0 w-16 text-right">
|
||||||
|
{ing.quantity}{ing.unit ? ` ${ing.unit}` : ""}
|
||||||
|
</span>
|
||||||
|
<span>{ing.rawName}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parallel timers dock */}
|
||||||
|
{otherRunningTimers.length > 0 && (
|
||||||
|
<div className="shrink-0 border-t bg-muted/30 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{otherRunningTimers.map(([stepIdx, timer]) => (
|
||||||
|
<button
|
||||||
|
key={stepIdx}
|
||||||
|
onClick={() => setCurrent(stepIdx)}
|
||||||
|
className="flex items-center gap-2 rounded-full bg-background border px-3 py-1.5 text-xs font-mono hover:bg-muted transition-colors shrink-0"
|
||||||
|
title={`Step ${stepIdx + 1}: ${steps[stepIdx]?.instruction.slice(0, 50)}`}
|
||||||
|
>
|
||||||
|
<span className={cn("font-bold tabular-nums", timer.remaining <= 10 && "text-destructive")}>
|
||||||
|
{formatTime(timer.remaining)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">Step {stepIdx + 1}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); dismissTimer(stepIdx); }}
|
||||||
|
className="ml-1 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Help overlay */}
|
||||||
|
{showHelp && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 bg-background/80 backdrop-blur-sm flex items-center justify-center p-6"
|
||||||
|
onClick={() => setShowHelp(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-popover border rounded-2xl shadow-xl w-full max-w-lg p-6 space-y-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-lg">{t("shortcutsTitle")}</h2>
|
||||||
|
<button onClick={() => setShowHelp(false)} className="rounded-full p-1.5 hover:bg-muted transition-colors">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{t("keyboardSection")}</p>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{[
|
||||||
|
["→ / Space", t("shortcuts.nextStep")],
|
||||||
|
["←", t("shortcuts.prevStep")],
|
||||||
|
["T", t("shortcuts.startTimer")],
|
||||||
|
["?", t("shortcuts.toggleHelp")],
|
||||||
|
["Esc", t("shortcuts.exitMode")],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between gap-4">
|
||||||
|
<kbd className="font-mono text-xs bg-muted px-2 py-0.5 rounded border border-border">{key}</kbd>
|
||||||
|
<span className="text-muted-foreground text-right">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{t("voiceSection")}</p>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{[
|
||||||
|
[t("voiceLabels.nextCmd"), t("voiceLabels.nextLabel")],
|
||||||
|
[t("voiceLabels.prevCmd"), t("voiceLabels.prevLabel")],
|
||||||
|
[t("voiceLabels.timerCmd"), t("voiceLabels.timerLabel")],
|
||||||
|
[t("voiceLabels.stopCmd"), t("voiceLabels.stopLabel")],
|
||||||
|
[t("voiceLabels.resetCmd"), t("voiceLabels.resetLabel")],
|
||||||
|
[t("voiceLabels.repeatCmd"), t("voiceLabels.repeatLabel")],
|
||||||
|
].map(([cmd, label]) => (
|
||||||
|
<div key={cmd} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="font-mono text-xs bg-muted px-2 py-0.5 rounded border border-border whitespace-nowrap">“{cmd}”</span>
|
||||||
|
<span className="text-muted-foreground text-right">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!voiceActive && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t("enableMicFirst")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom navigation */}
|
||||||
|
<div className="flex items-center justify-between px-8 py-6 border-t shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={goPrev}
|
||||||
|
disabled={isFirst}
|
||||||
|
className="flex items-center gap-2 rounded-full px-5 py-3 border text-sm font-medium hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Previous step (←)"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
{t("previous")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Step dots */}
|
||||||
|
<div className="hidden sm:flex gap-1.5">
|
||||||
|
{steps.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrent(i)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full transition-all",
|
||||||
|
i === current ? "w-6 h-2.5 bg-primary" : "w-2.5 h-2.5 bg-muted-foreground/30 hover:bg-muted-foreground/60"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLast ? (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/recipes/${recipeId}`)}
|
||||||
|
className="flex items-center gap-2 rounded-full px-5 py-3 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
{t("finish")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={goNext}
|
||||||
|
className="flex items-center gap-2 rounded-full px-5 py-3 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
title="Next step (→ or Space)"
|
||||||
|
>
|
||||||
|
{t("next")}
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
function getTransport() {
|
||||||
|
const host = process.env["SMTP_HOST"];
|
||||||
|
if (!host) return null;
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port: parseInt(process.env["SMTP_PORT"] ?? "587"),
|
||||||
|
secure: process.env["SMTP_SECURE"] === "true",
|
||||||
|
auth: {
|
||||||
|
user: process.env["SMTP_USER"],
|
||||||
|
pass: process.env["SMTP_PASS"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const FROM = process.env["SMTP_FROM"] ?? "Epicure <noreply@epicure.app>";
|
||||||
|
|
||||||
|
export async function sendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
}) {
|
||||||
|
const transport = getTransport();
|
||||||
|
if (!transport) {
|
||||||
|
console.log(`[email:dev] No SMTP_HOST — logging email instead.\n Subject: ${subject}\n To: ${to}\n Body: ${html.replace(/<[^>]+>/g, "").slice(0, 200)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const info = await transport.sendMail({ from: FROM, to, subject, html });
|
||||||
|
console.log(`[email:sent] "${subject}" → ${to} | id:${info.messageId} | response:${info.response}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[email:error] "${subject}" → ${to} |`, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Templates ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function layout(title: string, body: string) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${title}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#fafaf9;font-family:Georgia,serif;color:#1c1917;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e7e5e4;">
|
||||||
|
<tr><td style="padding:32px 40px;border-bottom:1px solid #e7e5e4;">
|
||||||
|
<span style="font-size:22px;font-weight:bold;letter-spacing:-0.5px;">🍴 Epicure</span>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:40px;">
|
||||||
|
${body}
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:24px 40px;background:#fafaf9;border-top:1px solid #e7e5e4;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#78716c;">You received this email from Epicure. If you didn't request it, ignore this email.</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function btn(href: string, label: string) {
|
||||||
|
return `<a href="${href}" style="display:inline-block;background:#1c1917;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-size:15px;font-weight:600;margin:24px 0;">${label}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyEmailHtml(url: string) {
|
||||||
|
return layout(
|
||||||
|
"Verify your email — Epicure",
|
||||||
|
`<h1 style="margin:0 0 8px;font-size:24px;">Verify your email</h1>
|
||||||
|
<p style="margin:0 0 4px;color:#57534e;line-height:1.6;">Click the button below to verify your email address and activate your Epicure account.</p>
|
||||||
|
${btn(url, "Verify email")}
|
||||||
|
<p style="margin:16px 0 0;font-size:13px;color:#a8a29e;">Link expires in 24 hours. Or paste this URL into your browser:<br/>
|
||||||
|
<a href="${url}" style="color:#78716c;font-size:12px;word-break:break-all;">${url}</a></p>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPasswordHtml(url: string) {
|
||||||
|
return layout(
|
||||||
|
"Reset your password — Epicure",
|
||||||
|
`<h1 style="margin:0 0 8px;font-size:24px;">Reset your password</h1>
|
||||||
|
<p style="margin:0 0 4px;color:#57534e;line-height:1.6;">We received a request to reset the password for your Epicure account.</p>
|
||||||
|
${btn(url, "Reset password")}
|
||||||
|
<p style="margin:16px 0 0;font-size:13px;color:#a8a29e;">Link expires in 1 hour. If you didn't request a reset, you can safely ignore this email.</p>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function welcomeHtml(name: string) {
|
||||||
|
return layout(
|
||||||
|
"Welcome to Epicure",
|
||||||
|
`<h1 style="margin:0 0 8px;font-size:24px;">Welcome, ${name}!</h1>
|
||||||
|
<p style="margin:0 0 16px;color:#57534e;line-height:1.6;">Your account is ready. Start by creating your first recipe, importing from a URL, or letting AI generate one for you.</p>
|
||||||
|
${btn(process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000", "Go to Epicure")}
|
||||||
|
<p style="margin:16px 0 0;font-size:13px;color:#a8a29e;">Questions? Just reply to this email.</p>`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const FRACTIONS: [number, string][] = [
|
||||||
|
[1 / 8, "⅛"],
|
||||||
|
[1 / 4, "¼"],
|
||||||
|
[1 / 3, "⅓"],
|
||||||
|
[3 / 8, "⅜"],
|
||||||
|
[1 / 2, "½"],
|
||||||
|
[5 / 8, "⅝"],
|
||||||
|
[2 / 3, "⅔"],
|
||||||
|
[3 / 4, "¾"],
|
||||||
|
[7 / 8, "⅞"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function formatQuantity(value: number): string {
|
||||||
|
if (value === 0) return "0";
|
||||||
|
|
||||||
|
const whole = Math.floor(value);
|
||||||
|
const decimal = value - whole;
|
||||||
|
|
||||||
|
if (decimal < 0.05) return whole === 0 ? "0" : String(whole);
|
||||||
|
if (decimal > 0.95) return String(whole + 1);
|
||||||
|
|
||||||
|
let bestFraction = "";
|
||||||
|
let bestDiff = Infinity;
|
||||||
|
for (const [frac, symbol] of FRACTIONS) {
|
||||||
|
const diff = Math.abs(decimal - frac);
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff;
|
||||||
|
bestFraction = symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestDiff > 0.06) {
|
||||||
|
return value.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return whole === 0 ? bestFraction : `${whole} ${bestFraction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleQuantity(baseQuantity: string | null, base: number, desired: number): string {
|
||||||
|
if (!baseQuantity) return "";
|
||||||
|
const raw = parseFloat(baseQuantity);
|
||||||
|
if (isNaN(raw)) return baseQuantity;
|
||||||
|
const scale = base === 0 ? 1 : desired / base;
|
||||||
|
return formatQuantity(raw * scale);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
OpenApiGeneratorV31,
|
||||||
|
OpenAPIRegistry,
|
||||||
|
extendZodWithOpenApi,
|
||||||
|
} from "@asteasolutions/zod-to-openapi";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Nothing at module level — Next.js evaluates route modules at build time to
|
||||||
|
// determine static vs dynamic, which would run registry.register() before
|
||||||
|
// extendZodWithOpenApi patches the Zod prototype. Wrap everything lazily.
|
||||||
|
let cachedSpec: object | null = null;
|
||||||
|
|
||||||
|
export function generateOpenApiSpec(): object {
|
||||||
|
if (cachedSpec) return cachedSpec;
|
||||||
|
|
||||||
|
extendZodWithOpenApi(z);
|
||||||
|
const registry = new OpenAPIRegistry();
|
||||||
|
|
||||||
|
const security: Array<Record<string, string[]>> = [{ SessionCookie: [] }, { BearerApiKey: [] }];
|
||||||
|
|
||||||
|
registry.registerComponent("securitySchemes", "SessionCookie", { type: "apiKey", in: "cookie", name: "better-auth.session_token" });
|
||||||
|
registry.registerComponent("securitySchemes", "BearerApiKey", { type: "http", scheme: "bearer", bearerFormat: "ek_..." });
|
||||||
|
|
||||||
|
const DietaryTagsRef = registry.register("DietaryTags", z.object({
|
||||||
|
vegan: z.boolean().optional(), vegetarian: z.boolean().optional(),
|
||||||
|
glutenFree: z.boolean().optional(), dairyFree: z.boolean().optional(),
|
||||||
|
nutFree: z.boolean().optional(), halal: z.boolean().optional(), kosher: z.boolean().optional(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RecipeIngredientRef = registry.register("RecipeIngredient", z.object({
|
||||||
|
id: z.string(), rawName: z.string(), quantity: z.number().nullable(),
|
||||||
|
unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RecipeStepRef = registry.register("RecipeStep", z.object({
|
||||||
|
id: z.string(), order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RecipeRef = registry.register("Recipe", z.object({
|
||||||
|
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
||||||
|
baseServings: z.number().int(), visibility: z.enum(["private", "unlisted", "public"]),
|
||||||
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||||
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
|
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
||||||
|
ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef),
|
||||||
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CreateRecipeRef = registry.register("CreateRecipe", z.object({
|
||||||
|
title: z.string().min(1).max(200), description: z.string().optional(),
|
||||||
|
baseServings: z.number().int().positive(), visibility: z.enum(["private", "unlisted", "public"]),
|
||||||
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
|
prepMins: z.number().int().positive().optional(), cookMins: z.number().int().positive().optional(),
|
||||||
|
dietaryTags: DietaryTagsRef.optional(),
|
||||||
|
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().nullable(), unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int() })),
|
||||||
|
steps: z.array(z.object({ order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ApiErrorRef = registry.register("ApiError", z.object({ error: z.string() }));
|
||||||
|
|
||||||
|
const Pagination = z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20) });
|
||||||
|
|
||||||
|
const CommentRef = registry.register("Comment", z.object({
|
||||||
|
id: z.string(), recipeId: z.string(), userId: z.string(),
|
||||||
|
body: z.string(), parentId: z.string().nullable(), createdAt: z.string().datetime(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CollectionRef = registry.register("Collection", z.object({
|
||||||
|
id: z.string(), userId: z.string(), name: z.string(),
|
||||||
|
description: z.string().nullable(), isPublic: z.boolean(), createdAt: z.string().datetime(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MealPlanRef = registry.register("MealPlan", z.object({
|
||||||
|
weekStart: z.string(),
|
||||||
|
entries: z.array(z.object({ id: z.string(), dayOfWeek: z.number(), mealType: z.string(), recipeId: z.string(), servings: z.number() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PantryItemRef = registry.register("PantryItem", z.object({
|
||||||
|
id: z.string(), rawName: z.string(), quantity: z.string().nullable(),
|
||||||
|
unit: z.string().nullable(), expiresAt: z.string().datetime().nullable(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ShoppingListRef = registry.register("ShoppingList", z.object({
|
||||||
|
id: z.string(), name: z.string(), generatedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
|
||||||
|
items: z.array(z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable(), checked: z.boolean() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ApiKeyRef = registry.register("ApiKey", z.object({
|
||||||
|
id: z.string(), name: z.string(), lastUsedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CreateApiKeyResponseRef = registry.register("CreateApiKeyResponse", z.object({
|
||||||
|
id: z.string(), name: z.string(), key: z.string().describe("Full key — shown once"), createdAt: z.string().datetime(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const AiGeneratedRef = registry.register("AiGeneratedRecipe", z.object({
|
||||||
|
title: z.string(), description: z.string(), baseServings: z.number(),
|
||||||
|
difficulty: z.enum(["easy", "medium", "hard"]),
|
||||||
|
prepMins: z.number().nullable(), cookMins: z.number().nullable(),
|
||||||
|
dietaryTags: DietaryTagsRef,
|
||||||
|
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().nullable(), unit: z.string().nullable(), note: z.string().nullable() })),
|
||||||
|
steps: z.array(z.object({ instruction: z.string(), timerSeconds: z.number().nullable() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PaginatedRecipes = z.object({
|
||||||
|
data: z.array(RecipeRef),
|
||||||
|
pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const idParam = z.object({ id: z.string() });
|
||||||
|
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List recipes", security, request: { query: z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20), visibility: z.enum(["private", "unlisted", "public"]).optional(), q: z.string().optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}", summary: "Delete recipe", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/favorite", summary: "Toggle favorite", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/rate", summary: "Rate recipe (1–5)", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ score: z.number().int().min(1).max(5) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ score: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments", summary: "List comments", security, request: { params: idParam, query: Pagination }, responses: { 200: { description: "Comments", content: { "application/json": { schema: z.array(CommentRef) } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments", summary: "Post comment", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ body: z.string().min(1), parentId: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: CommentRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(1) }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based)", security, request: { query: Pagination }, responses: { 200: { description: "Feed", content: { "application/json": { schema: z.array(RecipeRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: Pagination }, responses: { 200: { description: "Collections", content: { "application/json": { schema: z.array(CollectionRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get meal plan for week", security, request: { params: z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }) }, responses: { 200: { description: "Meal plan", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: Pagination }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.array(PantryItemRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", security, request: { query: Pagination }, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists", summary: "Create shopping list", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1), fromMealPlanWeek: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: ShoppingListRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100) }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
|
||||||
|
const generator = new OpenApiGeneratorV31(registry.definitions);
|
||||||
|
cachedSpec = generator.generateDocument({
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: { title: "Epicure API", version: "1.0.0", description: "Auth: session cookie or Bearer API key (`ek_...`)." },
|
||||||
|
servers: [{ url: process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedSpec;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __redis: Redis | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRedis(): Redis {
|
||||||
|
if (!globalThis.__redis) {
|
||||||
|
globalThis.__redis = new Redis(
|
||||||
|
process.env["REDIS_URL"] ?? "redis://localhost:6379",
|
||||||
|
{ lazyConnect: false, maxRetriesPerRequest: 3 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return globalThis.__redis;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
|
||||||
|
const bucket = process.env["STORAGE_BUCKET"] ?? "epicure-uploads";
|
||||||
|
const endpoint = process.env["STORAGE_ENDPOINT"] ?? "http://localhost:9000";
|
||||||
|
const publicUrl = process.env["STORAGE_PUBLIC_URL"] ?? "http://localhost:9000";
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
region: process.env["STORAGE_REGION"] ?? "us-east-1",
|
||||||
|
endpoint,
|
||||||
|
forcePathStyle: true,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env["STORAGE_ACCESS_KEY"] ?? "minioadmin",
|
||||||
|
secretAccessKey: process.env["STORAGE_SECRET_KEY"] ?? "minioadmin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createPresignedUploadUrl(key: string, contentType: string): Promise<string> {
|
||||||
|
const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType });
|
||||||
|
return getSignedUrl(s3, command, { expiresIn: 300 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteObject(key: string): Promise<void> {
|
||||||
|
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicUrl(key: string): string {
|
||||||
|
return `${publicUrl}/${bucket}/${key}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
Reference in New Issue
Block a user