feat: v1 release - serving calculator, notes UI, tags, random recipe, confetti, favorites section, PWA icons, category icons, animations

This commit is contained in:
clawd
2026-02-18 10:23:22 +00:00
parent 60ca01fb94
commit de567f93db
17 changed files with 476 additions and 39 deletions

View File

@@ -0,0 +1,11 @@
-- Add category icons and Vegan category
UPDATE categories SET icon = '🧁' WHERE slug = 'backen';
UPDATE categories SET icon = '🎂' WHERE slug = 'torten';
UPDATE categories SET icon = '🥐' WHERE slug = 'fruehstueck';
UPDATE categories SET icon = '🍝' WHERE slug = 'mittag';
UPDATE categories SET icon = '🥘' WHERE slug = 'abend';
UPDATE categories SET icon = '🥨' WHERE slug = 'snacks';
UPDATE categories SET icon = '🍮' WHERE slug = 'desserts';
INSERT OR IGNORE INTO categories (id, name, slug, icon, sort_order) VALUES
('01VEGAN000000000000000000', 'Vegan', 'vegan', '🌱', 8);

View File

@@ -2,6 +2,12 @@ import { FastifyInstance } from 'fastify';
import * as svc from '../services/recipe.service.js';
export async function recipeRoutes(app: FastifyInstance) {
app.get('/api/recipes/random', async (request, reply) => {
const recipe = svc.getRandomRecipe();
if (!recipe) return reply.status(404).send({ error: 'No recipes found' });
return recipe;
});
app.get('/api/recipes/search', async (request) => {
const { q } = request.query as { q?: string };
if (!q) return { data: [], total: 0 };

View File

@@ -26,6 +26,7 @@ export const createRecipeSchema = z.object({
source_url: z.string().optional(),
ingredients: z.array(ingredientSchema).optional(),
steps: z.array(stepSchema).optional(),
tags: z.array(z.string()).optional(),
});
export const updateRecipeSchema = createRecipeSchema.partial();

View File

@@ -1,6 +1,22 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
function syncTags(db: any, recipeId: string, tags: string[]) {
db.prepare('DELETE FROM recipe_tags WHERE recipe_id = ?').run(recipeId);
for (const tagName of tags) {
const trimmed = tagName.trim();
if (!trimmed) continue;
const slug = trimmed.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
let tag = db.prepare('SELECT id FROM tags WHERE slug = ?').get(slug) as any;
if (!tag) {
const tagId = ulid();
db.prepare('INSERT INTO tags (id, name, slug) VALUES (?, ?, ?)').run(tagId, trimmed, slug);
tag = { id: tagId };
}
db.prepare('INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)').run(recipeId, tag.id);
}
}
function slugify(text: string): string {
return text
.toLowerCase()
@@ -34,6 +50,7 @@ export interface CreateRecipeInput {
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 }[];
tags?: string[];
}
function mapTimeFields(row: any) {
@@ -127,6 +144,10 @@ export function createRecipe(input: CreateRecipeInput) {
insertStep.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
}
}
if (input.tags && input.tags.length > 0) {
syncTags(db, id, input.tags);
}
});
transaction();
@@ -152,6 +173,30 @@ export function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
input.source_url ?? existing.source_url, id
);
// Replace ingredients if provided
if (input.ingredients) {
db.prepare('DELETE FROM ingredients WHERE recipe_id = ?').run(id);
for (let i = 0; i < input.ingredients.length; i++) {
const ing = input.ingredients[i];
db.prepare('INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i);
}
}
// Replace steps if provided
if (input.steps) {
db.prepare('DELETE FROM steps WHERE recipe_id = ?').run(id);
for (const step of input.steps) {
db.prepare('INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES (?, ?, ?, ?, ?)')
.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
}
}
// Sync tags if provided
if (input.tags) {
syncTags(db, id, input.tags);
}
return getRecipeBySlug(slug);
}
@@ -169,6 +214,13 @@ export function toggleFavorite(id: string) {
return { id, is_favorite: newVal };
}
export function getRandomRecipe() {
const db = getDb();
const recipe = db.prepare('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1').get() as any;
if (!recipe) return null;
return getRecipeBySlug(recipe.slug);
}
export function searchRecipes(query: string) {
const db = getDb();
// Add * for prefix matching