feat: stabilization + recipe edit/create UI

This commit is contained in:
clawd
2026-02-18 09:55:39 +00:00
commit ee452efa6a
75 changed files with 15160 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import { getDb } from '../db/connection.js';
import sharp from 'sharp';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.resolve(__dirname, '../../data/images');
async function ensureDir(dir: string) {
await fs.promises.mkdir(dir, { recursive: true });
}
export async function saveRecipeImage(recipeId: string, buffer: Buffer) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
const dir = path.join(DATA_DIR, 'recipes', recipeId);
await ensureDir(dir);
await sharp(buffer).resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(dir, 'hero.webp'));
await sharp(buffer).resize({ width: 400, withoutEnlargement: true }).webp({ quality: 70 }).toFile(path.join(dir, 'hero_thumb.webp'));
const imagePath = `/images/recipes/${recipeId}/hero.webp`;
db.prepare('UPDATE recipes SET image_url = ?, updated_at = datetime(\'now\') WHERE id = ?').run(imagePath, recipeId);
return { image_url: imagePath, thumb_url: `/images/recipes/${recipeId}/hero_thumb.webp` };
}
export async function saveStepImage(recipeId: string, stepNumber: number, buffer: Buffer) {
const db = getDb();
const step = db.prepare('SELECT id FROM steps WHERE recipe_id = ? AND step_number = ?').get(recipeId, stepNumber) as any;
if (!step) return null;
const dir = path.join(DATA_DIR, 'recipes', recipeId, 'steps');
await ensureDir(dir);
const filename = `step_${stepNumber}.webp`;
await sharp(buffer).resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(dir, filename));
const imageUrl = `/images/recipes/${recipeId}/steps/${filename}`;
db.prepare('UPDATE steps SET image_url = ? WHERE id = ?').run(imageUrl, step.id);
return { image_url: imageUrl };
}
export async function downloadAndSaveImage(recipeId: string, url: string) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to download image: ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
return saveRecipeImage(recipeId, buffer);
}

View File

@@ -0,0 +1,26 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
export function listNotes(recipeId: string) {
return getDb().prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipeId);
}
export function createNote(recipeId: string, content: string) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
const id = ulid();
db.prepare('INSERT INTO notes (id, recipe_id, content) VALUES (?, ?, ?)').run(id, recipeId, content);
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
}
export function updateNote(id: string, content: string) {
const db = getDb();
const result = db.prepare('UPDATE notes SET content = ? WHERE id = ?').run(content, id);
if (result.changes === 0) return null;
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
}
export function deleteNote(id: string): boolean {
return getDb().prepare('DELETE FROM notes WHERE id = ?').run(id).changes > 0;
}

View File

