Goal: Build a lightweight recipe management app on Val Town that lets users save recipes from URLs via AI extraction, view their collection, and generate shopping lists.
Architecture: Single Val Town project with Hono backend serving a React frontend. SQLite for persistence. OpenAI for recipe extraction from scraped web pages. Simple REST API with three core endpoints: recipes CRUD, URL ingestion, and shopping list generation.
Tech Stack: Val Town, TypeScript, Hono, React 18.2.0, SQLite, OpenAI GPT-4o-mini, TailwindCSS (via Twind)
// shared/types.ts
interface Recipe {
id: number;
title: string;
source_url: string | null;
servings: number;
prep_time_minutes: number | null;
cook_time_minutes: number | null;
created_at: string;
updated_at: string;
}
interface Ingredient {
id: number;
recipe_id: number;
name: string; // "chicken breast"
quantity: string; // "2" or "1/2"
unit: string | null; // "lb", "cup", "piece", null for "2 cloves garlic"
notes: string | null; // "diced", "optional"
sort_order: number;
}
interface Instruction {
id: number;
recipe_id: number;
step_number: number;
text: string;
}
Why this model:
- Normalized ingredients enable smart shopping list aggregation
sort_orderpreserves ingredient grouping (proteins together, etc.)quantityas string handles fractions ("1/2") without float precision issues- Extensible: add
tags,categories,notestables later without schema changes
Files:
- Create:
backend/index.ts - Create:
backend/README.md - Create:
frontend/index.html - Create:
frontend/README.md - Create:
shared/types.ts - Create:
shared/README.md - Create:
README.md
Step 1: Create shared types
// shared/types.ts
export interface Recipe {
id: number;
title: string;
source_url: string | null;
servings: number;
prep_time_minutes: number | null;
cook_time_minutes: number | null;
created_at: string;
updated_at: string;
}
export interface Ingredient {
id: number;
recipe_id: number;
name: string;
quantity: string;
unit: string | null;
notes: string | null;
sort_order: number;
}
export interface Instruction {
id: number;
recipe_id: number;
step_number: number;
text: string;
}
// API request/response types
export interface RecipeWithDetails extends Recipe {
ingredients: Ingredient[];
instructions: Instruction[];
}
export interface IngestRequest {
url: string;
}
export interface ShoppingListRequest {
recipe_ids: number[];
}
export interface ShoppingListItem {
name: string;
total_quantity: string;
unit: string | null;
recipes: string[]; // which recipes need this ingredient
}
Step 2: Create minimal backend entry point
// backend/index.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
const app = new Hono();
// Unwrap Hono errors to see original error details
app.onError((err) => Promise.reject(err));
app.get("/api/health", (c) => c.json({ status: "ok" }));
export default app.fetch;
Step 3: Create minimal frontend HTML
<!-- frontend/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Recipes</title> <script src="https://cdn.twind.style" crossorigin></script> <script src="https://esm.town/v/std/catch"></script> </head> <body> <div id="root"></div> <script type="module" src="/frontend/index.tsx"></script> </body> </html>
Step 4: Create README files
<!-- README.md -->
# Recipes
A simple recipe management app built on Val Town.
## Features
- Save recipes from URLs (AI-powered extraction)
- View and manage recipe collection
- Generate shopping lists from selected recipes
## Development
Main entry: `backend/index.ts`
<!-- backend/README.md -->
# Backend
Hono-based API server.
- `index.ts` - Main entry point and route registration
- `database/` - SQLite schema and queries
- `routes/` - API route handlers
<!-- frontend/README.md -->
# Frontend
React 18.2.0 SPA with TailwindCSS (Twind).
- `index.html` - HTML shell
- `index.tsx` - React entry point
- `components/` - React components
<!-- shared/README.md -->
# Shared
Types and utilities used by both frontend and backend.
Note: Code here must work in both Deno (backend) and browser (frontend).
Do not use `Deno` keyword directly.
Step 5: Verify backend starts
Run: Deploy to Val Town and hit /api/health
Expected: {"status":"ok"}
Step 6: Commit
git add . git commit -m "feat: scaffold project structure with types and minimal backend"
Files:
- Create:
backend/database/migrations.ts - Create:
backend/database/queries.ts - Create:
backend/database/README.md - Modify:
backend/index.ts
Step 1: Create migrations file
// backend/database/migrations.ts
import { sqlite } from "https://esm.town/v/stevekrouse/sqlite/main.tsx";
const RECIPES_TABLE = "recipes_v1";
const INGREDIENTS_TABLE = "ingredients_v1";
const INSTRUCTIONS_TABLE = "instructions_v1";
export async function runMigrations() {
await sqlite.execute(`
CREATE TABLE IF NOT EXISTS ${RECIPES_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
source_url TEXT,
servings INTEGER NOT NULL DEFAULT 4,
prep_time_minutes INTEGER,
cook_time_minutes INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
await sqlite.execute(`
CREATE TABLE IF NOT EXISTS ${INGREDIENTS_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL,
name TEXT NOT NULL,
quantity TEXT NOT NULL,
unit TEXT,
notes TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (recipe_id) REFERENCES ${RECIPES_TABLE}(id) ON DELETE CASCADE
)
`);
await sqlite.execute(`
CREATE TABLE IF NOT EXISTS ${INSTRUCTIONS_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL,
step_number INTEGER NOT NULL,
text TEXT NOT NULL,
FOREIGN KEY (recipe_id) REFERENCES ${RECIPES_TABLE}(id) ON DELETE CASCADE
)
`);
// Index for faster recipe lookups
await sqlite.execute(`
CREATE INDEX IF NOT EXISTS idx_ingredients_recipe_id ON ${INGREDIENTS_TABLE}(recipe_id)
`);
await sqlite.execute(`
CREATE INDEX IF NOT EXISTS idx_instructions_recipe_id ON ${INSTRUCTIONS_TABLE}(recipe_id)
`);
}
export { RECIPES_TABLE, INGREDIENTS_TABLE, INSTRUCTIONS_TABLE };
Step 2: Create queries file
// backend/database/queries.ts
import { sqlite } from "https://esm.town/v/stevekrouse/sqlite/main.tsx";
import { RECIPES_TABLE, INGREDIENTS_TABLE, INSTRUCTIONS_TABLE } from "./migrations.ts";
import type { Recipe, Ingredient, Instruction, RecipeWithDetails } from "../../shared/types.ts";
export async function getAllRecipes(): Promise<Recipe[]> {
const { rows } = await sqlite.execute(`SELECT * FROM ${RECIPES_TABLE} ORDER BY created_at DESC`);
return rows as Recipe[];
}
export async function getRecipeById(id: number): Promise<RecipeWithDetails | null> {
const { rows: recipeRows } = await sqlite.execute(
`SELECT * FROM ${RECIPES_TABLE} WHERE id = ?`,
[id]
);
if (recipeRows.length === 0) return null;
const recipe = recipeRows[0] as Recipe;
const { rows: ingredientRows } = await sqlite.execute(
`SELECT * FROM ${INGREDIENTS_TABLE} WHERE recipe_id = ? ORDER BY sort_order`,
[id]
);
const { rows: instructionRows } = await sqlite.execute(
`SELECT * FROM ${INSTRUCTIONS_TABLE} WHERE recipe_id = ? ORDER BY step_number`,
[id]
);
return {
...recipe,
ingredients: ingredientRows as Ingredient[],
instructions: instructionRows as Instruction[],
};
}
export async function createRecipe(
recipe: Omit<Recipe, "id" | "created_at" | "updated_at">,
ingredients: Omit<Ingredient, "id" | "recipe_id">[],
instructions: Omit<Instruction, "id" | "recipe_id">[]
): Promise<number> {
const { lastInsertRowid } = await sqlite.execute(
`INSERT INTO ${RECIPES_TABLE} (title, source_url, servings, prep_time_minutes, cook_time_minutes)
VALUES (?, ?, ?, ?, ?)`,
[recipe.title, recipe.source_url, recipe.servings, recipe.prep_time_minutes, recipe.cook_time_minutes]
);
const recipeId = Number(lastInsertRowid);
for (const ing of ingredients) {
await sqlite.execute(
`INSERT INTO ${INGREDIENTS_TABLE} (recipe_id, name, quantity, unit, notes, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`,
[recipeId, ing.name, ing.quantity, ing.unit, ing.notes, ing.sort_order]
);
}
for (const inst of instructions) {
await sqlite.execute(
`INSERT INTO ${INSTRUCTIONS_TABLE} (recipe_id, step_number, text)
VALUES (?, ?, ?)`,
[recipeId, inst.step_number, inst.text]
);
}
return recipeId;
}
export async function deleteRecipe(id: number): Promise<boolean> {
// Delete ingredients and instructions first (cascade should handle, but be explicit)
await sqlite.execute(`DELETE FROM ${INGREDIENTS_TABLE} WHERE recipe_id = ?`, [id]);
await sqlite.execute(`DELETE FROM ${INSTRUCTIONS_TABLE} WHERE recipe_id = ?`, [id]);
const { rowsAffected } = await sqlite.execute(`DELETE FROM ${RECIPES_TABLE} WHERE id = ?`, [id]);
return rowsAffected > 0;
}
export async function getIngredientsByRecipeIds(recipeIds: number[]): Promise<(Ingredient & { recipe_title: string })[]> {
if (recipeIds.length === 0) return [];
const placeholders = recipeIds.map(() => "?").join(",");
const { rows } = await sqlite.execute(
`SELECT i.*, r.title as recipe_title
FROM ${INGREDIENTS_TABLE} i
JOIN ${RECIPES_TABLE} r ON i.recipe_id = r.id
WHERE i.recipe_id IN (${placeholders})
ORDER BY i.name`,
recipeIds
);
return rows as (Ingredient & { recipe_title: string })[];
}
Step 3: Create database README
<!-- backend/database/README.md -->
# Database
SQLite persistence layer.
## Tables
- `recipes_v1` - Core recipe metadata
- `ingredients_v1` - Recipe ingredients (normalized)
- `instructions_v1` - Step-by-step instructions
## Schema Changes
When modifying schema, create new table versions (e.g., `recipes_v2`)
rather than using ALTER TABLE. SQLite has limited ALTER support.
Step 4: Add migrations to backend startup
// backend/index.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { runMigrations } from "./database/migrations.ts";
const app = new Hono();
// Unwrap Hono errors to see original error details
app.onError((err) => Promise.reject(err));
// Run migrations on startup
await runMigrations();
app.get("/api/health", (c) => c.json({ status: "ok" }));
export default app.fetch;
Step 5: Verify migrations run
Run: Deploy and hit /api/health
Expected: No errors, tables created in SQLite
Step 6: Commit
git add backend/database/ backend/index.ts git commit -m "feat: add database schema and query functions"
Files:
- Create:
backend/routes/recipes.ts - Modify:
backend/index.ts
Step 1: Create recipes routes
// backend/routes/recipes.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { getAllRecipes, getRecipeById, createRecipe, deleteRecipe } from "../database/queries.ts";
import type { RecipeWithDetails } from "../../shared/types.ts";
const recipes = new Hono();
// GET /api/recipes - List all recipes
recipes.get("/", async (c) => {
const allRecipes = await getAllRecipes();
return c.json(allRecipes);
});
// GET /api/recipes/:id - Get single recipe with details
recipes.get("/:id", async (c) => {
const id = parseInt(c.req.param("id"), 10);
if (isNaN(id)) {
return c.json({ error: "Invalid recipe ID" }, 400);
}
const recipe = await getRecipeById(id);
if (!recipe) {
return c.json({ error: "Recipe not found" }, 404);
}
return c.json(recipe);
});
// POST /api/recipes - Create recipe (manual entry)
recipes.post("/", async (c) => {
const body = await c.req.json<RecipeWithDetails>();
if (!body.title || !body.ingredients || !body.instructions) {
return c.json({ error: "Missing required fields: title, ingredients, instructions" }, 400);
}
const recipeId = await createRecipe(
{
title: body.title,
source_url: body.source_url || null,
servings: body.servings || 4,
prep_time_minutes: body.prep_time_minutes || null,
cook_time_minutes: body.cook_time_minutes || null,
},
body.ingredients.map((ing, idx) => ({
name: ing.name,
quantity: ing.quantity,
unit: ing.unit || null,
notes: ing.notes || null,
sort_order: ing.sort_order ?? idx,
})),
body.instructions.map((inst, idx) => ({
step_number: inst.step_number ?? idx + 1,
text: inst.text,
}))
);
return c.json({ id: recipeId }, 201);
});
// DELETE /api/recipes/:id - Delete recipe
recipes.delete("/:id", async (c) => {
const id = parseInt(c.req.param("id"), 10);
if (isNaN(id)) {
return c.json({ error: "Invalid recipe ID" }, 400);
}
const deleted = await deleteRecipe(id);
if (!deleted) {
return c.json({ error: "Recipe not found" }, 404);
}
return c.json({ success: true });
});
export default recipes;
Step 2: Register routes in main app
// backend/index.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { runMigrations } from "./database/migrations.ts";
import recipes from "./routes/recipes.ts";
const app = new Hono();
// Unwrap Hono errors to see original error details
app.onError((err) => Promise.reject(err));
// Run migrations on startup
await runMigrations();
// API routes
app.get("/api/health", (c) => c.json({ status: "ok" }));
app.route("/api/recipes", recipes);
export default app.fetch;
Step 3: Test the API manually
Run: POST to /api/recipes with test data:
{ "title": "Test Recipe", "servings": 2, "ingredients": [{"name": "salt", "quantity": "1", "unit": "tsp"}], "instructions": [{"text": "Add salt"}] }
Expected: {"id": 1} with status 201
Run: GET /api/recipes
Expected: Array containing the test recipe
Run: GET /api/recipes/1
Expected: Full recipe with ingredients and instructions
Run: DELETE /api/recipes/1
Expected: {"success": true}
Step 4: Commit
git add backend/routes/recipes.ts backend/index.ts git commit -m "feat: add recipe CRUD API endpoints"
Files:
- Create:
backend/routes/ingest.ts - Modify:
backend/index.ts
Step 1: Create ingest route with URL fetching and AI extraction
// backend/routes/ingest.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { OpenAI } from "https://esm.town/v/std/openai";
import { createRecipe } from "../database/queries.ts";
import type { IngestRequest } from "../../shared/types.ts";
const ingest = new Hono();
const openai = new OpenAI();
// POST /api/ingest - Extract recipe from URL
ingest.post("/", async (c) => {
const body = await c.req.json<IngestRequest>();
if (!body.url) {
return c.json({ error: "URL is required" }, 400);
}
// Validate URL
let url: URL;
try {
url = new URL(body.url);
} catch {
return c.json({ error: "Invalid URL" }, 400);
}
// Fetch the page content
let pageContent: string;
try {
const response = await fetch(url.toString(), {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; RecipeBot/1.0)",
},
});
if (!response.ok) {
return c.json({ error: `Failed to fetch URL: ${response.status}` }, 400);
}
pageContent = await response.text();
} catch (err) {
return c.json({ error: `Failed to fetch URL: ${err.message}` }, 400);
}
// Strip HTML to reduce token usage - basic extraction
const textContent = pageContent
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 15000); // Limit content size
// Use AI to extract structured recipe data
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 2000,
messages: [
{
role: "system",
content: `You are a recipe extraction assistant. Extract recipe information from webpage text and return ONLY valid JSON matching this schema:
{
"title": "string",
"servings": number,
"prep_time_minutes": number or null,
"cook_time_minutes": number or null,
"ingredients": [
{"name": "string", "quantity": "string", "unit": "string or null", "notes": "string or null"}
],
"instructions": [
{"step_number": number, "text": "string"}
]
}
Rules:
- Extract ALL ingredients, even if formatting is inconsistent
- Parse quantities as strings to preserve fractions like "1/2"
- Unit should be null if not applicable (e.g., "2 eggs" has no unit)
- Notes capture preparation details like "diced", "room temperature"
- Instructions should be clear, numbered steps
- If you cannot find a recipe, return {"error": "No recipe found"}`,
},
{
role: "user",
content: `Extract the recipe from this webpage content:\n\n${textContent}`,
},
],
});
const aiResponse = completion.choices[0]?.message?.content;
if (!aiResponse) {
return c.json({ error: "AI extraction failed" }, 500);
}
// Parse AI response
let extracted;
try {
extracted = JSON.parse(aiResponse);
} catch {
return c.json({ error: "Failed to parse AI response" }, 500);
}
if (extracted.error) {
return c.json({ error: extracted.error }, 400);
}
// Save to database
const recipeId = await createRecipe(
{
title: extracted.title,
source_url: body.url,
servings: extracted.servings || 4,
prep_time_minutes: extracted.prep_time_minutes,
cook_time_minutes: extracted.cook_time_minutes,
},
extracted.ingredients.map((ing: any, idx: number) => ({
name: ing.name,
quantity: ing.quantity || "1",
unit: ing.unit || null,
notes: ing.notes || null,
sort_order: idx,
})),
extracted.instructions.map((inst: any, idx: number) => ({
step_number: inst.step_number || idx + 1,
text: inst.text,
}))
);
return c.json({ id: recipeId, title: extracted.title }, 201);
});
export default ingest;
Step 2: Register ingest route
// backend/index.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { runMigrations } from "./database/migrations.ts";
import recipes from "./routes/recipes.ts";
import ingest from "./routes/ingest.ts";
const app = new Hono();
// Unwrap Hono errors to see original error details
app.onError((err) => Promise.reject(err));
// Run migrations on startup
await runMigrations();
// API routes
app.get("/api/health", (c) => c.json({ status: "ok" }));
app.route("/api/recipes", recipes);
app.route("/api/ingest", ingest);
export default app.fetch;
Step 3: Test with a real Hello Fresh URL
Run: POST to /api/ingest:
{"url": "https://www.hellofresh.com/recipes/one-pan-southwest-chicken-5f9b5c5a4e3b6e5a5c5d5e5f"}
Expected: {"id": <number>, "title": "<recipe title>"} with status 201
Run: GET /api/recipes/<id>
Expected: Full extracted recipe with ingredients and instructions
Step 4: Commit
git add backend/routes/ingest.ts backend/index.ts git commit -m "feat: add AI-powered recipe extraction from URLs"
Files:
- Create:
backend/routes/shopping.ts - Modify:
backend/index.ts
Step 1: Create shopping list route
// backend/routes/shopping.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { getIngredientsByRecipeIds } from "../database/queries.ts";
import type { ShoppingListRequest, ShoppingListItem } from "../../shared/types.ts";
const shopping = new Hono();
// POST /api/shopping-list - Generate shopping list from recipe IDs
shopping.post("/", async (c) => {
const body = await c.req.json<ShoppingListRequest>();
if (!body.recipe_ids || body.recipe_ids.length === 0) {
return c.json({ error: "At least one recipe_id is required" }, 400);
}
const ingredients = await getIngredientsByRecipeIds(body.recipe_ids);
// Group ingredients by normalized name
const grouped = new Map<string, ShoppingListItem>();
for (const ing of ingredients) {
const normalizedName = ing.name.toLowerCase().trim();
if (grouped.has(normalizedName)) {
const existing = grouped.get(normalizedName)!;
// Simple aggregation: just list quantities, don't try to add them
existing.total_quantity += `, ${ing.quantity}${ing.unit ? " " + ing.unit : ""}`;
if (!existing.recipes.includes(ing.recipe_title)) {
existing.recipes.push(ing.recipe_title);
}
} else {
grouped.set(normalizedName, {
name: ing.name,
total_quantity: `${ing.quantity}${ing.unit ? " " + ing.unit : ""}`,
unit: ing.unit,
recipes: [ing.recipe_title],
});
}
}
const shoppingList = Array.from(grouped.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
return c.json(shoppingList);
});
export default shopping;
Step 2: Register shopping route
// backend/index.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { runMigrations } from "./database/migrations.ts";
import recipes from "./routes/recipes.ts";
import ingest from "./routes/ingest.ts";
import shopping from "./routes/shopping.ts";
const app = new Hono();
// Unwrap Hono errors to see original error details
app.onError((err) => Promise.reject(err));
// Run migrations on startup
await runMigrations();
// API routes
app.get("/api/health", (c) => c.json({ status: "ok" }));
app.route("/api/recipes", recipes);
app.route("/api/ingest", ingest);
app.route("/api/shopping-list", shopping);
export default app.fetch;
Step 3: Test shopping list generation
Run: Create 2 test recipes with overlapping ingredients, then POST to /api/shopping-list:
{"recipe_ids": [1, 2]}
Expected: Aggregated shopping list with ingredient sources
Step 4: Commit
git add backend/routes/shopping.ts backend/index.ts git commit -m "feat: add shopping list generation from recipes"
Files:
- Create:
backend/routes/static.ts - Modify:
backend/index.ts
Step 1: Create static file serving route
// backend/routes/static.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { readFile, serveFile } from "https://esm.town/v/std/utils/index.ts";
const staticRoutes = new Hono();
// Serve frontend and shared files
staticRoutes.get("/frontend/*", (c) => serveFile(c.req.path, import.meta.url));
staticRoutes.get("/shared/*", (c) => serveFile(c.req.path, import.meta.url));
// Serve index.html for root path
staticRoutes.get("/", async (c) => {
const html = await readFile("/frontend/index.html", import.meta.url);
return c.html(html);
});
export default staticRoutes;
Step 2: Add static routes to main app
// backend/index.ts
import { Hono } from "https://esm.sh/hono@4.4.2";
import { runMigrations } from "./database/migrations.ts";
import recipes from "./routes/recipes.ts";
import ingest from "./routes/ingest.ts";
import shopping from "./routes/shopping.ts";
import staticRoutes from "./routes/static.ts";
const app = new Hono();
// Unwrap Hono errors to see original error details
app.onError((err) => Promise.reject(err));
// Run migrations on startup
await runMigrations();
// API routes
app.get("/api/health", (c) => c.json({ status: "ok" }));
app.route("/api/recipes", recipes);
app.route("/api/ingest", ingest);
app.route("/api/shopping-list", shopping);
// Static file serving (must be after API routes)
app.route("", staticRoutes);
export default app.fetch;
Step 3: Verify static serving works
Run: Deploy and hit /
Expected: HTML page loads (may be blank until React is added)
Step 4: Commit
git add backend/routes/static.ts backend/index.ts git commit -m "feat: add static file serving for frontend"
Files:
- Create:
frontend/index.tsx - Create:
frontend/components/App.tsx - Create:
frontend/components/RecipeList.tsx - Create:
frontend/components/RecipeDetail.tsx
Step 1: Create React entry point
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/index.tsx
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
import App from "./components/App.tsx";
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
Step 2: Create App component with routing state
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/App.tsx
import { useState, useEffect } from "https://esm.sh/react@18.2.0";
import type { Recipe } from "../../shared/types.ts";
import RecipeList from "./RecipeList.tsx";
import RecipeDetail from "./RecipeDetail.tsx";
export default function App() {
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const fetchRecipes = async () => {
setLoading(true);
const res = await fetch("/api/recipes");
const data = await res.json();
setRecipes(data);
setLoading(false);
};
useEffect(() => {
fetchRecipes();
}, []);
if (loading) {
return (
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
<p class="text-gray-500">Loading...</p>
</div>
);
}
return (
<div class="min-h-screen bg-gray-50">
<header class="bg-white shadow-sm">
<div class="max-w-4xl mx-auto px-4 py-4">
<h1
class="text-2xl font-bold text-gray-900 cursor-pointer"
onClick={() => setSelectedRecipeId(null)}
>
Recipes
</h1>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 py-6">
{selectedRecipeId ? (
<RecipeDetail
recipeId={selectedRecipeId}
onBack={() => setSelectedRecipeId(null)}
onDelete={() => {
setSelectedRecipeId(null);
fetchRecipes();
}}
/>
) : (
<RecipeList
recipes={recipes}
onSelect={setSelectedRecipeId}
onRecipeAdded={fetchRecipes}
/>
)}
</main>
</div>
);
}
Step 3: Create RecipeList component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/RecipeList.tsx
import { useState } from "https://esm.sh/react@18.2.0";
import type { Recipe } from "../../shared/types.ts";
interface Props {
recipes: Recipe[];
onSelect: (id: number) => void;
onRecipeAdded: () => void;
}
export default function RecipeList({ recipes, onSelect, onRecipeAdded }: Props) {
const [url, setUrl] = useState("");
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleImport = async (e: React.FormEvent) => {
e.preventDefault();
if (!url.trim()) return;
setImporting(true);
setError(null);
try {
const res = await fetch("/api/ingest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Import failed");
}
setUrl("");
onRecipeAdded();
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
};
return (
<div>
{/* Import form */}
<form onSubmit={handleImport} class="mb-6">
<div class="flex gap-2">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="Paste recipe URL..."
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={importing}
/>
<button
type="submit"
disabled={importing || !url.trim()}
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{importing ? "Importing..." : "Import"}
</button>
</div>
{error && <p class="mt-2 text-red-600 text-sm">{error}</p>}
</form>
{/* Recipe list */}
{recipes.length === 0 ? (
<p class="text-gray-500 text-center py-8">
No recipes yet. Import one from a URL above!
</p>
) : (
<div class="grid gap-4">
{recipes.map((recipe) => (
<div
key={recipe.id}
onClick={() => onSelect(recipe.id)}
class="bg-white p-4 rounded-lg shadow-sm cursor-pointer hover:shadow-md transition-shadow"
>
<h2 class="text-lg font-semibold text-gray-900">{recipe.title}</h2>
<div class="mt-1 text-sm text-gray-500 flex gap-4">
<span>{recipe.servings} servings</span>
{recipe.prep_time_minutes && <span>Prep: {recipe.prep_time_minutes}min</span>}
{recipe.cook_time_minutes && <span>Cook: {recipe.cook_time_minutes}min</span>}
</div>
</div>
))}
</div>
)}
</div>
);
}
Step 4: Create RecipeDetail component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/RecipeDetail.tsx
import { useState, useEffect } from "https://esm.sh/react@18.2.0";
import type { RecipeWithDetails } from "../../shared/types.ts";
interface Props {
recipeId: number;
onBack: () => void;
onDelete: () => void;
}
export default function RecipeDetail({ recipeId, onBack, onDelete }: Props) {
const [recipe, setRecipe] = useState<RecipeWithDetails | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchRecipe = async () => {
setLoading(true);
const res = await fetch(`/api/recipes/${recipeId}`);
const data = await res.json();
setRecipe(data);
setLoading(false);
};
fetchRecipe();
}, [recipeId]);
const handleDelete = async () => {
if (!confirm("Delete this recipe?")) return;
await fetch(`/api/recipes/${recipeId}`, { method: "DELETE" });
onDelete();
};
if (loading) {
return <p class="text-gray-500">Loading recipe...</p>;
}
if (!recipe) {
return <p class="text-red-600">Recipe not found</p>;
}
return (
<div>
<button
onClick={onBack}
class="mb-4 text-blue-600 hover:underline"
>
← Back to recipes
</button>
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-between items-start mb-4">
<h2 class="text-2xl font-bold text-gray-900">{recipe.title}</h2>
<button
onClick={handleDelete}
class="text-red-600 hover:text-red-800 text-sm"
>
Delete
</button>
</div>
{recipe.source_url && (
<a
href={recipe.source_url}
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:underline text-sm block mb-4"
>
View original recipe →
</a>
)}
<div class="flex gap-4 text-sm text-gray-600 mb-6">
<span>{recipe.servings} servings</span>
{recipe.prep_time_minutes && <span>Prep: {recipe.prep_time_minutes} min</span>}
{recipe.cook_time_minutes && <span>Cook: {recipe.cook_time_minutes} min</span>}
</div>
<h3 class="text-lg font-semibold mb-2">Ingredients</h3>
<ul class="mb-6 space-y-1">
{recipe.ingredients.map((ing) => (
<li key={ing.id} class="text-gray-700">
<span class="font-medium">{ing.quantity}</span>
{ing.unit && <span> {ing.unit}</span>}
<span> {ing.name}</span>
{ing.notes && <span class="text-gray-500"> ({ing.notes})</span>}
</li>
))}
</ul>
<h3 class="text-lg font-semibold mb-2">Instructions</h3>
<ol class="space-y-3">
{recipe.instructions.map((inst) => (
<li key={inst.id} class="flex gap-3">
<span class="font-bold text-blue-600">{inst.step_number}.</span>
<span class="text-gray-700">{inst.text}</span>
</li>
))}
</ol>
</div>
</div>
);
}
Step 5: Verify frontend renders
Run: Deploy and hit /
Expected: App loads with recipe list and import form
Step 6: Commit
git add frontend/ git commit -m "feat: add React frontend with recipe list and detail views"
Files:
- Create:
frontend/components/ShoppingList.tsx - Modify:
frontend/components/App.tsx
Step 1: Create ShoppingList component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/ShoppingList.tsx
import { useState } from "https://esm.sh/react@18.2.0";
import type { Recipe, ShoppingListItem } from "../../shared/types.ts";
interface Props {
recipes: Recipe[];
onClose: () => void;
}
export default function ShoppingList({ recipes, onClose }: Props) {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [shoppingList, setShoppingList] = useState<ShoppingListItem[] | null>(null);
const [loading, setLoading] = useState(false);
const toggleRecipe = (id: number) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
};
const generateList = async () => {
if (selectedIds.size === 0) return;
setLoading(true);
const res = await fetch("/api/shopping-list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipe_ids: Array.from(selectedIds) }),
});
const data = await res.json();
setShoppingList(data);
setLoading(false);
};
return (
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col">
<div class="p-4 border-b flex justify-between items-center">
<h2 class="text-xl font-bold">Shopping List</h2>
<button onClick={onClose} class="text-gray-500 hover:text-gray-700">✕</button>
</div>
<div class="p-4 overflow-y-auto flex-1">
{!shoppingList ? (
<>
<p class="text-gray-600 mb-4">Select recipes to include:</p>
<div class="space-y-2 mb-4">
{recipes.map((recipe) => (
<label key={recipe.id} class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedIds.has(recipe.id)}
onChange={() => toggleRecipe(recipe.id)}
class="w-4 h-4"
/>
<span>{recipe.title}</span>
</label>
))}
</div>
<button
onClick={generateList}
disabled={selectedIds.size === 0 || loading}
class="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Generating..." : "Generate List"}
</button>
</>
) : (
<>
<button
onClick={() => setShoppingList(null)}
class="text-blue-600 hover:underline mb-4"
>
← Back to selection
</button>
<ul class="space-y-2">
{shoppingList.map((item, idx) => (
<li key={idx} class="flex justify-between items-start">
<div>
<span class="font-medium">{item.name}</span>
<span class="text-gray-600 ml-2">{item.total_quantity}</span>
</div>
<span class="text-xs text-gray-400">
{item.recipes.join(", ")}
</span>
</li>
))}
</ul>
</>
)}
</div>
</div>
</div>
);
}
Step 2: Add shopping list button to App
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/App.tsx
import { useState, useEffect } from "https://esm.sh/react@18.2.0";
import type { Recipe } from "../../shared/types.ts";
import RecipeList from "./RecipeList.tsx";
import RecipeDetail from "./RecipeDetail.tsx";
import ShoppingList from "./ShoppingList.tsx";
export default function App() {
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const [showShoppingList, setShowShoppingList] = useState(false);
const [loading, setLoading] = useState(true);
const fetchRecipes = async () => {
setLoading(true);
const res = await fetch("/api/recipes");
const data = await res.json();
setRecipes(data);
setLoading(false);
};
useEffect(() => {
fetchRecipes();
}, []);
if (loading) {
return (
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
<p class="text-gray-500">Loading...</p>
</div>
);
}
return (
<div class="min-h-screen bg-gray-50">
<header class="bg-white shadow-sm">
<div class="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1
class="text-2xl font-bold text-gray-900 cursor-pointer"
onClick={() => setSelectedRecipeId(null)}
>
Recipes
</h1>
{recipes.length > 0 && (
<button
onClick={() => setShowShoppingList(true)}
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Shopping List
</button>
)}
</div>
</header>
<main class="max-w-4xl mx-auto px-4 py-6">
{selectedRecipeId ? (
<RecipeDetail
recipeId={selectedRecipeId}
onBack={() => setSelectedRecipeId(null)}
onDelete={() => {
setSelectedRecipeId(null);
fetchRecipes();
}}
/>
) : (
<RecipeList
recipes={recipes}
onSelect={setSelectedRecipeId}
onRecipeAdded={fetchRecipes}
/>
)}
</main>
{showShoppingList && (
<ShoppingList
recipes={recipes}
onClose={() => setShowShoppingList(false)}
/>
)}
</div>
);
}
Step 3: Test shopping list flow
Run: Deploy, import 2+ recipes, click "Shopping List" Expected: Modal appears, can select recipes and generate combined list
Step 4: Commit
git add frontend/components/ git commit -m "feat: add shopping list UI with recipe selection"
Files:
- Modify:
frontend/index.html(add favicon) - Create:
frontend/favicon.svg
Step 1: Create favicon
<!-- frontend/favicon.svg --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <text y=".9em" font-size="90">🍳</text> </svg>
Step 2: Add favicon to HTML
<!-- frontend/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Recipes</title> <link rel="icon" href="/frontend/favicon.svg" type="image/svg+xml"> <script src="https://cdn.twind.style" crossorigin></script> <script src="https://esm.town/v/std/catch"></script> </head> <body> <div id="root"></div> <script type="module" src="/frontend/index.tsx"></script> </body> </html>
Step 3: End-to-end test
Run full test flow:
- Open app at
/ - Import a Hello Fresh recipe URL
- View the recipe details
- Import another recipe
- Generate a shopping list from both recipes
- Delete a recipe
Expected: All operations complete without errors
Step 4: Commit
git add frontend/ git commit -m "feat: add favicon and finalize frontend"
These are natural extensions when needed:
- Recipe editing - Edit imported recipes to fix AI extraction errors
- Tags/Categories - Add
recipe_tagstable for organization - Meal planning - Add
meal_planstable with date + recipe_id - Unit conversion - Smart aggregation that converts "1 cup + 8 oz" into "2 cups"
- PDF import - Add PDF parsing endpoint using a PDF extraction library
- Recipe scaling - Adjust servings and recalculate ingredient quantities