feat(recipes): add recipe module with local-first data, encryption, and card UI

New "Rezepte" module following the established scoped-CSS + theme-token
pattern. Includes Dexie schema (v8), encryption for user-typed fields,
3 German seed recipes, search/filter/tag UI, inline creation form, and
expanded detail view with ingredients checklist and numbered steps.

Also documents the frontend styling inconsistency (13/40 ListViews use
Tailwind instead of scoped CSS) in docs/optimizable/ for future cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 16:45:41 +02:00
parent 65160024f7
commit f5b9d0a31f
17 changed files with 1453 additions and 0 deletions

View file

@ -48,6 +48,7 @@ import {
PersonSimpleCircle,
BookOpen,
Books,
CookingPot,
} from '@mana/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
@ -844,3 +845,24 @@ registerApp({
list: { load: () => import('$lib/modules/drink/ListView.svelte') },
},
});
registerApp({
id: 'recipes',
name: 'Rezepte',
color: '#f97316',
icon: CookingPot,
views: {
list: { load: () => import('$lib/modules/recipes/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-recipe',
label: 'Neues Rezept',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'recipes', action: 'new' } })
),
},
],
});

View file

@ -410,6 +410,14 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// plaintext for indexing and daily aggregation queries.
drinkEntries: { enabled: true, fields: ['name', 'note'] },
drinkPresets: { enabled: true, fields: ['name'] },
// ─── Recipes ─────────────────────────────────────────────
// User-typed content (title, description, ingredients list, steps)
// encrypted. `ingredients` is Ingredient[] and `steps` is string[] —
// aes.ts JSON-stringifies before wrap, same as nutriphi's `foods`.
// Plaintext (intentional): difficulty, tags, servings, times,
// isFavorite, photo refs — needed for indexing and filtering.
recipes: { enabled: true, fields: ['title', 'description', 'ingredients', 'steps'] },
};
/**

View file

@ -392,6 +392,12 @@ db.version(7).stores({
drinkPresets: 'id, order, drinkType, isArchived',
});
// Schema version 8 — adds the Recipes module.
// *tags is a Dexie MultiEntry index for tag-based filtering.
db.version(8).stores({
recipes: 'id, difficulty, isFavorite, *tags',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -89,6 +89,7 @@ import { newsModuleConfig } from '$lib/modules/news/module.config';
import { bodyModuleConfig } from '$lib/modules/body/module.config';
import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
import { drinkModuleConfig } from '$lib/modules/drink/module.config';
import { recipesModuleConfig } from '$lib/modules/recipes/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
manaCoreConfig,
@ -133,6 +134,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
bodyModuleConfig,
firstsModuleConfig,
drinkModuleConfig,
recipesModuleConfig,
];
// ─── Derived Maps ──────────────────────────────────────────

View file

@ -29,6 +29,7 @@ import { NOTES_GUEST_SEED } from '$lib/modules/notes/collections';
import { TIMES_GUEST_SEED } from '$lib/modules/times/collections';
import { PLANTS_GUEST_SEED } from '$lib/modules/plants/collections';
import { DRINK_GUEST_SEED } from '$lib/modules/drink/collections';
import { RECIPES_GUEST_SEED } from '$lib/modules/recipes/collections';
/**
* Flat list of { tableName, rows } entries. Only modules with non-empty
@ -62,6 +63,7 @@ register(NOTES_GUEST_SEED);
register(TIMES_GUEST_SEED);
register(PLANTS_GUEST_SEED);
register(DRINK_GUEST_SEED);
register(RECIPES_GUEST_SEED);
/**
* Seed all module guest data into empty tables. Idempotent: tables

View file

@ -0,0 +1,883 @@
<!--
Recipes — ListView
Card grid with search, tag filter, and inline creation.
-->
<script lang="ts">
import {
useAllRecipes,
searchRecipes,
filterByTag,
filterByDifficulty,
getAllTags,
getTotalTime,
formatTime,
} from './queries';
import { recipesStore } from './stores/recipes.svelte';
import {
DIFFICULTY_LABELS,
DIFFICULTY_COLORS,
DEFAULT_TAGS,
UNIT_OPTIONS,
type Difficulty,
type Ingredient,
} from './types';
import type { Recipe } from './types';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { Trash, Heart, Copy, Star } from '@mana/shared-icons';
let recipes$ = useAllRecipes();
let recipes = $derived(recipes$.value);
let searchQuery = $state('');
let activeTag = $state<string | null>(null);
let activeDifficulty = $state<Difficulty | null>(null);
let showFavoritesOnly = $state(false);
let showCreate = $state(false);
let expandedId = $state<string | null>(null);
// New recipe form
let newTitle = $state('');
let newDescription = $state('');
let newDifficulty = $state<Difficulty>('medium');
let newServings = $state(4);
let newPrepTime = $state('');
let newCookTime = $state('');
let newTags = $state<string[]>([]);
let newIngredients = $state<Ingredient[]>([{ name: '', amount: '', unit: '' }]);
let newSteps = $state<string[]>(['']);
let allTags = $derived(getAllTags(recipes));
let filtered = $derived.by(() => {
let result = recipes;
if (showFavoritesOnly) result = result.filter((r) => r.isFavorite);
if (searchQuery) result = searchRecipes(result, searchQuery);
if (activeTag) result = filterByTag(result, activeTag);
if (activeDifficulty) result = filterByDifficulty(result, activeDifficulty);
return result;
});
const ctxMenu = useItemContextMenu<Recipe>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.state.target
? [
{
id: 'favorite',
label: ctxMenu.state.target.isFavorite ? 'Favorit entfernen' : 'Als Favorit',
icon: Heart,
action: () => {
const t = ctxMenu.state.target;
if (t) recipesStore.toggleFavorite(t.id);
},
},
{
id: 'duplicate',
label: 'Duplizieren',
icon: Copy,
action: () => {
const t = ctxMenu.state.target;
if (t) recipesStore.duplicateRecipe(t.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
const t = ctxMenu.state.target;
if (t) recipesStore.deleteRecipe(t.id);
},
},
]
: []
);
async function handleCreate(e: Event) {
e.preventDefault();
if (!newTitle.trim()) return;
await recipesStore.createRecipe({
title: newTitle.trim(),
description: newDescription.trim(),
difficulty: newDifficulty,
servings: newServings,
prepTimeMin: newPrepTime ? parseInt(newPrepTime) : null,
cookTimeMin: newCookTime ? parseInt(newCookTime) : null,
tags: newTags,
ingredients: newIngredients.filter((i) => i.name.trim()),
steps: newSteps.filter((s) => s.trim()),
});
resetForm();
}
function resetForm() {
newTitle = '';
newDescription = '';
newDifficulty = 'medium';
newServings = 4;
newPrepTime = '';
newCookTime = '';
newTags = [];
newIngredients = [{ name: '', amount: '', unit: '' }];
newSteps = [''];
showCreate = false;
}
function addIngredientRow() {
newIngredients = [...newIngredients, { name: '', amount: '', unit: '' }];
}
function removeIngredientRow(idx: number) {
newIngredients = newIngredients.filter((_, i) => i !== idx);
}
function addStepRow() {
newSteps = [...newSteps, ''];
}
function removeStepRow(idx: number) {
newSteps = newSteps.filter((_, i) => i !== idx);
}
function toggleTag(tag: string) {
if (newTags.includes(tag)) newTags = newTags.filter((t) => t !== tag);
else newTags = [...newTags, tag];
}
function toggleExpanded(id: string) {
expandedId = expandedId === id ? null : id;
}
function handleCreateKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') resetForm();
}
</script>
<div class="recipes-view">
<!-- Search & Filters -->
<div class="filters">
<input
class="search-input"
type="text"
placeholder="Rezept suchen..."
bind:value={searchQuery}
/>
<div class="chip-row">
<button
class="chip"
class:active={showFavoritesOnly}
onclick={() => (showFavoritesOnly = !showFavoritesOnly)}
>
<Star size={12} weight={showFavoritesOnly ? 'fill' : 'regular'} />
Favoriten
</button>
{#each ['easy', 'medium', 'hard'] as const as d}
<button
class="chip"
class:active={activeDifficulty === d}
onclick={() => (activeDifficulty = activeDifficulty === d ? null : d)}
>
<span class="diff-dot" style:background={DIFFICULTY_COLORS[d]}></span>
{DIFFICULTY_LABELS[d].de}
</button>
{/each}
</div>
{#if allTags.length > 0}
<div class="chip-row">
{#each allTags as tag}
<button
class="chip"
class:active={activeTag === tag}
onclick={() => (activeTag = activeTag === tag ? null : tag)}
>
{tag}
</button>
{/each}
</div>
{/if}
</div>
<!-- Recipe Cards -->
<div class="recipe-grid">
{#each filtered as recipe (recipe.id)}
{@const totalTime = getTotalTime(recipe)}
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div
class="recipe-card"
role="button"
tabindex="0"
onclick={() => toggleExpanded(recipe.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpanded(recipe.id);
}
}}
oncontextmenu={(e) => ctxMenu.open(e, recipe)}
>
<div class="card-photo">
{#if recipe.photoThumbnailUrl}
<img src={recipe.photoThumbnailUrl} alt={recipe.title} loading="lazy" />
{:else}
<span class="photo-emoji"
>{recipe.difficulty === 'easy'
? '🥘'
: recipe.difficulty === 'medium'
? '👨‍🍳'
: '🔥'}</span
>
{/if}
{#if recipe.isFavorite}
<span class="fav-badge"><Star size={14} weight="fill" /></span>
{/if}
</div>
<div class="card-body">
<span class="card-title">{recipe.title}</span>
{#if recipe.description}
<span class="card-desc">{recipe.description}</span>
{/if}
<div class="card-meta">
<span
class="diff-badge"
style:background="{DIFFICULTY_COLORS[recipe.difficulty]}22"
style:color={DIFFICULTY_COLORS[recipe.difficulty]}
>
{DIFFICULTY_LABELS[recipe.difficulty].de}
</span>
{#if totalTime}<span class="meta-pill">{formatTime(totalTime)}</span>{/if}
<span class="meta-pill">{recipe.servings} Port.</span>
</div>
{#if recipe.tags.length > 0}
<div class="card-tags">
{#each recipe.tags.slice(0, 3) as tag}<span class="mini-tag">{tag}</span>{/each}
{#if recipe.tags.length > 3}<span class="mini-tag">+{recipe.tags.length - 3}</span
>{/if}
</div>
{/if}
</div>
</div>
{#if expandedId === recipe.id}
<div class="detail-panel">
{#if recipe.ingredients.length > 0}
<div class="detail-section">
<div class="detail-heading">
Zutaten <span class="detail-sub">({recipe.servings} Portionen)</span>
</div>
{#each recipe.ingredients as ing}
<div class="ing-row">
<span class="ing-qty">{ing.amount} {ing.unit}</span>
<span class="ing-name">{ing.name}</span>
</div>
{/each}
</div>
{/if}
{#if recipe.steps.length > 0}
<div class="detail-section">
<div class="detail-heading">Zubereitung</div>
{#each recipe.steps as step, i}
<div class="step-row">
<span class="step-num">{i + 1}</span>
<span class="step-text">{step}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
{#if !showCreate}
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div
class="recipe-card add-card"
role="button"
tabindex="0"
onclick={() => (showCreate = true)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showCreate = true;
}
}}
>
<span class="add-icon">+</span>
<span class="add-label">Neues Rezept</span>
</div>
{/if}
</div>
<!-- Create Form -->
{#if showCreate}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form class="create-form" onsubmit={handleCreate} onkeydown={handleCreateKeydown}>
<div class="form-heading">Neues Rezept</div>
<!-- svelte-ignore a11y_autofocus -->
<input class="form-input" type="text" placeholder="Titel *" bind:value={newTitle} autofocus />
<textarea
class="form-input"
placeholder="Beschreibung (optional)"
bind:value={newDescription}
rows="2"
></textarea>
<div class="form-grid">
<label class="form-field">
<span class="form-label">Schwierigkeit</span>
<select class="form-input" bind:value={newDifficulty}>
{#each ['easy', 'medium', 'hard'] as const as d}<option value={d}
>{DIFFICULTY_LABELS[d].de}</option
>{/each}
</select>
</label>
<label class="form-field">
<span class="form-label">Portionen</span>
<input class="form-input" type="number" min="1" bind:value={newServings} />
</label>
<label class="form-field">
<span class="form-label">Vorb. (Min.)</span>
<input class="form-input" type="number" min="0" bind:value={newPrepTime} />
</label>
<label class="form-field">
<span class="form-label">Koch (Min.)</span>
<input class="form-input" type="number" min="0" bind:value={newCookTime} />
</label>
</div>
<div class="form-section">
<span class="form-label">Tags</span>
<div class="chip-row">
{#each DEFAULT_TAGS as tag}
<button
type="button"
class="chip"
class:active={newTags.includes(tag)}
onclick={() => toggleTag(tag)}>{tag}</button
>
{/each}
</div>
</div>
<div class="form-section">
<span class="form-label">Zutaten</span>
{#each newIngredients as ing, i}
<div class="ing-edit-row">
<input
class="form-input ing-qty-input"
type="text"
placeholder="Menge"
bind:value={ing.amount}
/>
<select class="form-input ing-unit-input" bind:value={ing.unit}>
{#each UNIT_OPTIONS as u}<option value={u}>{u || '—'}</option>{/each}
</select>
<input
class="form-input"
type="text"
placeholder="Zutat"
bind:value={ing.name}
style="flex:1"
/>
{#if newIngredients.length > 1}<button
type="button"
class="remove-btn"
onclick={() => removeIngredientRow(i)}>×</button
>{/if}
</div>
{/each}
<button type="button" class="add-row-btn" onclick={addIngredientRow}>+ Zutat</button>
</div>
<div class="form-section">
<span class="form-label">Schritte</span>
{#each newSteps as _, i}
<div class="step-edit-row">
<span class="step-edit-num">{i + 1}.</span>
<input
class="form-input"
type="text"
placeholder="Schritt beschreiben..."
bind:value={newSteps[i]}
style="flex:1"
/>
{#if newSteps.length > 1}<button
type="button"
class="remove-btn"
onclick={() => removeStepRow(i)}>×</button
>{/if}
</div>
{/each}
<button type="button" class="add-row-btn" onclick={addStepRow}>+ Schritt</button>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" onclick={resetForm}>Abbrechen</button>
<button type="submit" class="btn-create" disabled={!newTitle.trim()}>Erstellen</button>
</div>
</form>
{/if}
{#if filtered.length === 0 && !showCreate}
<div class="empty">
{#if recipes.length === 0}
<p>Noch keine Rezepte gespeichert.</p>
<button class="btn-create" onclick={() => (showCreate = true)}>Erstes Rezept anlegen</button
>
{:else}
<p>Keine Rezepte gefunden.</p>
{/if}
</div>
{/if}
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={ctxMenu.close}
/>
</div>
<style>
.recipes-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem;
}
/* ── Filters ──────────────────────────────────── */
.filters {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
outline: none;
}
.search-input:focus {
border-color: hsl(var(--color-primary));
}
.search-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.chip-row {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
font-size: 0.6875rem;
font-weight: 500;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.chip.active {
background: hsl(var(--color-primary));
color: white;
border-color: hsl(var(--color-primary));
}
.chip:hover:not(.active) {
background: hsl(var(--color-muted));
border-color: hsl(var(--color-muted-foreground));
}
.diff-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Recipe Grid ─────────────────────────────── */
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.recipe-card {
display: flex;
flex-direction: column;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
overflow: hidden;
cursor: pointer;
user-select: none;
transition:
transform 0.15s,
box-shadow 0.15s;
color: hsl(var(--color-foreground));
}
.recipe-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.card-photo {
position: relative;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--color-muted));
overflow: hidden;
}
.card-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-emoji {
font-size: 2.25rem;
line-height: 1;
}
.fav-badge {
position: absolute;
top: 0.375rem;
right: 0.375rem;
color: #f59e0b;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4));
}
.card-body {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.1875rem;
}
.card-title {
font-size: 0.8125rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: hsl(var(--color-foreground));
}
.card-desc {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
margin-top: 0.125rem;
}
.diff-badge {
font-size: 0.5625rem;
font-weight: 700;
padding: 0.0625rem 0.3125rem;
border-radius: 0.25rem;
}
.meta-pill {
font-size: 0.5625rem;
font-weight: 600;
padding: 0.0625rem 0.3125rem;
border-radius: 0.25rem;
background: hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
}
.card-tags {
display: flex;
gap: 0.1875rem;
flex-wrap: wrap;
margin-top: 0.125rem;
}
.mini-tag {
font-size: 0.5rem;
padding: 0rem 0.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
}
/* Add card */
.add-card {
align-items: center;
justify-content: center;
min-height: 180px;
border: 2px dashed hsl(var(--color-border));
background: transparent;
gap: 0.5rem;
}
.add-card:hover {
border-color: hsl(var(--color-primary));
transform: none;
box-shadow: none;
}
.add-icon {
font-size: 1.5rem;
font-weight: 300;
line-height: 1;
color: hsl(var(--color-muted-foreground));
}
.add-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.add-card:hover .add-icon,
.add-card:hover .add-label {
color: hsl(var(--color-primary));
}
/* ── Expanded Detail ─────────────────────────── */
.detail-panel {
grid-column: 1 / -1;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-heading {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin-bottom: 0.375rem;
}
.detail-sub {
font-weight: 400;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.ing-row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
padding: 0.1875rem 0;
border-bottom: 1px solid hsl(var(--color-border) / 0.4);
}
.ing-qty {
font-weight: 600;
min-width: 3.5rem;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
}
.ing-name {
color: hsl(var(--color-muted-foreground));
}
.step-row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
align-items: flex-start;
margin-bottom: 0.375rem;
}
.step-num {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: #f97316;
color: white;
font-size: 0.625rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-text {
color: hsl(var(--color-foreground));
line-height: 1.4;
}
/* ── Create Form ─────────────────────────────── */
.create-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.form-heading {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.form-input {
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
outline: none;
font-family: inherit;
resize: vertical;
width: 100%;
}
.form-input:focus {
border-color: hsl(var(--color-primary));
}
.form-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.form-grid {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.1875rem;
flex: 1;
min-width: 5rem;
}
.form-label {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.form-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.ing-edit-row,
.step-edit-row {
display: flex;
gap: 0.25rem;
align-items: center;
}
.ing-qty-input {
width: 3.5rem;
}
.ing-unit-input {
width: 4rem;
}
.step-edit-num {
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
width: 1.25rem;
text-align: right;
flex-shrink: 0;
}
.remove-btn {
background: transparent;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
padding: 0.125rem;
line-height: 1;
flex-shrink: 0;
}
.remove-btn:hover {
color: #ef4444;
}
.add-row-btn {
align-self: flex-start;
padding: 0.1875rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.add-row-btn:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
}
.btn-cancel,
.btn-create {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-cancel {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.btn-cancel:hover {
background: hsl(var(--color-muted));
}
.btn-create {
background: hsl(var(--color-primary));
color: white;
}
.btn-create:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-create:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Empty ────────────────────────────────────── */
.empty {
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
padding: 2rem 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
@media (max-width: 640px) {
.recipe-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.card-photo {
height: 80px;
}
.form-grid {
flex-direction: column;
}
}
</style>

View file

@ -0,0 +1,107 @@
/**
* Recipes module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalRecipe } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const recipeTable = db.table<LocalRecipe>('recipes');
// ─── Guest Seed ────────────────────────────────────────────
export const RECIPES_GUEST_SEED = {
recipes: [
{
id: 'recipe-pfannkuchen',
title: 'Pfannkuchen',
description: 'Klassische deutsche Pfannkuchen — dünn und goldbraun.',
ingredients: [
{ name: 'Mehl', amount: '250', unit: 'g' },
{ name: 'Milch', amount: '400', unit: 'ml' },
{ name: 'Eier', amount: '3', unit: 'Stück' },
{ name: 'Salz', amount: '1', unit: 'Prise' },
{ name: 'Butter', amount: '2', unit: 'EL' },
],
steps: [
'Mehl, Milch, Eier und Salz zu einem glatten Teig verrühren.',
'Teig 15 Minuten ruhen lassen.',
'Butter in einer Pfanne erhitzen und dünne Pfannkuchen ausbacken.',
'Von beiden Seiten goldbraun braten.',
],
servings: 4,
prepTimeMin: 10,
cookTimeMin: 20,
difficulty: 'easy',
tags: ['Deutsch', 'Vegetarisch', 'Schnell'],
isFavorite: true,
photoMediaId: null,
photoUrl: null,
photoThumbnailUrl: null,
},
{
id: 'recipe-aglio-olio',
title: 'Spaghetti Aglio e Olio',
description: 'Einfache italienische Pasta mit Knoblauch und Olivenöl.',
ingredients: [
{ name: 'Spaghetti', amount: '400', unit: 'g' },
{ name: 'Knoblauch', amount: '6', unit: 'Stück' },
{ name: 'Olivenöl', amount: '6', unit: 'EL' },
{ name: 'Chiliflocken', amount: '1', unit: 'TL' },
{ name: 'Petersilie', amount: '1', unit: 'Bund' },
{ name: 'Salz', amount: '1', unit: 'TL' },
],
steps: [
'Spaghetti in reichlich Salzwasser al dente kochen.',
'Knoblauch in dünne Scheiben schneiden.',
'Olivenöl in einer großen Pfanne erhitzen, Knoblauch und Chiliflocken bei niedriger Hitze goldbraun anbraten.',
'Spaghetti abgießen (etwas Kochwasser aufheben) und in die Pfanne geben.',
'Mit Kochwasser, Petersilie und Salz abschmecken.',
],
servings: 4,
prepTimeMin: 5,
cookTimeMin: 15,
difficulty: 'easy',
tags: ['Italienisch', 'Vegetarisch', 'Schnell'],
isFavorite: false,
photoMediaId: null,
photoUrl: null,
photoThumbnailUrl: null,
},
{
id: 'recipe-gemuese-curry',
title: 'Gemüse-Curry',
description: 'Cremiges Curry mit saisonalem Gemüse und Kokosmilch.',
ingredients: [
{ name: 'Kichererbsen (Dose)', amount: '1', unit: 'Dose' },
{ name: 'Kokosmilch', amount: '400', unit: 'ml' },
{ name: 'Süßkartoffel', amount: '1', unit: 'Stück' },
{ name: 'Spinat', amount: '200', unit: 'g' },
{ name: 'Currypaste', amount: '2', unit: 'EL' },
{ name: 'Zwiebel', amount: '1', unit: 'Stück' },
{ name: 'Knoblauch', amount: '2', unit: 'Stück' },
{ name: 'Ingwer', amount: '1', unit: 'EL' },
{ name: 'Reis', amount: '300', unit: 'g' },
],
steps: [
'Reis nach Packungsanleitung kochen.',
'Zwiebel, Knoblauch und Ingwer fein hacken und in Öl anbraten.',
'Currypaste hinzufügen und 1 Minute anrösten.',
'Süßkartoffel würfeln, hinzugeben und mit Kokosmilch ablöschen.',
'15 Minuten köcheln lassen, bis die Süßkartoffel weich ist.',
'Kichererbsen und Spinat unterrühren, 3 Minuten ziehen lassen.',
'Mit Salz abschmecken und mit Reis servieren.',
],
servings: 4,
prepTimeMin: 15,
cookTimeMin: 25,
difficulty: 'medium',
tags: ['Vegan', 'Asiatisch'],
isFavorite: false,
photoMediaId: null,
photoUrl: null,
photoThumbnailUrl: null,
},
] satisfies LocalRecipe[],
};

View file

@ -0,0 +1,25 @@
/**
* Recipes module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { recipesStore } from './stores/recipes.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllRecipes,
toRecipe,
filterByTag,
filterByDifficulty,
searchRecipes,
getAllTags,
getTotalTime,
formatTime,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export { recipeTable, RECIPES_GUEST_SEED } from './collections';
// ─── Types ───────────────────────────────────────────────
export { DIFFICULTY_LABELS, DIFFICULTY_COLORS, DEFAULT_TAGS, UNIT_OPTIONS } from './types';
export type { LocalRecipe, Recipe, Ingredient, Difficulty } from './types';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const recipesModuleConfig: ModuleConfig = {
appId: 'recipes',
tables: [{ name: 'recipes' }],
};

View file

@ -0,0 +1,89 @@
/**
* Reactive Queries & Pure Helpers for Recipes module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import type { LocalRecipe, Recipe, Difficulty } from './types';
// ─── Type Converter ──────────────────────────────────────
export function toRecipe(local: LocalRecipe): Recipe {
const now = new Date().toISOString();
return {
id: local.id,
title: local.title,
description: local.description,
ingredients: local.ingredients ?? [],
steps: local.steps ?? [],
servings: local.servings,
prepTimeMin: local.prepTimeMin ?? null,
cookTimeMin: local.cookTimeMin ?? null,
difficulty: local.difficulty,
tags: local.tags ?? [],
isFavorite: local.isFavorite,
photoMediaId: local.photoMediaId ?? null,
photoUrl: local.photoUrl ?? null,
photoThumbnailUrl: local.photoThumbnailUrl ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Live Queries ─────────────────────────────────────────
export function useAllRecipes() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalRecipe>('recipes').toArray();
const visible = locals.filter((r) => !r.deletedAt);
const decrypted = await decryptRecords('recipes', visible);
return decrypted.map(toRecipe);
}, [] as Recipe[]);
}
// ─── Pure Helpers ─────────────────────────────────────────
/** Filter recipes by tag */
export function filterByTag(recipes: Recipe[], tag: string): Recipe[] {
return recipes.filter((r) => r.tags.includes(tag));
}
/** Filter recipes by difficulty */
export function filterByDifficulty(recipes: Recipe[], difficulty: Difficulty): Recipe[] {
return recipes.filter((r) => r.difficulty === difficulty);
}
/** Search recipes by title or description */
export function searchRecipes(recipes: Recipe[], query: string): Recipe[] {
const lower = query.toLowerCase();
return recipes.filter(
(r) =>
r.title.toLowerCase().includes(lower) ||
r.description.toLowerCase().includes(lower) ||
r.ingredients.some((i) => i.name.toLowerCase().includes(lower))
);
}
/** Get all unique tags across all recipes, sorted alphabetically */
export function getAllTags(recipes: Recipe[]): string[] {
const tags = new Set<string>();
for (const r of recipes) {
for (const t of r.tags) tags.add(t);
}
return [...tags].sort();
}
/** Get total time (prep + cook) or null if both are null */
export function getTotalTime(recipe: Recipe): number | null {
if (recipe.prepTimeMin == null && recipe.cookTimeMin == null) return null;
return (recipe.prepTimeMin ?? 0) + (recipe.cookTimeMin ?? 0);
}
/** Format minutes as human-readable string */
export function formatTime(minutes: number): string {
if (minutes < 60) return `${minutes} Min.`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h} Std. ${m} Min.` : `${h} Std.`;
}

View file

@ -0,0 +1,109 @@
/**
* Recipes Store Mutation-Only Service
*
* All reads are handled by liveQuery hooks in queries.ts.
*/
import { encryptRecord } from '$lib/data/crypto';
import { recipeTable } from '../collections';
import { toRecipe } from '../queries';
import type { LocalRecipe, Difficulty, Ingredient } from '../types';
export const recipesStore = {
async createRecipe(input: {
title: string;
description?: string;
ingredients?: Ingredient[];
steps?: string[];
servings?: number;
prepTimeMin?: number | null;
cookTimeMin?: number | null;
difficulty?: Difficulty;
tags?: string[];
}) {
const newLocal: LocalRecipe = {
id: crypto.randomUUID(),
title: input.title,
description: input.description ?? '',
ingredients: input.ingredients ?? [],
steps: input.steps ?? [],
servings: input.servings ?? 4,
prepTimeMin: input.prepTimeMin ?? null,
cookTimeMin: input.cookTimeMin ?? null,
difficulty: input.difficulty ?? 'medium',
tags: input.tags ?? [],
isFavorite: false,
photoMediaId: null,
photoUrl: null,
photoThumbnailUrl: null,
};
const snapshot = toRecipe({ ...newLocal });
await encryptRecord('recipes', newLocal);
await recipeTable.add(newLocal);
return snapshot;
},
async updateRecipe(
id: string,
patch: Partial<
Pick<
LocalRecipe,
| 'title'
| 'description'
| 'ingredients'
| 'steps'
| 'servings'
| 'prepTimeMin'
| 'cookTimeMin'
| 'difficulty'
| 'tags'
| 'photoMediaId'
| 'photoUrl'
| 'photoThumbnailUrl'
>
>
) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('recipes', wrapped);
await recipeTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteRecipe(id: string) {
await recipeTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async toggleFavorite(id: string) {
const existing = await recipeTable.get(id);
if (!existing) return;
await recipeTable.update(id, {
isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async duplicateRecipe(id: string) {
const existing = await recipeTable.get(id);
if (!existing) return null;
const newLocal: LocalRecipe = {
...existing,
id: crypto.randomUUID(),
title: `Kopie von ${existing.title}`,
isFavorite: false,
createdAt: undefined,
updatedAt: undefined,
deletedAt: undefined,
};
const snapshot = toRecipe({ ...newLocal });
// existing record is already encrypted — re-encrypt the clone
await encryptRecord('recipes', newLocal);
await recipeTable.add(newLocal);
return snapshot;
},
};

View file

@ -0,0 +1,99 @@
/**
* Recipes module types.
*
* Recipe = a cooking recipe with ingredients, steps, and metadata.
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Subtypes ───────────────────────────────────────────
export type Difficulty = 'easy' | 'medium' | 'hard';
export interface Ingredient {
name: string;
amount: string; // string to allow "1/2", "eine Prise", etc.
unit: string; // "g", "ml", "EL", "TL", "Stück", etc.
}
// ─── Local Record Type (Dexie) ──────────────────────────
export interface LocalRecipe extends BaseRecord {
title: string;
description: string;
ingredients: Ingredient[];
steps: string[];
servings: number;
prepTimeMin: number | null;
cookTimeMin: number | null;
difficulty: Difficulty;
tags: string[];
isFavorite: boolean;
photoMediaId: string | null;
photoUrl: string | null;
photoThumbnailUrl: string | null;
}
// ─── Domain Type ────────────────────────────────────────
export interface Recipe {
id: string;
title: string;
description: string;
ingredients: Ingredient[];
steps: string[];
servings: number;
prepTimeMin: number | null;
cookTimeMin: number | null;
difficulty: Difficulty;
tags: string[];
isFavorite: boolean;
photoMediaId: string | null;
photoUrl: string | null;
photoThumbnailUrl: string | null;
createdAt: string;
updatedAt: string;
}
// ─── Constants ──────────────────────────────────────────
export const DIFFICULTY_LABELS: Record<Difficulty, { de: string; en: string }> = {
easy: { de: 'Einfach', en: 'Easy' },
medium: { de: 'Mittel', en: 'Medium' },
hard: { de: 'Schwer', en: 'Hard' },
};
export const DIFFICULTY_COLORS: Record<Difficulty, string> = {
easy: '#22c55e',
medium: '#f59e0b',
hard: '#ef4444',
};
export const DEFAULT_TAGS = [
'Vegan',
'Vegetarisch',
'Glutenfrei',
'Schnell',
'Dessert',
'Suppe',
'Salat',
'Italienisch',
'Asiatisch',
'Deutsch',
];
export const UNIT_OPTIONS = [
'g',
'kg',
'ml',
'L',
'EL',
'TL',
'Stück',
'Prise',
'Bund',
'Scheibe',
'Dose',
'Becher',
'',
];

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllRecipes } from '$lib/modules/recipes/queries';
let { children }: { children: Snippet } = $props();
const allRecipes = useAllRecipes();
setContext('recipes', allRecipes);
</script>
{@render children()}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/recipes/ListView.svelte';
</script>
<svelte:head>
<title>Rezepte - Mana</title>
</svelte:head>
<ListView />

View file

@ -0,0 +1,51 @@
# Frontend Consistency Improvements
Tracked improvements for UI/styling consistency across the Mana unified app.
## 1. Standardize ListView Styling Approach
**Status:** Open
**Priority:** Low (no functional impact, maintenance concern)
**Effort:** Medium (13 modules to migrate)
### Problem
Module ListViews use two different styling approaches:
- **Scoped CSS + `hsl(var(--color-*))` theme tokens** — 27 modules (65%)
- todo, notes, drink, contacts, journal, dreams, habits, firsts, calendar, chat, places, inventory, finance, news, body, calc, events, photos, automations, cycles, uload, picture, recipes
- **Tailwind utility classes** — 13 modules (35%)
- nutriphi, plants, moodlit, cards, presi, storage, skilltree, context, guides, memoro, who, music, playground, citycorners, questions, times
### Why it matters
- Tailwind modules sometimes hardcode colors (`bg-white/5`, `text-white/80`) instead of using theme tokens, breaking theme consistency.
- `transition-all` in Tailwind classes can cause rendering bugs with CSS custom properties (recipe module had invisible text until hover — fixed by switching to specific transition properties).
- Mixed approaches make it harder to audit theme compliance and onboard new contributors.
### Recommendation
Migrate the 13 Tailwind-based ListViews to scoped CSS with `hsl(var(--color-*))` tokens, matching the majority pattern. Key rules:
1. Use `hsl(var(--color-foreground))`, `hsl(var(--color-muted))`, etc. — not hardcoded colors.
2. Use specific `transition: transform 0.15s, box-shadow 0.15s` — never `transition-all` (causes CSS variable animation bugs).
3. Keep scoped `<style>` blocks — no Tailwind utility classes in ListView templates.
### Modules to migrate
- [ ] nutriphi
- [ ] plants
- [ ] moodlit
- [ ] cards
- [ ] presi
- [ ] storage
- [ ] skilltree
- [ ] context
- [ ] guides
- [ ] memoro
- [ ] who
- [ ] music
- [ ] playground
- [ ] citycorners
- [ ] questions
- [ ] times

View file

@ -171,6 +171,11 @@ export const APP_ICONS = {
// the "guess who's behind the disguise" mechanic. Purple gradient.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wh" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#a855f7"/><stop offset="100%" style="stop-color:#7c3aed"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wh)"/><path d="M58 30c-3-2-7-3-11-3-12 0-22 9-22 21 0 7 4 13 9 17l-3 11 11-5c2 0 3 1 5 1 12 0 22-9 22-21 0-7-4-13-11-21z" fill="white" fill-opacity="0.18"/><path d="M50 28c-4 0-8 1-11 3-7 8-11 14-11 21 0 12 10 21 22 21 12 0 22-9 22-21s-10-24-22-24z" fill="white"/><circle cx="44" cy="48" r="2.6" fill="#7c3aed"/><circle cx="60" cy="48" r="2.6" fill="#7c3aed"/><path d="M44 60c2 3 4 4 6 4s4-1 6-4" stroke="#7c3aed" stroke-width="2.5" stroke-linecap="round" fill="none"/><text x="76" y="42" font-family="system-ui" font-size="22" font-weight="700" fill="white" fill-opacity="0.85">?</text></svg>`
),
recipes: svgToDataUrl(
// Cooking pot with steam — represents recipe collection.
// Orange→amber gradient for a warm kitchen feel.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="rc" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f97316"/><stop offset="100%" style="stop-color:#d97706"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#rc)"/><ellipse cx="50" cy="56" rx="24" ry="6" fill="white" fill-opacity="0.2"/><path d="M28 50h44v18a14 14 0 0 1-14 14H42a14 14 0 0 1-14-14V50z" fill="white" fill-opacity="0.9"/><rect x="26" y="47" width="48" height="6" rx="3" fill="white"/><path d="M22 50h-4a2 2 0 0 1 0-4h4" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/><path d="M78 50h4a2 2 0 0 0 0-4h-4" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/><path d="M40 38c0-4 3-6 3-10" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/><path d="M50 36c0-4 3-6 3-10" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/><path d="M60 38c0-4 3-6 3-10" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -768,6 +768,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'recipes',
name: 'Rezepte',
description: {
de: 'Rezeptsammlung',
en: 'Recipe Collection',
},
longDescription: {
de: 'Sammle und organisiere deine Lieblingsrezepte — mit Zutaten, Schritten, Fotos und Tags.',
en: 'Collect and organize your favorite recipes — with ingredients, steps, photos, and tags.',
},
icon: APP_ICONS.recipes,
color: '#f97316',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
{
id: 'who',
name: 'Who',