diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a706525..9818331 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,7 +10,10 @@ "mounts": [ "source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent,type=bind" ], - "remoteEnv": { "VSCODE_AGENT_FOLDER": "/home/node/.vscode-server" }, + "remoteEnv": { + "SSH_AUTH_SOCK": "/ssh-agent", + "VSCODE_AGENT_FOLDER": "/home/node/.vscode-server" + }, "runArgs": ["--memory=6g", "--shm-size=1g"], "customizations": { diff --git a/src/app/api/recipes/[slug]/route.ts b/src/app/api/recipes/[slug]/route.ts new file mode 100644 index 0000000..85a172b --- /dev/null +++ b/src/app/api/recipes/[slug]/route.ts @@ -0,0 +1,25 @@ +import { db } from '@/lib/db'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET( + request: NextRequest, + { params }: { params: { slug: string } } +) { + try { + const { slug } = params; + const recipe = await db.recipe.findFirst({ + where: { slug }, + orderBy: { version: 'desc' }, + include: { ingredients: true, steps: true, components: true, media: true, book: true } + }); + + if (!recipe) { + return NextResponse.json({ error: 'Recipe not found' }, { status: 404 }); + } + + return NextResponse.json(recipe); + } catch (error) { + console.error('Error fetching recipe:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/recipes/search/route.ts b/src/app/api/recipes/search/route.ts new file mode 100644 index 0000000..923f782 --- /dev/null +++ b/src/app/api/recipes/search/route.ts @@ -0,0 +1,55 @@ +import { db } from '@/lib/db'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q')?.trim(); + const limit = parseInt(searchParams.get('limit') || '50'); + + if (!query) { + // If no query, return latest recipes + const recipes = await db.recipe.findMany({ + orderBy: [{ slug: 'asc' }, { version: 'desc' }], + take: limit, + include: { ingredients: true } + }); + + const latestBySlug = new Map(); + for (const r of recipes) if (!latestBySlug.has(r.slug)) latestBySlug.set(r.slug, r); + + return NextResponse.json(Array.from(latestBySlug.values())); + } + + // Search recipes by title, slug, description, and ingredients + const recipes = await db.recipe.findMany({ + where: { + OR: [ + { title: { contains: query, mode: 'insensitive' } }, + { slug: { contains: query, mode: 'insensitive' } }, + { description: { contains: query, mode: 'insensitive' } }, + { subtitle: { contains: query, mode: 'insensitive' } }, + { + ingredients: { + some: { + item: { contains: query, mode: 'insensitive' } + } + } + } + ] + }, + orderBy: [{ slug: 'asc' }, { version: 'desc' }], + take: limit, + include: { ingredients: true } + }); + + // Get latest version of each recipe + const latestBySlug = new Map(); + for (const r of recipes) if (!latestBySlug.has(r.slug)) latestBySlug.set(r.slug, r); + + return NextResponse.json(Array.from(latestBySlug.values())); + } catch (error) { + console.error('Error searching recipes:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 1f95ff1..deacdde 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,26 +1,89 @@ -import { db } from '@/lib/db'; +'use client'; + +import { useState, useEffect } from 'react'; import Link from 'next/link'; -export default async function HomePage(){ - const recipes = await db.recipe.findMany({ orderBy: [{ slug: 'asc' }, { version: 'desc' }] }); - const latestBySlug = new Map(); - for (const r of recipes) if (!latestBySlug.has(r.slug)) latestBySlug.set(r.slug, r); +export default function HomePage(){ + const [recipes, setRecipes] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [loading, setLoading] = useState(false); + const [debouncedQuery, setDebouncedQuery] = useState(''); + + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery]); + + // Fetch recipes based on search query + useEffect(() => { + async function fetchRecipes() { + setLoading(true); + try { + const params = new URLSearchParams(); + if (debouncedQuery) { + params.append('q', debouncedQuery); + } + const response = await fetch(`/api/recipes/search?${params}`); + if (response.ok) { + const data = await response.json(); + setRecipes(data); + } + } catch (error) { + console.error('Error fetching recipes:', error); + } finally { + setLoading(false); + } + } + + fetchRecipes(); + }, [debouncedQuery]); + return ( -
-

All Recipes (latest)

+
+
+

All Recipes (latest)

+ +
+ setSearchQuery(e.target.value)} + className="input w-full" + /> +
+ + {loading && ( +
Searching...
+ )} +
+
- {Array.from(latestBySlug.values()).map(r => ( - + {recipes.map(r => ( +
{r.title}
{r.slug} — v{r.version}
+ {r.description && ( +
{r.description}
+ )}
{r.yieldQty ? `${r.yieldQty} ${r.yieldUnit ?? ''}` : ''}
))}
+ + {!loading && recipes.length === 0 && debouncedQuery && ( +
+ No recipes found for "{debouncedQuery}" +
+ )}
); } diff --git a/src/app/recipes/[slug]/page.tsx b/src/app/recipes/[slug]/page.tsx index 6b06a41..4012c85 100644 --- a/src/app/recipes/[slug]/page.tsx +++ b/src/app/recipes/[slug]/page.tsx @@ -20,7 +20,10 @@ export default async function RecipePage({ params }: { params: { slug: string }

{recipe.title}

{recipe.slug} — v{recipe.version}
-
{recipe.yieldQty ? `${recipe.yieldQty} ${recipe.yieldUnit ?? ''}` : ''}
+
+
{recipe.yieldQty ? `${recipe.yieldQty} ${recipe.yieldUnit ?? ''}` : ''}
+ Print +
diff --git a/src/app/recipes/[slug]/print/page.tsx b/src/app/recipes/[slug]/print/page.tsx new file mode 100644 index 0000000..0229fb7 --- /dev/null +++ b/src/app/recipes/[slug]/print/page.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +export default function RecipePrintPage({ params }: { params: { slug: string } }){ + const { slug } = params; + const [recipe, setRecipe] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchRecipe() { + try { + const response = await fetch(`/api/recipes/${slug}`); + if (response.ok) { + const data = await response.json(); + setRecipe(data); + } + } catch (error) { + console.error('Error fetching recipe:', error); + } finally { + setLoading(false); + } + } + fetchRecipe(); + }, [slug]); + + if (loading) return
Loading...
; + if (!recipe) return
Not found.
; + + const grouped = recipe.ingredients.reduce((acc: Record, ing) => { + const key = ing.groupName ?? 'Ingredients'; (acc[key] ||= []).push(ing as any); return acc; + }, {}); + + return ( +
+ + + + ← Back to Recipe + + +
+

{recipe.title}

+
{recipe.slug} — v{recipe.version}
+ {recipe.yieldQty && ( +
Yield: {recipe.yieldQty} {recipe.yieldUnit ?? ''}
+ )} +
+ +
+
+
Ingredients
+ {Object.entries(grouped).map(([group, list]) => ( +
+
{group}
+
    + {list.sort((a,b)=>a.position-b.position).map(ing => ( +
  • + {ing.qty != null && ( + {ing.qty} {ing.unit} + )} + {ing.item} + {ing.prep && `, ${ing.prep}`} +
  • + ))} +
+
+ ))} +
+ +
+
Instructions
+
    + {recipe.steps.sort((a,b)=>a.position-b.position).map(st => ( +
  1. + {st.text} + {st.timerSec && ( + ({Math.round(st.timerSec/60)} min) + )} +
  2. + ))} +
+
+
+ + {recipe.components.length > 0 && ( +
+
Components
+
    + {recipe.components.sort((a,b)=>a.position-b.position).map(c => ( +
  • + Uses {c.targetSlug} × {c.scale} ({c.includeMode}) +
  • + ))} +
+
+ )} +
+ ); +}