@@ -0,0 +1,197 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
function ensureUniqueSlug(baseSlug: string, excludeId?: string): string {
const db = getDb();
let slug = baseSlug;
let i = 1;
while (true) {
const existing = excludeId
? db.prepare('SELECT id FROM recipes WHERE slug = ? AND id != ?').get(slug, excludeId)
: db.prepare('SELECT id FROM recipes WHERE slug = ?').get(slug);
if (!existing) return slug;
slug = `${baseSlug}-${i++}`;
}
}
export interface CreateRecipeInput {
title: string;
description?: string;
category_id?: string;
difficulty?: 'easy' | 'medium' | 'hard';
prep_time?: number;
cook_time?: number;
servings?: number;
image_url?: string;
source_url?: string;
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[];
steps?: { step_number: number; instruction: string; duration_minutes?: number }[];
}
function mapTimeFields(row: any) {
if (!row) return row;
row.prep_time_min = row.prep_time ?? null;
row.cook_time_min = row.cook_time ?? null;
row.total_time_min = row.total_time ?? null;
return row;
}
export function listRecipes(opts: {
page?: number; limit?: number; category_id?: string; category_slug?: string;
favorite?: boolean; difficulty?: string; maxTime?: number;
}) {
const db = getDb();
const page = opts.page || 1;
const limit = opts.limit || 20;
const offset = (page - 1) * limit;
const conditions: string[] = [];
const params: any[] = [];
if (opts.category_id) { conditions.push('r.category_id = ?'); params.push(opts.category_id); }
if (opts.category_slug) { conditions.push('c.slug = ?'); params.push(opts.category_slug); }
if (opts.favorite !== undefined) { conditions.push('r.is_favorite = ?'); params.push(opts.favorite ? 1 : 0); }
if (opts.difficulty) { conditions.push('r.difficulty = ?'); params.push(opts.difficulty); }
if (opts.maxTime) { conditions.push('r.total_time <= ?'); params.push(opts.maxTime); }
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where}`).get(...params) as any;
const rows = db.prepare(
`SELECT r.*, c.name as category_name, c.slug as category_slug FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`
).all(...params, limit, offset);
const data = rows.map(mapTimeFields);
return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) };
}
export function getRecipeBySlug(slug: string) {
const db = getDb();
const recipe = db.prepare(
'SELECT r.*, c.name as category_name FROM recipes r LEFT JOIN categories c ON r.category_id = c.id WHERE r.slug = ?'
).get(slug) as any;
if (!recipe) return null;
recipe.ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ? ORDER BY sort_order').all(recipe.id);
recipe.steps = db.prepare('SELECT * FROM steps WHERE recipe_id = ? ORDER BY step_number').all(recipe.id);
recipe.notes = db.prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipe.id);
const tagRows = db.prepare(
'SELECT t.name FROM tags t JOIN recipe_tags rt ON t.id = rt.tag_id WHERE rt.recipe_id = ?'
).all(recipe.id) as { name: string }[];
recipe.tags = tagRows.map(t => t.name);
mapTimeFields(recipe);
return recipe;
}
export function createRecipe(input: CreateRecipeInput) {
const db = getDb();
const id = ulid();
const slug = ensureUniqueSlug(slugify(input.title));
const totalTime = (input.prep_time || 0) + (input.cook_time || 0) || null;
const insertRecipe = db.prepare(`
INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertIngredient = db.prepare(`
INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertStep = db.prepare(`
INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes)
VALUES (?, ?, ?, ?, ?)
`);
const transaction = db.transaction(() => {
insertRecipe.run(id, input.title, slug, input.description || null, input.category_id || null,
input.difficulty || 'medium', input.prep_time || null, input.cook_time || null, totalTime,
input.servings || 4, input.image_url || null, input.source_url || null);
if (input.ingredients) {
for (let i = 0; i < input.ingredients.length; i++) {
const ing = input.ingredients[i];
insertIngredient.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i);
}
}
if (input.steps) {
for (const step of input.steps) {
insertStep.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
}
}
});
transaction();
return getRecipeBySlug(slug);
}
export function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
const db = getDb();
const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(id) as any;
if (!existing) return null;
const slug = input.title ? ensureUniqueSlug(slugify(input.title), id) : existing.slug;
const totalTime = ((input.prep_time ?? existing.prep_time) || 0) + ((input.cook_time ?? existing.cook_time) || 0) || null;
db.prepare(`
UPDATE recipes SET title=?, slug=?, description=?, category_id=?, difficulty=?, prep_time=?, cook_time=?, total_time=?, servings=?, image_url=?, source_url=?, updated_at=datetime('now')
WHERE id=?
`).run(
input.title ?? existing.title, slug, input.description ?? existing.description,
input.category_id ?? existing.category_id, input.difficulty ?? existing.difficulty,
input.prep_time ?? existing.prep_time, input.cook_time ?? existing.cook_time, totalTime,
input.servings ?? existing.servings, input.image_url ?? existing.image_url,
input.source_url ?? existing.source_url, id
);
return getRecipeBySlug(slug);
}
export function deleteRecipe(id: string): boolean {
const result = getDb().prepare('DELETE FROM recipes WHERE id = ?').run(id);
return result.changes > 0;
}
export function toggleFavorite(id: string) {
const db = getDb();
const recipe = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
if (!recipe) return null;
const newVal = recipe.is_favorite ? 0 : 1;
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
return { id, is_favorite: newVal };
}
export function searchRecipes(query: string) {
const db = getDb();
// Add * for prefix matching
const ftsQuery = query.trim().split(/\s+/).map(t => `"${t}"*`).join(' ');
return db.prepare(`
SELECT r.*, c.name as category_name
FROM recipes_fts fts
JOIN recipes r ON r.rowid = fts.rowid
LEFT JOIN categories c ON r.category_id = c.id
WHERE recipes_fts MATCH ?
ORDER BY rank
`).all(ftsQuery).map(mapTimeFields);
}
export function listCategories() {
return getDb().prepare('SELECT * FROM categories ORDER BY sort_order').all();
}
export function createCategory(name: string) {
const db = getDb();
const id = ulid();
const slug = name.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
const maxOrder = (db.prepare('SELECT MAX(sort_order) as m FROM categories').get() as any).m || 0;
db.prepare('INSERT INTO categories (id, name, slug, sort_order) VALUES (?, ?, ?, ?)').run(id, name, slug, maxOrder + 1);
return { id, name, slug, sort_order: maxOrder + 1 };
}

View File

@@ -0,0 +1,64 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
export function listItems() {
const db = getDb();
const items = db.prepare(`
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
ORDER BY si.checked, si.created_at DESC
`).all() as any[];
const grouped: Record<string, any> = {};
for (const item of items) {
const key = item.recipe_id || '_custom';
if (!grouped[key]) {
grouped[key] = { recipe_id: item.recipe_id, recipe_title: item.recipe_title || 'Eigene Einträge', items: [] };
}
grouped[key].items.push(item);
}
return Object.values(grouped);
}
export function addFromRecipe(recipeId: string) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
const ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ?').all(recipeId) as any[];
const insert = db.prepare('INSERT INTO shopping_items (id, name, amount, unit, recipe_id) VALUES (?, ?, ?, ?, ?)');
const added: any[] = [];
const txn = db.transaction(() => {
for (const ing of ingredients) {
const id = ulid();
insert.run(id, ing.name, ing.amount, ing.unit, recipeId);
added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, checked: 0 });
}
});
txn();
return added;
}
export function addItem(name: string, amount?: number, unit?: string) {
const db = getDb();
const id = ulid();
db.prepare('INSERT INTO shopping_items (id, name, amount, unit) VALUES (?, ?, ?, ?)').run(id, name, amount ?? null, unit ?? null);
return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id);
}
export function toggleCheck(id: string) {
const db = getDb();
const item = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id) as any;
if (!item) return null;
const newVal = item.checked ? 0 : 1;
db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id);
return { ...item, checked: newVal };
}
export function deleteItem(id: string): boolean {
return getDb().prepare('DELETE FROM shopping_items WHERE id = ?').run(id).changes > 0;
}
export function deleteChecked(): number {
return getDb().prepare('DELETE FROM shopping_items WHERE checked = 1').run().changes;
}

View File

@@ -0,0 +1,26 @@
import { getDb } from '../db/connection.js';
export function listTags() {
return getDb().prepare(`
SELECT t.*, COUNT(rt.recipe_id) as recipe_count
FROM tags t
LEFT JOIN recipe_tags rt ON t.id = rt.tag_id
GROUP BY t.id
ORDER BY t.name
`).all();
}
export function getRecipesByTag(tagName: string) {
const db = getDb();
const tag = db.prepare('SELECT * FROM tags WHERE name = ? OR slug = ?').get(tagName, tagName) as any;
if (!tag) return null;
const recipes = db.prepare(`
SELECT r.*, c.name as category_name
FROM recipes r
JOIN recipe_tags rt ON r.id = rt.recipe_id
LEFT JOIN categories c ON r.category_id = c.id
WHERE rt.tag_id = ?
ORDER BY r.created_at DESC
`).all(tag.id);
return { tag, recipes };
}