mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
693d20edd1
commit
189249ba01
8 changed files with 630 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
96
apps/mana/apps/web/src/lib/modules/nutriphi/api.ts
Normal file
96
apps/mana/apps/web/src/lib/modules/nutriphi/api.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
130
apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts
Normal file
130
apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue