feat(mana/web/nutriphi): photo capture + AI meal recognition flow

Wires the new backend endpoints into the unified Mana app and rebuilds
the meal-add page around two modes:

  - Text mode: free-text description + optional " KI-Vorschlag" button
    that runs Gemini on the description and prefills all six nutrient
    fields. The badge auto-clears if the user edits the description so
    stale estimates can't be silently saved.

  - Foto mode: file picker (accept=image/*, capture=environment for
    mobile camera) → preview → upload to mana-media → Gemini Vision on
    the stored URL. Result prefills the same form fields for review.
    Re-analyze without re-upload is supported.

Both modes show a confidence badge (green ≥50 %, yellow with a "prüfen"
warning below). Save is disabled in foto mode until the upload+analysis
has completed, so a meal can never be persisted with a dangling photo
reference.

New module files:
  - api.ts          server-only client (uploadMealPhoto, analyzeMealPhoto, analyzeMealText)
  - mutations.ts    mealMutations.create / .createFromPhoto / .delete + photoMutations
                    keeps the encryption pattern explicit (clone → encrypt → write,
                    return plaintext snapshot)

Touched:
  - queries.ts      propagate photoMediaId/photoUrl through toMealWithNutrition
  - index.ts        export the new mutations + types
  - registry.ts     extend the meals comment to document why nutrition,
                    photoMediaId, photoUrl and confidence stay plaintext
  - +page.svelte / history/+page.svelte    show 64×64 / 48×48 thumbnail
                    + 📷 indicator for photo-mode meals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 15:14:07 +02:00
parent 693d20edd1
commit 189249ba01
8 changed files with 630 additions and 45 deletions

View file

@ -124,11 +124,20 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
cycleDayLogs: { enabled: true, fields: ['notes', 'mood'] },
// ─── NutriPhi ────────────────────────────────────────────
// LocalMeal only has `description` as user-typed text (mealType /
// inputType / nutrition numbers stay plaintext for the daily-summary
// aggregations and the calorie-progress widget). portionSize is a
// short label like "1 Tasse" — same sensitivity as description, so
// we encrypt it too.
// LocalMeal user-typed text → encrypted: description, portionSize.
// Plaintext (intentional):
// - mealType / inputType / date / createdAt: structural, used for
// filtering and the daily-summary aggregations + calorie-progress
// widget. Encrypting would force decrypt-then-aggregate on every
// liveQuery refresh.
// - nutrition (object of numbers): same — calorie totals are summed
// in pure $derived helpers; encrypting them would defeat the
// local-first reactive layer.
// - photoMediaId / photoUrl: opaque pointers to mana-media; the URL
// alone is not PII (anyone with the URL already has the bytes),
// and CAS-deduped media IDs leak no user content. Same rationale
// planta uses for plantPhotos.
// - confidence (float 0-1): pure metadata about the AI run.
meals: { enabled: true, fields: ['description', 'portionSize'] },
// ─── Planta ──────────────────────────────────────────────
@ -278,6 +287,44 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// columns (isPinned, order) stay plaintext for sort.
playgroundSnippets: { enabled: true, fields: ['name', 'systemPrompt'] },
// ─── News ────────────────────────────────────────────────
// Saved articles are reading-behavior data (sensitive). The body
// fields (title/excerpt/content/htmlContent/author) are encrypted
// at rest. The structural columns — type, isRead, isArchived,
// originalUrl, sourceCuratedId, sourceSlug, categoryId, image, the
// numeric metrics — stay plaintext for indexing, dedupe, and the
// reader's reading-progress logic.
//
// `newsCategories.name` is the user-named folder label and gets the
// same treatment as note titles.
//
// `newsPreferences` holds selected topics + blocklist + learned
// weights. The lists themselves leak less than the *contents* of
// the user's reading; still, the topic-weight map is a noisy proxy
// for interests, so we encrypt it.
//
// `newsReactions` records "what did the user say about article X";
// the meaningful payload is the (articleId, reaction) tuple. We
// encrypt the reaction enum to avoid leaking aggregate "user thumbs
// down N% of articles from source X" signals to anyone with raw DB
// access. The articleId itself stays plaintext because it's used as
// the join key to suppress already-rated articles in the feed scorer.
//
// `newsCachedFeed` is intentionally NOT registered — it's a local
// mirror of the public server pool, the same content already lives
// unencrypted in news.curated_articles, and encrypting it would
// break the [topic+publishedAt] index used for the feed query.
newsArticles: {
enabled: true,
fields: ['title', 'excerpt', 'content', 'htmlContent', 'author'],
},
newsCategories: { enabled: true, fields: ['name'] },
newsPreferences: {
enabled: true,
fields: ['selectedTopics', 'blockedSources', 'topicWeights', 'sourceWeights'],
},
newsReactions: { enabled: true, fields: ['reaction', 'sourceSlug', 'topic'] },
// ─── TimeBlocks (cross-module hub) ───────────────────────
// Phase 7.1: encrypted alongside tasks + calendar.events + habits
// because the consumer modules denormalize their title/description

View file

@ -0,0 +1,96 @@
/**
* NutriPhi server-only API client
*
* CRUD lives in IndexedDB + sync. This module talks to mana-api for the
* three server-only operations: photo upload (S3 via mana-media), AI
* meal analysis from a photo URL (Gemini Vision via mana-llm), and
* AI meal analysis from a text description.
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from '$lib/api/config';
import type { NutritionData } from './types';
export interface UploadMealPhotoResult {
mediaId: string;
publicUrl: string;
thumbnailUrl: string;
storagePath: string;
}
export interface AnalyzedFood {
name: string;
quantity?: string;
calories?: number;
}
export interface MealAnalysisResult {
foods?: AnalyzedFood[];
totalNutrition?: NutritionData;
description?: string;
confidence?: number;
warnings?: string[];
suggestions?: string[];
}
async function authHeader(): Promise<Record<string, string>> {
const token = await authStore.getAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
/** Upload a meal photo to mana-api → S3 (mana-media). */
export async function uploadMealPhoto(file: File): Promise<UploadMealPhotoResult> {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${getManaApiUrl()}/api/v1/nutriphi/photos/upload`, {
method: 'POST',
headers: await authHeader(),
body: formData,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Upload failed (${res.status}): ${body || res.statusText}`);
}
return res.json() as Promise<UploadMealPhotoResult>;
}
/** Run Gemini Vision analysis on a previously uploaded photo URL. */
export async function analyzeMealPhoto(photoUrl: string): Promise<MealAnalysisResult> {
const res = await fetch(`${getManaApiUrl()}/api/v1/nutriphi/analysis/photo`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await authHeader()),
},
body: JSON.stringify({ photoUrl }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Analysis failed (${res.status}): ${body || res.statusText}`);
}
return res.json() as Promise<MealAnalysisResult>;
}
/** Run Gemini analysis on a free-text meal description. */
export async function analyzeMealText(description: string): Promise<MealAnalysisResult> {
const res = await fetch(`${getManaApiUrl()}/api/v1/nutriphi/analysis/text`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await authHeader()),
},
body: JSON.stringify({ description }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Analysis failed (${res.status}): ${body || res.statusText}`);
}
return res.json() as Promise<MealAnalysisResult>;
}

