mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
65160024f7
commit
f5b9d0a31f
17 changed files with 1453 additions and 0 deletions
|
|
@ -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' } })
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
883
apps/mana/apps/web/src/lib/modules/recipes/ListView.svelte
Normal file
883
apps/mana/apps/web/src/lib/modules/recipes/ListView.svelte
Normal 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>
|
||||
107
apps/mana/apps/web/src/lib/modules/recipes/collections.ts
Normal file
107
apps/mana/apps/web/src/lib/modules/recipes/collections.ts
Normal 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[],
|
||||
};
|
||||
25
apps/mana/apps/web/src/lib/modules/recipes/index.ts
Normal file
25
apps/mana/apps/web/src/lib/modules/recipes/index.ts
Normal 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';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const recipesModuleConfig: ModuleConfig = {
|
||||
appId: 'recipes',
|
||||
tables: [{ name: 'recipes' }],
|
||||
};
|
||||
89
apps/mana/apps/web/src/lib/modules/recipes/queries.ts
Normal file
89
apps/mana/apps/web/src/lib/modules/recipes/queries.ts
Normal 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.`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
99
apps/mana/apps/web/src/lib/modules/recipes/types.ts
Normal file
99
apps/mana/apps/web/src/lib/modules/recipes/types.ts
Normal 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',
|
||||
'',
|
||||
];
|
||||
13
apps/mana/apps/web/src/routes/(app)/recipes/+layout.svelte
Normal file
13
apps/mana/apps/web/src/routes/(app)/recipes/+layout.svelte
Normal 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()}
|
||||
9
apps/mana/apps/web/src/routes/(app)/recipes/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/recipes/+page.svelte
Normal 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 />
|
||||
51
docs/optimizable/frontend-consistency-improvements.md
Normal file
51
docs/optimizable/frontend-consistency-improvements.md
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue