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:
Arnaud
2026-07-01 08:11:31 +02:00
parent 3f96d1ea41
commit 9d9dfb46c6
26 changed files with 1427 additions and 0 deletions
+38
View File
@@ -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
+41
View File
@@ -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
+36
View File
@@ -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.
+37
View File
@@ -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 });
}
+56
View File
@@ -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 });
}
+51
View File
@@ -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 });
}
+15
View File
@@ -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

+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/recipes");
}
+172
View File
@@ -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">&ldquo;{cmd}&rdquo;</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>
);
}
+106
View File
@@ -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>`
);
}
+45
View File
@@ -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);
}
+141
View File
@@ -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 (15)", 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;
}
+16
View File
@@ -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;
}
+29
View File
@@ -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}`;
}
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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