View file

@ -4,6 +4,9 @@
export { mealTable, goalTable, nutriFavoriteTable, NUTRIPHI_GUEST_SEED } from './collections';
export * from './queries';
export { mealMutations, photoMutations, textAnalysisMutations } from './mutations';
export type { CreateMealDto, CreateMealFromPhotoDto, PhotoAnalysisOutcome } from './mutations';
export type { UploadMealPhotoResult, MealAnalysisResult, AnalyzedFood } from './api';
export type {
LocalMeal,
LocalGoal,

View file

@ -0,0 +1,130 @@
/**
* NutriPhi Mutation Helpers (Local-First)
*
* All writes go to IndexedDB first, sync handles the rest. Mutations throw
* on failure so UI callers can surface errors via toasts. Server-only
* operations (photo upload, AI analysis) live in ./api.
*
* Encryption pattern: build the LocalMeal as plaintext, shallow-clone it,
* run encryptRecord on the clone (mutates only the allow-listed fields
* see crypto/registry.ts), then write the clone to Dexie. The original
* plaintext object is returned to the caller. nutrition / photoMediaId /
* photoUrl / confidence are NOT encrypted by design (see registry comment).
*/
import { db } from '$lib/data/database';
import { encryptRecord } from '$lib/data/crypto';
import {
uploadMealPhoto,
analyzeMealPhoto,
analyzeMealText,
type MealAnalysisResult,
type UploadMealPhotoResult,
} from './api';
import type { LocalMeal, MealType, NutritionData } from './types';
export interface CreateMealDto {
mealType: MealType;
description: string;
nutrition?: NutritionData | null;
portionSize?: string | null;
date?: string; // YYYY-MM-DD, defaults to today
}
export interface CreateMealFromPhotoDto extends CreateMealDto {
photoMediaId: string;
photoUrl: string;
confidence: number;
}
function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
export const mealMutations = {
/** Persist a text-only meal entry. */
async create(dto: CreateMealDto): Promise<LocalMeal> {
const now = new Date().toISOString();
const row: LocalMeal = {
id: crypto.randomUUID(),
date: dto.date ?? todayStr(),
mealType: dto.mealType,
inputType: 'text',
description: dto.description.trim(),
portionSize: dto.portionSize ?? null,
confidence: dto.nutrition ? 0.8 : 0,
nutrition: dto.nutrition ?? null,
photoMediaId: null,
photoUrl: null,
createdAt: now,
updatedAt: now,
};
const encrypted: Record<string, unknown> = { ...row };
await encryptRecord('meals', encrypted);
await db.table('meals').add(encrypted);
return row;
},
/** Persist a meal entry that originated from a photo + AI analysis. */
async createFromPhoto(dto: CreateMealFromPhotoDto): Promise<LocalMeal> {
const now = new Date().toISOString();
const row: LocalMeal = {
id: crypto.randomUUID(),
date: dto.date ?? todayStr(),
mealType: dto.mealType,
inputType: 'photo',
description: dto.description.trim(),
portionSize: dto.portionSize ?? null,
confidence: dto.confidence,
nutrition: dto.nutrition ?? null,
photoMediaId: dto.photoMediaId,
photoUrl: dto.photoUrl,
createdAt: now,
updatedAt: now,
};
const encrypted: Record<string, unknown> = { ...row };
await encryptRecord('meals', encrypted);
await db.table('meals').add(encrypted);
return row;
},
async delete(id: string): Promise<void> {
const now = new Date().toISOString();
await db.table('meals').update(id, { deletedAt: now, updatedAt: now });
},
};
export interface PhotoAnalysisOutcome {
upload: UploadMealPhotoResult;
analysis: MealAnalysisResult;
}
export const photoMutations = {
/**
* Upload a meal photo to mana-media and immediately run AI analysis on it.
* Does NOT persist a meal the caller (usually the add page) shows the
* result to the user for review and then calls mealMutations.createFromPhoto.
*/
async uploadAndAnalyze(file: File): Promise<PhotoAnalysisOutcome> {
const upload = await uploadMealPhoto(file);
const analysis = await analyzeMealPhoto(upload.publicUrl);
return { upload, analysis };
},
/** Just upload a photo, no analysis. Useful when re-running analysis later. */
async upload(file: File): Promise<UploadMealPhotoResult> {
return uploadMealPhoto(file);
},
/** Re-run analysis on an already-uploaded photo URL. */
async analyze(photoUrl: string): Promise<MealAnalysisResult> {
return analyzeMealPhoto(photoUrl);
},
};
export const textAnalysisMutations = {
/** Run Gemini analysis on a free-text meal description (no persistence). */
async analyze(description: string): Promise<MealAnalysisResult> {
return analyzeMealText(description);
},
};

View file

@ -30,6 +30,8 @@ export function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
portionSize: local.portionSize,
confidence: local.confidence,
nutrition: local.nutrition ?? null,
photoMediaId: local.photoMediaId ?? null,
photoUrl: local.photoUrl ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}

