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';
|
import * as svc from '../services/recipe.service.js';
|
||||||
|
|
||||||
export async function recipeRoutes(app: FastifyInstance) {
|
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) => {
|
app.get('/api/recipes/search', async (request) => {
|
||||||
const { q } = request.query as { q?: string };
|
const { q } = request.query as { q?: string };
|
||||||
if (!q) return { data: [], total: 0 };
|
if (!q) return { data: [], total: 0 };
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const createRecipeSchema = z.object({
|
|||||||
source_url: z.string().optional(),
|
source_url: z.string().optional(),
|
||||||
ingredients: z.array(ingredientSchema).optional(),
|
ingredients: z.array(ingredientSchema).optional(),
|
||||||
steps: z.array(stepSchema).optional(),
|
steps: z.array(stepSchema).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateRecipeSchema = createRecipeSchema.partial();
|
export const updateRecipeSchema = createRecipeSchema.partial();
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { getDb } from '../db/connection.js';
|
import { getDb } from '../db/connection.js';
|
||||||
import { ulid } from 'ulid';
|
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 {
|
function slugify(text: string): string {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -34,6 +50,7 @@ export interface CreateRecipeInput {
|
|||||||
source_url?: string;
|
source_url?: string;
|
||||||
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[];
|
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[];
|
||||||
steps?: { step_number: number; instruction: string; duration_minutes?: number }[];
|
steps?: { step_number: number; instruction: string; duration_minutes?: number }[];
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapTimeFields(row: any) {
|
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);
|
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();
|
transaction();
|
||||||
@@ -152,6 +173,30 @@ export function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
|
|||||||
input.source_url ?? existing.source_url, id
|
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);
|
return getRecipeBySlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +214,13 @@ export function toggleFavorite(id: string) {
|
|||||||
return { id, is_favorite: newVal };
|
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) {
|
export function searchRecipes(query: string) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
// Add * for prefix matching
|
// Add * for prefix matching
|
||||||
|
|||||||
6
frontend/public/icon-192.svg
Normal file
6
frontend/public/icon-192.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||||
|
<rect width="192" height="192" rx="32" fill="#FBF8F5"/>
|
||||||
|
<circle cx="96" cy="100" r="60" fill="#F2D7DB"/>
|
||||||
|
<text x="96" y="118" text-anchor="middle" font-size="72">🧁</text>
|
||||||
|
<text x="96" y="172" text-anchor="middle" font-family="serif" font-weight="700" font-size="22" fill="#C4737E">Luna</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
6
frontend/public/icon-512.svg
Normal file
6
frontend/public/icon-512.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" rx="80" fill="#FBF8F5"/>
|
||||||
|
<circle cx="256" cy="250" r="160" fill="#F2D7DB"/>
|
||||||
|
<text x="256" y="295" text-anchor="middle" font-size="180">🧁</text>
|
||||||
|
<text x="256" y="450" text-anchor="middle" font-family="serif" font-weight="700" font-size="56" fill="#C4737E">Luna</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 404 B |
17
frontend/src/api/notes.ts
Normal file
17
frontend/src/api/notes.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
import type { Note } from './types'
|
||||||
|
|
||||||
|
export function fetchNotes(recipeId: string) {
|
||||||
|
return apiFetch<Note[]>(`/recipes/${recipeId}/notes`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNote(recipeId: string, content: string) {
|
||||||
|
return apiFetch<Note>(`/recipes/${recipeId}/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNote(noteId: string) {
|
||||||
|
return apiFetch<{ ok: boolean }>(`/notes/${noteId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ export function fetchRecipe(slug: string) {
|
|||||||
return apiFetch<Recipe>(`/recipes/${slug}`)
|
return apiFetch<Recipe>(`/recipes/${slug}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchRandomRecipe() {
|
||||||
|
return apiFetch<Recipe>('/recipes/random')
|
||||||
|
}
|
||||||
|
|
||||||
export function searchRecipes(q: string) {
|
export function searchRecipes(q: string) {
|
||||||
return apiFetch<PaginatedResponse<Recipe>>(`/recipes/search?q=${encodeURIComponent(q)}`)
|
return apiFetch<PaginatedResponse<Recipe>>(`/recipes/search?q=${encodeURIComponent(q)}`)
|
||||||
}
|
}
|
||||||
@@ -44,6 +48,7 @@ export interface RecipeFormData {
|
|||||||
source_url?: string
|
source_url?: string
|
||||||
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[]
|
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[]
|
||||||
steps?: { step_number: number; instruction: string; duration_minutes?: number }[]
|
steps?: { step_number: number; instruction: string; duration_minutes?: number }[]
|
||||||
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRecipe(data: RecipeFormData) {
|
export function createRecipe(data: RecipeFormData) {
|
||||||
|
|||||||
12
frontend/src/api/tags.ts
Normal file
12
frontend/src/api/tags.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
recipe_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTags() {
|
||||||
|
return apiFetch<Tag[]>('/tags')
|
||||||
|
}
|
||||||
56
frontend/src/components/ui/Confetti.tsx
Normal file
56
frontend/src/components/ui/Confetti.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const COLORS = ['#C4737E', '#D4A574', '#7BAE7F', '#C94C4C', '#F2D7DB', '#FFD700']
|
||||||
|
const EMOJIS = ['🎉', '🧁', '🎂', '✨', '💖', '🍰']
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
id: number
|
||||||
|
x: number
|
||||||
|
emoji: string
|
||||||
|
color: string
|
||||||
|
delay: number
|
||||||
|
duration: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Confetti({ active }: { active: boolean }) {
|
||||||
|
const [particles, setParticles] = useState<Particle[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const p: Particle[] = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
x: Math.random() * 100,
|
||||||
|
emoji: EMOJIS[Math.floor(Math.random() * EMOJIS.length)],
|
||||||
|
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||||
|
delay: Math.random() * 0.5,
|
||||||
|
duration: 1.5 + Math.random() * 1.5,
|
||||||
|
size: 16 + Math.random() * 16,
|
||||||
|
}))
|
||||||
|
setParticles(p)
|
||||||
|
const timer = setTimeout(() => setParticles([]), 3500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
if (particles.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 pointer-events-none z-[100] overflow-hidden">
|
||||||
|
{particles.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.id}
|
||||||
|
className="absolute animate-confetti-fall"
|
||||||
|
style={{
|
||||||
|
left: `${p.x}%`,
|
||||||
|
top: '-20px',
|
||||||
|
fontSize: `${p.size}px`,
|
||||||
|
animationDelay: `${p.delay}s`,
|
||||||
|
animationDuration: `${p.duration}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.emoji}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
import Masonry from 'react-masonry-css'
|
import Masonry from 'react-masonry-css'
|
||||||
import { fetchRecipes } from '../api/recipes'
|
import { Dices } from 'lucide-react'
|
||||||
|
import { fetchRecipes, fetchRandomRecipe } from '../api/recipes'
|
||||||
import { fetchCategories } from '../api/categories'
|
import { fetchCategories } from '../api/categories'
|
||||||
|
import { fetchTags } from '../api/tags'
|
||||||
import { RecipeCard } from '../components/recipe/RecipeCard'
|
import { RecipeCard } from '../components/recipe/RecipeCard'
|
||||||
|
import { RecipeCardSmall } from '../components/recipe/RecipeCardSmall'
|
||||||
import type { Recipe } from '../api/types'
|
import type { Recipe } from '../api/types'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
import { RecipeCardSkeleton } from '../components/ui/Skeleton'
|
import { RecipeCardSkeleton } from '../components/ui/Skeleton'
|
||||||
@@ -11,57 +16,163 @@ import { EmptyState } from '../components/ui/EmptyState'
|
|||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [activeCategory, setActiveCategory] = useState<string | undefined>()
|
const [activeCategory, setActiveCategory] = useState<string | undefined>()
|
||||||
|
const [activeTag, setActiveTag] = useState<string | undefined>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { data: categories } = useQuery({
|
const { data: categories } = useQuery({
|
||||||
queryKey: ['categories'],
|
queryKey: ['categories'],
|
||||||
queryFn: fetchCategories,
|
queryFn: fetchCategories,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: tags } = useQuery({
|
||||||
|
queryKey: ['tags'],
|
||||||
|
queryFn: fetchTags,
|
||||||
|
})
|
||||||
|
|
||||||
const { data: recipesData, isLoading } = useQuery({
|
const { data: recipesData, isLoading } = useQuery({
|
||||||
queryKey: ['recipes', { category: activeCategory }],
|
queryKey: ['recipes', { category: activeCategory }],
|
||||||
queryFn: () => fetchRecipes({ category: activeCategory, sort: 'newest' }),
|
queryFn: () => fetchRecipes({ category: activeCategory, sort: 'newest' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: favoritesData } = useQuery({
|
||||||
|
queryKey: ['recipes', { favorite: true }],
|
||||||
|
queryFn: () => fetchRecipes({ favorite: true }),
|
||||||
|
})
|
||||||
|
|
||||||
const recipes = recipesData?.data ?? []
|
const recipes = recipesData?.data ?? []
|
||||||
|
const totalCount = recipesData?.total ?? 0
|
||||||
|
const favorites = favoritesData?.data ?? []
|
||||||
|
|
||||||
|
// Filter by tag client-side if active
|
||||||
|
const filteredRecipes = activeTag
|
||||||
|
? recipes.filter(r => r.tags?.includes(activeTag))
|
||||||
|
: recipes
|
||||||
|
|
||||||
|
const handleRandomRecipe = async () => {
|
||||||
|
try {
|
||||||
|
const recipe = await fetchRandomRecipe()
|
||||||
|
if (recipe?.slug) navigate(`/recipe/${recipe.slug}`)
|
||||||
|
} catch {
|
||||||
|
// no recipes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTags = (tags || []).filter(t => t.recipe_count > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* TopBar */}
|
{/* TopBar */}
|
||||||
<div className="flex items-center justify-between px-4 py-4">
|
<div className="flex items-center justify-between px-4 py-4">
|
||||||
<h1 className="font-display text-2xl text-espresso">🍰 Luna Recipes</h1>
|
<h1 className="font-display text-2xl text-espresso">🧁 Luna Recipes</h1>
|
||||||
<div className="w-9 h-9 rounded-full bg-primary-light flex items-center justify-center text-sm">👤</div>
|
<div className="w-9 h-9 rounded-full bg-primary-light flex items-center justify-center text-sm">👤</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recipe Counter */}
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<motion.div
|
||||||
|
className="px-4 pb-3"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-warm-grey">
|
||||||
|
Du hast <span className="font-semibold text-primary">{totalCount}</span> Rezept{totalCount !== 1 ? 'e' : ''} gesammelt! 🎉
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Random Recipe Button */}
|
||||||
|
{totalCount > 1 && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<button
|
||||||
|
onClick={handleRandomRecipe}
|
||||||
|
className="w-full bg-gradient-to-r from-primary to-secondary text-white px-4 py-3 rounded-2xl font-medium text-sm flex items-center justify-center gap-2 shadow-sm active:scale-[0.98] transition-transform min-h-[44px]"
|
||||||
|
>
|
||||||
|
<Dices size={18} />
|
||||||
|
Was koche ich heute? 🎲
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Favorites Section */}
|
||||||
|
{favorites.length > 0 && (
|
||||||
|
<div className="pb-4">
|
||||||
|
<h2 className="font-display text-lg text-espresso px-4 mb-2">❤️ Favoriten</h2>
|
||||||
|
<div className="flex gap-3 px-4 overflow-x-auto scrollbar-hide pb-1">
|
||||||
|
{favorites.map((recipe, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={recipe.id}
|
||||||
|
className="min-w-[260px] max-w-[260px]"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
>
|
||||||
|
<RecipeCardSmall recipe={recipe} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Category Chips */}
|
{/* Category Chips */}
|
||||||
{categories && (Array.isArray(categories) ? categories : []).length > 0 && (
|
{categories && (Array.isArray(categories) ? categories : []).length > 0 && (
|
||||||
<div className="flex gap-2 px-4 pb-4 overflow-x-auto scrollbar-hide">
|
<div className="flex gap-2 px-4 pb-2 overflow-x-auto scrollbar-hide">
|
||||||
<Badge active={!activeCategory} onClick={() => setActiveCategory(undefined)}>
|
<Badge active={!activeCategory} onClick={() => { setActiveCategory(undefined); setActiveTag(undefined) }}>
|
||||||
Alle
|
Alle
|
||||||
</Badge>
|
</Badge>
|
||||||
{(Array.isArray(categories) ? categories : []).map((cat) => (
|
{(Array.isArray(categories) ? categories : []).map((cat) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
active={activeCategory === cat.slug}
|
active={activeCategory === cat.slug}
|
||||||
onClick={() => setActiveCategory(activeCategory === cat.slug ? undefined : cat.slug)}
|
onClick={() => { setActiveCategory(activeCategory === cat.slug ? undefined : cat.slug); setActiveTag(undefined) }}
|
||||||
>
|
>
|
||||||
{cat.icon || ''} {cat.name}
|
{cat.icon ? `${cat.icon} ` : ''}{cat.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tag Chips */}
|
||||||
|
{activeTags.length > 0 && (
|
||||||
|
<div className="flex gap-2 px-4 pb-4 overflow-x-auto scrollbar-hide">
|
||||||
|
{activeTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => setActiveTag(activeTag === tag.name ? undefined : tag.name)}
|
||||||
|
className={`inline-block px-3 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors whitespace-nowrap ${
|
||||||
|
activeTag === tag.name
|
||||||
|
? 'bg-secondary text-white'
|
||||||
|
: 'bg-sand/50 text-warm-grey'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
#{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recipe Grid */}
|
{/* Recipe Grid */}
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Masonry breakpointCols={2} className="flex -ml-4" columnClassName="pl-4">
|
<Masonry breakpointCols={2} className="flex -ml-4" columnClassName="pl-4">
|
||||||
{Array.from({ length: 6 }).map((_, i) => <RecipeCardSkeleton key={i} />)}
|
{Array.from({ length: 6 }).map((_, i) => <RecipeCardSkeleton key={i} />)}
|
||||||
</Masonry>
|
</Masonry>
|
||||||
) : recipes.length === 0 ? (
|
) : filteredRecipes.length === 0 ? (
|
||||||
<EmptyState title="Noch keine Rezepte" description="Füge dein erstes Rezept hinzu!" />
|
<EmptyState
|
||||||
|
title="Noch keine Rezepte"
|
||||||
|
description="Füge dein erstes Rezept hinzu und starte deine Sammlung! 💖"
|
||||||
|
icon="🧁"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Masonry breakpointCols={2} className="flex -ml-4" columnClassName="pl-4">
|
<Masonry breakpointCols={2} className="flex -ml-4" columnClassName="pl-4">
|
||||||
{recipes.map((recipe: Recipe) => (
|
{filteredRecipes.map((recipe: Recipe, i: number) => (
|
||||||
<RecipeCard key={recipe.id} recipe={recipe} />
|
<motion.div
|
||||||
|
key={recipe.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.04, duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<RecipeCard recipe={recipe} />
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</Masonry>
|
</Masonry>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
|
|||||||
import { ArrowLeft, Plus, Trash2, Camera, X, GripVertical, Link, Loader2 } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Camera, X, GripVertical, Link, Loader2 } from 'lucide-react'
|
||||||
import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage, fetchOgPreview } from '../api/recipes'
|
import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage, fetchOgPreview } from '../api/recipes'
|
||||||
import { fetchCategories } from '../api/categories'
|
import { fetchCategories } from '../api/categories'
|
||||||
|
import { Confetti } from '../components/ui/Confetti'
|
||||||
import type { RecipeFormData } from '../api/recipes'
|
import type { RecipeFormData } from '../api/recipes'
|
||||||
import type { Ingredient, Step } from '../api/types'
|
import type { Ingredient, Step } from '../api/types'
|
||||||
|
|
||||||
@@ -60,8 +61,10 @@ export function RecipeFormPage() {
|
|||||||
const [steps, setSteps] = useState<StepRow[]>([
|
const [steps, setSteps] = useState<StepRow[]>([
|
||||||
{ key: nextKey(), instruction: '' },
|
{ key: nextKey(), instruction: '' },
|
||||||
])
|
])
|
||||||
|
const [tagsInput, setTagsInput] = useState('')
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [fetchingOg, setFetchingOg] = useState(false)
|
const [fetchingOg, setFetchingOg] = useState(false)
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
|
|
||||||
// Populate form when editing
|
// Populate form when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,6 +79,9 @@ export function RecipeFormPage() {
|
|||||||
setSourceUrl((recipe as any).source_url || '')
|
setSourceUrl((recipe as any).source_url || '')
|
||||||
setImageUrl(recipe.image_url || '')
|
setImageUrl(recipe.image_url || '')
|
||||||
if (recipe.image_url) setImagePreview(recipe.image_url)
|
if (recipe.image_url) setImagePreview(recipe.image_url)
|
||||||
|
if (recipe.tags && recipe.tags.length > 0) {
|
||||||
|
setTagsInput(recipe.tags.join(', '))
|
||||||
|
}
|
||||||
if (recipe.ingredients && recipe.ingredients.length > 0) {
|
if (recipe.ingredients && recipe.ingredients.length > 0) {
|
||||||
setIngredients(recipe.ingredients.map(ing => ({
|
setIngredients(recipe.ingredients.map(ing => ({
|
||||||
key: nextKey(),
|
key: nextKey(),
|
||||||
@@ -112,8 +118,14 @@ export function RecipeFormPage() {
|
|||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: ['recipes'] })
|
qc.invalidateQueries({ queryKey: ['recipes'] })
|
||||||
qc.invalidateQueries({ queryKey: ['recipe', slug] })
|
qc.invalidateQueries({ queryKey: ['recipe', slug] })
|
||||||
toast.success(isEdit ? 'Rezept aktualisiert!' : 'Rezept erstellt!')
|
if (!isEdit) {
|
||||||
navigate(saved?.slug ? `/recipe/${saved.slug}` : '/')
|
setShowConfetti(true)
|
||||||
|
toast.success('Rezept erstellt! 🎉')
|
||||||
|
setTimeout(() => navigate(saved?.slug ? `/recipe/${saved.slug}` : '/'), 1500)
|
||||||
|
} else {
|
||||||
|
toast.success('Rezept aktualisiert!')
|
||||||
|
navigate(saved?.slug ? `/recipe/${saved.slug}` : '/')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Fehler beim Speichern'),
|
onError: () => toast.error('Fehler beim Speichern'),
|
||||||
})
|
})
|
||||||
@@ -159,6 +171,7 @@ export function RecipeFormPage() {
|
|||||||
step_number: idx + 1,
|
step_number: idx + 1,
|
||||||
instruction: s.instruction.trim(),
|
instruction: s.instruction.trim(),
|
||||||
})),
|
})),
|
||||||
|
tags: tagsInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
}
|
}
|
||||||
saveMutation.mutate(data)
|
saveMutation.mutate(data)
|
||||||
}
|
}
|
||||||
@@ -204,6 +217,7 @@ export function RecipeFormPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-cream">
|
<div className="min-h-screen bg-cream">
|
||||||
|
<Confetti active={showConfetti} />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center gap-3">
|
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center gap-3">
|
||||||
<button onClick={() => navigate(-1)} className="p-2 -ml-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
|
<button onClick={() => navigate(-1)} className="p-2 -ml-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
|
||||||
@@ -379,6 +393,26 @@ export function RecipeFormPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Tags</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={e => setTagsInput(e.target.value)}
|
||||||
|
placeholder="z.B. Sommer, Schnell, Omas Rezept"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-warm-grey mt-1">Komma-getrennt eingeben</p>
|
||||||
|
{tagsInput && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{tagsInput.split(',').map(t => t.trim()).filter(Boolean).map((tag, i) => (
|
||||||
|
<span key={i} className="text-xs bg-sand/50 text-warm-grey px-2 py-0.5 rounded-full">#{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Ingredients */}
|
{/* Ingredients */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router'
|
import { useParams, useNavigate, Link } from 'react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil } from 'lucide-react'
|
import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil, Minus, Plus, Send, Trash2 } from 'lucide-react'
|
||||||
import { fetchRecipe, toggleFavorite } from '../api/recipes'
|
import { fetchRecipe, toggleFavorite } from '../api/recipes'
|
||||||
import { addFromRecipe } from '../api/shopping'
|
import { addFromRecipe } from '../api/shopping'
|
||||||
|
import { createNote, deleteNote } from '../api/notes'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
import { Skeleton } from '../components/ui/Skeleton'
|
import { Skeleton } from '../components/ui/Skeleton'
|
||||||
|
|
||||||
@@ -13,6 +16,8 @@ export function RecipePage() {
|
|||||||
const { slug } = useParams<{ slug: string }>()
|
const { slug } = useParams<{ slug: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const [servingScale, setServingScale] = useState<number | null>(null)
|
||||||
|
const [noteText, setNoteText] = useState('')
|
||||||
|
|
||||||
const { data: recipe, isLoading } = useQuery({
|
const { data: recipe, isLoading } = useQuery({
|
||||||
queryKey: ['recipe', slug],
|
queryKey: ['recipe', slug],
|
||||||
@@ -38,6 +43,24 @@ export function RecipePage() {
|
|||||||
onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'),
|
onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const noteMutation = useMutation({
|
||||||
|
mutationFn: (content: string) => createNote(recipe!.id, content),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['recipe', slug] })
|
||||||
|
setNoteText('')
|
||||||
|
toast.success('Notiz gespeichert! 📝')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Fehler beim Speichern der Notiz'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteNoteMutation = useMutation({
|
||||||
|
mutationFn: (noteId: string) => deleteNote(noteId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['recipe', slug] })
|
||||||
|
toast.success('Notiz gelöscht')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
@@ -50,9 +73,20 @@ export function RecipePage() {
|
|||||||
|
|
||||||
if (!recipe) return <div className="p-4">Rezept nicht gefunden.</div>
|
if (!recipe) return <div className="p-4">Rezept nicht gefunden.</div>
|
||||||
|
|
||||||
|
const originalServings = recipe.servings || 4
|
||||||
|
const currentServings = servingScale ?? originalServings
|
||||||
|
const scaleFactor = currentServings / originalServings
|
||||||
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
|
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
|
||||||
const gradient = gradients[recipe.title.length % gradients.length]
|
const gradient = gradients[recipe.title.length % gradients.length]
|
||||||
|
|
||||||
|
const scaleAmount = (amount?: number) => {
|
||||||
|
if (!amount) return ''
|
||||||
|
const scaled = amount * scaleFactor
|
||||||
|
// Nice formatting
|
||||||
|
if (scaled === Math.round(scaled)) return String(Math.round(scaled))
|
||||||
|
return scaled.toFixed(1).replace(/\.0$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// Group ingredients
|
// Group ingredients
|
||||||
const ingredientGroups = (recipe.ingredients || []).reduce<Record<string, typeof recipe.ingredients>>((acc, ing) => {
|
const ingredientGroups = (recipe.ingredients || []).reduce<Record<string, typeof recipe.ingredients>>((acc, ing) => {
|
||||||
const group = ing.group_name || 'Zutaten'
|
const group = ing.group_name || 'Zutaten'
|
||||||
@@ -62,7 +96,7 @@ export function RecipePage() {
|
|||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{recipe.image_url ? (
|
{recipe.image_url ? (
|
||||||
@@ -72,14 +106,14 @@ export function RecipePage() {
|
|||||||
<span className="text-6xl">🍰</span>
|
<span className="text-6xl">🍰</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => navigate(-1)} className="absolute top-4 left-4 bg-surface/80 backdrop-blur-sm rounded-full p-2">
|
<button onClick={() => navigate(-1)} className="absolute top-4 left-4 bg-surface/80 backdrop-blur-sm rounded-full p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
|
||||||
<ArrowLeft size={20} className="text-espresso" />
|
<ArrowLeft size={20} className="text-espresso" />
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute top-4 right-4 flex gap-2">
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
<Link to={`/recipe/${recipe.slug}/edit`} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
|
<Link to={`/recipe/${recipe.slug}/edit`} className="bg-surface/80 backdrop-blur-sm rounded-full p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
|
||||||
<Pencil size={20} className="text-espresso" />
|
<Pencil size={20} className="text-espresso" />
|
||||||
</Link>
|
</Link>
|
||||||
<button onClick={() => favMutation.mutate()} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
|
<button onClick={() => favMutation.mutate()} className="bg-surface/80 backdrop-blur-sm rounded-full p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
|
||||||
<Heart size={20} className={recipe.is_favorite ? 'fill-primary text-primary' : 'text-espresso'} />
|
<Heart size={20} className={recipe.is_favorite ? 'fill-primary text-primary' : 'text-espresso'} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,13 +124,14 @@ export function RecipePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl text-espresso">{recipe.title}</h1>
|
<h1 className="font-display text-2xl text-espresso">{recipe.title}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||||
{recipe.category_name && <Badge>{recipe.category_name}</Badge>}
|
{recipe.category_name ? (
|
||||||
|
<Badge>{recipe.category_name}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge>🍽️ Allgemein</Badge>
|
||||||
|
)}
|
||||||
{totalTime > 0 && (
|
{totalTime > 0 && (
|
||||||
<span className="flex items-center gap-1 text-warm-grey text-sm"><Clock size={14} /> {totalTime} min</span>
|
<span className="flex items-center gap-1 text-warm-grey text-sm"><Clock size={14} /> {totalTime} min</span>
|
||||||
)}
|
)}
|
||||||
{recipe.servings && (
|
|
||||||
<span className="flex items-center gap-1 text-warm-grey text-sm"><Users size={14} /> {recipe.servings} Portionen</span>
|
|
||||||
)}
|
|
||||||
{recipe.difficulty && (
|
{recipe.difficulty && (
|
||||||
<span className="flex items-center gap-1 text-warm-grey text-sm"><ChefHat size={14} /> {recipe.difficulty}</span>
|
<span className="flex items-center gap-1 text-warm-grey text-sm"><ChefHat size={14} /> {recipe.difficulty}</span>
|
||||||
)}
|
)}
|
||||||
@@ -113,6 +148,38 @@ export function RecipePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Serving Calculator */}
|
||||||
|
<div className="bg-primary-light/30 rounded-2xl p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium text-espresso">
|
||||||
|
<Users size={16} /> Portionen
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setServingScale(Math.max(1, currentServings - 1))}
|
||||||
|
className="w-9 h-9 rounded-full bg-surface border border-sand flex items-center justify-center text-espresso active:bg-sand transition-colors"
|
||||||
|
>
|
||||||
|
<Minus size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="font-display text-xl text-espresso min-w-[2ch] text-center">{currentServings}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setServingScale(currentServings + 1)}
|
||||||
|
className="w-9 h-9 rounded-full bg-surface border border-sand flex items-center justify-center text-espresso active:bg-sand transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{servingScale !== null && servingScale !== originalServings && (
|
||||||
|
<button
|
||||||
|
onClick={() => setServingScale(null)}
|
||||||
|
className="text-xs text-primary mt-2 underline"
|
||||||
|
>
|
||||||
|
Original ({originalServings}) zurücksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Ingredients */}
|
{/* Ingredients */}
|
||||||
{Object.keys(ingredientGroups).length > 0 && (
|
{Object.keys(ingredientGroups).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@@ -124,12 +191,18 @@ export function RecipePage() {
|
|||||||
)}
|
)}
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{items!.map((ing, i) => (
|
{items!.map((ing, i) => (
|
||||||
<li key={i} className="flex gap-2 text-sm text-espresso py-1 border-b border-sand/50">
|
<motion.li
|
||||||
|
key={i}
|
||||||
|
className="flex gap-2 text-sm text-espresso py-1 border-b border-sand/50"
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.03 }}
|
||||||
|
>
|
||||||
<span className="text-warm-grey min-w-[60px]">
|
<span className="text-warm-grey min-w-[60px]">
|
||||||
{ing.amount ? `${ing.amount} ${ing.unit || ''}`.trim() : ''}
|
{ing.amount ? `${scaleAmount(ing.amount)} ${ing.unit || ''}`.trim() : ''}
|
||||||
</span>
|
</span>
|
||||||
<span>{ing.name}</span>
|
<span>{ing.name}</span>
|
||||||
</li>
|
</motion.li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +228,13 @@ export function RecipePage() {
|
|||||||
<h2 className="font-display text-lg text-espresso mb-3">Zubereitung</h2>
|
<h2 className="font-display text-lg text-espresso mb-3">Zubereitung</h2>
|
||||||
<ol className="space-y-4">
|
<ol className="space-y-4">
|
||||||
{recipe.steps.map((step, i) => (
|
{recipe.steps.map((step, i) => (
|
||||||
<li key={i} className="flex gap-3">
|
<motion.li
|
||||||
|
key={i}
|
||||||
|
className="flex gap-3"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
>
|
||||||
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-primary text-white text-sm flex items-center justify-center font-medium">
|
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-primary text-white text-sm flex items-center justify-center font-medium">
|
||||||
{step.step_number || i + 1}
|
{step.step_number || i + 1}
|
||||||
</span>
|
</span>
|
||||||
@@ -167,27 +246,58 @@ export function RecipePage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</motion.li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{recipe.notes && recipe.notes.length > 0 && (
|
<div>
|
||||||
<div>
|
<h2 className="font-display text-lg text-espresso mb-3">Notizen</h2>
|
||||||
<h2 className="font-display text-lg text-espresso mb-3">Notizen</h2>
|
|
||||||
<div className="space-y-2">
|
{/* Existing notes */}
|
||||||
|
{recipe.notes && recipe.notes.length > 0 && (
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
{recipe.notes.map((note) => (
|
{recipe.notes.map((note) => (
|
||||||
<div key={note.id} className="bg-primary-light/30 rounded-xl p-3 text-sm text-espresso">
|
<motion.div
|
||||||
📝 {note.content}
|
key={note.id}
|
||||||
</div>
|
className="bg-primary-light/30 rounded-xl p-3 text-sm text-espresso flex items-start gap-2"
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
>
|
||||||
|
<span className="flex-1">📝 {note.content}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteNoteMutation.mutate(note.id)}
|
||||||
|
className="text-warm-grey hover:text-berry p-1 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
{/* Add note */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && noteText.trim()) noteMutation.mutate(noteText.trim()) }}
|
||||||
|
placeholder="Notiz hinzufügen..."
|
||||||
|
className="flex-1 bg-surface border border-sand rounded-xl px-4 py-3 text-sm text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => noteText.trim() && noteMutation.mutate(noteText.trim())}
|
||||||
|
disabled={!noteText.trim() || noteMutation.isPending}
|
||||||
|
className="bg-primary text-white rounded-xl px-4 min-h-[44px] min-w-[44px] flex items-center justify-center disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
--color-sage: #7BAE7F;
|
--color-sage: #7BAE7F;
|
||||||
--color-berry: #C94C4C;
|
--color-berry: #C94C4C;
|
||||||
--color-sand: #E8E0D8;
|
--color-sand: #E8E0D8;
|
||||||
|
--color-berry-red: #C94C4C;
|
||||||
--font-display: 'Playfair Display', serif;
|
--font-display: 'Playfair Display', serif;
|
||||||
--font-sans: 'Inter', system-ui, sans-serif;
|
--font-sans: 'Inter', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
@@ -30,3 +31,12 @@ body {
|
|||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes confetti-fall {
|
||||||
|
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
|
||||||
|
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-confetti-fall {
|
||||||
|
animation: confetti-fall 2s ease-in forwards;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export default defineConfig({
|
|||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
icons: [
|
icons: [
|
||||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
{ src: '/icon-192.svg', sizes: '192x192', type: 'image/svg+xml' },
|
||||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
{ src: '/icon-512.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user