feat: v1 release - serving calculator, notes UI, tags, random recipe, confetti, favorites section, PWA icons, category icons, animations
This commit is contained in:
Binary file not shown.
Binary file not shown.
11
backend/src/db/migrations/002_category_icons.sql
Normal file
11
backend/src/db/migrations/002_category_icons.sql
Normal 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);
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user