View file

@ -199,7 +199,15 @@
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
{#if meal.photoUrl}
<img
src={meal.photoUrl}
alt={meal.description}
class="h-16 w-16 flex-shrink-0 rounded-lg object-cover"
loading="lazy"
/>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
@ -210,6 +218,9 @@
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{formatTime(meal.createdAt)}
</span>
{#if meal.inputType === 'photo'}
<span class="text-xs text-[hsl(var(--muted-foreground))]">📷</span>
{/if}
</div>
<p class="mt-1 font-medium text-[hsl(var(--foreground))]">
{meal.description}

View file

@ -1,9 +1,12 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { db } from '$lib/data/database';
import { encryptRecord } from '$lib/data/crypto';
import { useAllFavorites } from '$lib/modules/nutriphi/queries';
import {
mealMutations,
photoMutations,
textAnalysisMutations,
} from '$lib/modules/nutriphi/mutations';
import { MEAL_TYPE_LABELS, suggestMealType } from '$lib/modules/nutriphi/constants';
import type { MealType, NutritionData } from '$lib/modules/nutriphi/types';
import { ArrowLeft } from '@mana/shared-icons';
@ -11,6 +14,9 @@
const allFavorites = useAllFavorites();
let favorites = $derived(allFavorites.current ?? []);
type Mode = 'text' | 'photo';
let mode = $state<Mode>('text');
let mealType = $state<MealType>(suggestMealType());
let description = $state('');
let calories = $state<number | null>(null);
@ -20,6 +26,21 @@
let fiber = $state<number | null>(null);
let sugar = $state<number | null>(null);
// Photo flow state
let fileInput: HTMLInputElement | undefined = $state();
let photoFile = $state<File | null>(null);
let photoPreviewUrl = $state<string | null>(null);
let photoMediaId = $state<string | null>(null);
let photoUploadedUrl = $state<string | null>(null);
let aiConfidence = $state<number | null>(null);
let analyzing = $state(false);
let analyzed = $state(false);
// Text-mode AI suggestion state
let textAnalyzing = $state(false);
let textAiConfidence = $state<number | null>(null);
let textAnalyzed = $state(false);
let saving = $state(false);
let error = $state('');
@ -40,52 +61,167 @@
sugar = fav.nutrition.sugar;
}
function resetPhotoState() {
if (photoPreviewUrl) URL.revokeObjectURL(photoPreviewUrl);
photoFile = null;
photoPreviewUrl = null;
photoMediaId = null;
photoUploadedUrl = null;
aiConfidence = null;
analyzed = false;
}
function switchMode(next: Mode) {
if (mode === next) return;
mode = next;
if (next === 'text') {
resetPhotoState();
} else {
// Leaving text mode — drop the text-AI badge state, but keep
// the description/nutrition values so the user doesn't lose them.
textAnalyzed = false;
textAiConfidence = null;
}
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (photoPreviewUrl) URL.revokeObjectURL(photoPreviewUrl);
photoFile = file;
photoPreviewUrl = URL.createObjectURL(file);
photoMediaId = null;
photoUploadedUrl = null;
aiConfidence = null;
analyzed = false;
error = '';
}
async function handleAnalyzePhoto() {
if (!photoFile) return;
analyzing = true;
error = '';
try {
const { upload, analysis } = await photoMutations.uploadAndAnalyze(photoFile);
photoMediaId = upload.mediaId;
photoUploadedUrl = upload.publicUrl;
// Prefill the same fields the text mode uses, so the user can review/edit.
if (analysis.description) description = analysis.description;
if (analysis.totalNutrition) {
calories = analysis.totalNutrition.calories ?? null;
protein = analysis.totalNutrition.protein ?? null;
carbohydrates = analysis.totalNutrition.carbohydrates ?? null;
fat = analysis.totalNutrition.fat ?? null;
fiber = analysis.totalNutrition.fiber ?? null;
sugar = analysis.totalNutrition.sugar ?? null;
}
aiConfidence = analysis.confidence ?? null;
analyzed = true;
} catch (err) {
console.error('photo analysis failed:', err);
error = 'KI-Analyse fehlgeschlagen. Bitte erneut versuchen.';
} finally {
analyzing = false;
}
}
async function handleSuggestFromText() {
if (!description.trim()) {
error = 'Bitte zuerst eine Beschreibung eingeben';
return;
}
textAnalyzing = true;
error = '';
try {
const analysis = await textAnalysisMutations.analyze(description.trim());
if (analysis.totalNutrition) {
calories = analysis.totalNutrition.calories ?? null;
protein = analysis.totalNutrition.protein ?? null;
carbohydrates = analysis.totalNutrition.carbohydrates ?? null;
fat = analysis.totalNutrition.fat ?? null;
fiber = analysis.totalNutrition.fiber ?? null;
sugar = analysis.totalNutrition.sugar ?? null;
}
textAiConfidence = analysis.confidence ?? null;
lastSuggestedFor = description.trim();
textAnalyzed = true;
} catch (err) {
console.error('text analysis failed:', err);
error = 'KI-Vorschlag fehlgeschlagen. Bitte erneut versuchen.';
} finally {
textAnalyzing = false;
}
}
function buildNutrition(): NutritionData | null {
if (calories === null) return null;
return {
calories: calories ?? 0,
protein: protein ?? 0,
carbohydrates: carbohydrates ?? 0,
fat: fat ?? 0,
fiber: fiber ?? 0,
sugar: sugar ?? 0,
};
}
async function handleSubmit() {
if (!description.trim()) {
error = 'Bitte beschreibe die Mahlzeit';
return;
}
if (mode === 'photo' && !photoMediaId) {
error = 'Bitte zuerst die KI-Analyse ausführen';
return;
}
saving = true;
error = '';
try {
const now = new Date().toISOString();
const today = new Date().toISOString().split('T')[0];
const nutrition: NutritionData | null =
calories !== null
? {
calories: calories ?? 0,
protein: protein ?? 0,
carbohydrates: carbohydrates ?? 0,
fat: fat ?? 0,
fiber: fiber ?? 0,
sugar: sugar ?? 0,
}
: null;
const row: Record<string, unknown> = {
id: crypto.randomUUID(),
date: today,
mealType,
inputType: 'text' as const,
description: description.trim(),
portionSize: null,
confidence: nutrition ? 0.8 : 0,
nutrition,
createdAt: now,
updatedAt: now,
};
await encryptRecord('meals', row);
await db.table('meals').add(row);
const nutrition = buildNutrition();
if (mode === 'photo' && photoMediaId && photoUploadedUrl) {
await mealMutations.createFromPhoto({
mealType,
description,
nutrition,
photoMediaId,
photoUrl: photoUploadedUrl,
confidence: aiConfidence ?? 0.8,
});
} else {
await mealMutations.create({
mealType,
description,
nutrition,
});
}
goto('/nutriphi');
} catch {
error = 'Mahlzeit konnte nicht gespeichert werden';
saving = false;
}
}
// If the user edits the description after a text-mode KI-suggestion, the
// shown nutrition no longer corresponds to the typed text — drop the badge
// (but keep the values, so they can still tweak them).
let lastSuggestedFor = $state('');
$effect(() => {
if (textAnalyzed && description.trim() !== lastSuggestedFor) {
textAnalyzed = false;
textAiConfidence = null;
}
});
let confidencePct = $derived(aiConfidence !== null ? Math.round(aiConfidence * 100) : null);
let lowConfidence = $derived(aiConfidence !== null && aiConfidence < 0.5);
let textConfidencePct = $derived(
textAiConfidence !== null ? Math.round(textAiConfidence * 100) : null
);
let textLowConfidence = $derived(textAiConfidence !== null && textAiConfidence < 0.5);
</script>
<svelte:head>
@ -105,14 +241,120 @@
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Mahlzeit hinzufuegen</h1>
</div>
<!-- Mode Toggle -->
<div
class="grid grid-cols-2 gap-2 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-1"
>
<button
type="button"
onclick={() => switchMode('text')}
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
{mode === 'text'
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'}"
>
Text
</button>
<button
type="button"
onclick={() => switchMode('photo')}
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
{mode === 'photo'
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'}"
>
📷 Foto
</button>
</div>
{#if error}
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
{/if}
<!-- Favorites -->
{#if favorites.length > 0}
<!-- Photo Capture (only in photo mode) -->
{#if mode === 'photo'}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 space-y-4">
<input
bind:this={fileInput}
type="file"
accept="image/*"
capture="environment"
class="hidden"
onchange={handleFileSelect}
/>
{#if !photoPreviewUrl}
<button
type="button"
onclick={() => fileInput?.click()}
class="flex w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-12 transition-colors hover:border-[hsl(var(--primary)/0.5)]"
>
<span class="text-4xl">📷</span>
<span class="text-sm font-medium text-[hsl(var(--foreground))]">
Foto aufnehmen oder hochladen
</span>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
Die KI erkennt das Gericht und schätzt die Nährwerte
</span>
</button>
{:else}
<div class="space-y-3">
<div class="relative overflow-hidden rounded-lg bg-[hsl(var(--muted))]">
<img src={photoPreviewUrl} alt="Mahlzeit" class="max-h-80 w-full object-contain" />
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => fileInput?.click()}
class="flex-1 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
Anderes Foto
</button>
{#if !analyzed}
<button
type="button"
onclick={handleAnalyzePhoto}
disabled={analyzing}
class="flex-[2] rounded-lg bg-[hsl(var(--primary))] px-3 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{analyzing ? 'Analysiere…' : '✨ Mit KI analysieren'}
</button>
{:else}
<button
type="button"
onclick={handleAnalyzePhoto}
disabled={analyzing}
class="flex-[2] rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))] disabled:opacity-50"
>
{analyzing ? 'Analysiere…' : '🔄 Erneut analysieren'}
</button>
{/if}
</div>
{#if analyzed && confidencePct !== null}
<div
class="flex items-center gap-2 rounded-lg px-3 py-2 text-xs
{lowConfidence
? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300'
: 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300'}"
>
<span class="font-medium">KI-Analyse</span>
<span>·</span>
<span>{confidencePct}% sicher</span>
{#if lowConfidence}
<span class="ml-auto">⚠ Bitte Werte prüfen</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Favorites (only in text mode) -->
{#if mode === 'text' && favorites.length > 0}
<div>
<h3 class="mb-2 text-sm font-medium text-[hsl(var(--foreground))]">Favoriten</h3>
<div class="flex flex-wrap gap-2">
@ -152,9 +394,26 @@
<!-- Description -->
<div>
<label for="meal-desc" class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
Beschreibung
</label>
<div class="mb-2 flex items-center justify-between">
<label for="meal-desc" class="block text-sm font-medium text-[hsl(var(--foreground))]">
Beschreibung
{#if mode === 'photo' && analyzed}
<span class="text-xs font-normal text-[hsl(var(--muted-foreground))]"
>(KI-Vorschlag, editierbar)</span
>
{/if}
</label>
{#if mode === 'text'}
<button
type="button"
onclick={handleSuggestFromText}
disabled={textAnalyzing || !description.trim()}
class="rounded-md border border-[hsl(var(--border))] px-2.5 py-1 text-xs font-medium text-[hsl(var(--foreground))] transition-colors hover:bg-[hsl(var(--muted))] disabled:opacity-50"
>
{textAnalyzing ? 'Analysiere…' : '✨ KI-Vorschlag'}
</button>
{/if}
</div>
<textarea
id="meal-desc"
bind:value={description}
@ -162,12 +421,38 @@
rows="3"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
{#if mode === 'text' && textAnalyzed && textConfidencePct !== null}
<div
class="mt-2 flex items-center gap-2 rounded-lg px-3 py-2 text-xs
{textLowConfidence
? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300'
: 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300'}"
>
<span class="font-medium">KI-Schätzung</span>
<span>·</span>
<span>{textConfidencePct}% sicher</span>
{#if textLowConfidence}
<span class="ml-auto">⚠ Bitte Werte prüfen</span>
{/if}
</div>
{/if}
</div>
<!-- Nutrition -->
<div>
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
Naehrwerte <span class="text-[hsl(var(--muted-foreground))]">(optional)</span>
Naehrwerte
{#if mode === 'photo' && analyzed}
<span class="text-xs font-normal text-[hsl(var(--muted-foreground))]"
>(KI-Schätzung, editierbar)</span
>
{:else if mode === 'text' && textAnalyzed}
<span class="text-xs font-normal text-[hsl(var(--muted-foreground))]"
>(KI-Schätzung, editierbar)</span
>
{:else}
<span class="text-[hsl(var(--muted-foreground))]">(optional)</span>
{/if}
</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
@ -262,7 +547,7 @@
<button
type="button"
onclick={handleSubmit}
disabled={saving || !description.trim()}
disabled={saving || !description.trim() || (mode === 'photo' && !photoMediaId)}
class="flex-1 rounded-lg bg-[hsl(var(--primary))] px-4 py-3 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{saving ? $_('common.saving') : $_('common.save')}

View file

@ -164,6 +164,14 @@
<div
class="group flex items-center gap-4 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-3"
>
{#if meal.photoUrl}
<img
src={meal.photoUrl}
alt={meal.description}
class="h-12 w-12 flex-shrink-0 rounded-lg object-cover"
loading="lazy"
/>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
@ -174,6 +182,9 @@
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{formatTime(meal.createdAt)}
</span>
{#if meal.inputType === 'photo'}
<span class="text-xs text-[hsl(var(--muted-foreground))]">📷</span>
{/if}
</div>
<p class="mt-1 text-sm text-[hsl(var(--foreground))] truncate">
{meal.description}