mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
feat(mana/web/nutriphi): inline text + photo quick-add in workbench ListView
The workbench card was read-only — users had to navigate to /nutriphi/add
to log anything. Now the card has a quick-add bar in the toolbar slot:
- Text input → Enter or send button → mealMutations.create() with
suggestMealType() (no AI round-trip; users get instant persistence
and can edit nutrition later from the detail page)
- 📷 button → file picker (capture=environment for mobile camera) →
photoMutations.uploadAndAnalyze → mealMutations.createFromPhoto with
the full Gemini result (foods + thumbnail + confidence)
- Toast on success ("📷 Mahlzeit hinzugefügt · KI 87%") and on error
Item rendering also got a small upgrade:
- Each row is now a link to /nutriphi/[id] (matches the rest of the
nutriphi pages now that the detail route exists)
- Thumbnail shown next to the row when present (uses photoThumbnailUrl
for bandwidth)
- 📷 indicator badge for photo-mode meals
Pre-existing bug fix in passing: the goals query was reading from the
non-existent table 'nutriphiGoals' instead of 'goals' (the actual table
name from module.config.ts), so the calorie target was never visible
in the workbench card. Switched to 'goals'.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e579e292cc
commit
c184991b3a
1 changed files with 146 additions and 8 deletions
|
|
@ -1,12 +1,16 @@
|
|||
<!--
|
||||
NutriPhi — Workbench ListView
|
||||
Today's nutrition progress with meal log.
|
||||
Today's nutrition progress with meal log + inline quick-add bar
|
||||
(text input + photo upload, both write straight to mealMutations).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { BaseListView } from '@mana/shared-ui';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import { mealMutations, photoMutations } from './mutations';
|
||||
import { suggestMealType } from './constants';
|
||||
import type { LocalMeal, LocalGoal } from './types';
|
||||
|
||||
const mealsQuery = useLiveQueryWithDefault(async () => {
|
||||
|
|
@ -15,8 +19,10 @@
|
|||
return decryptRecords('meals', visible);
|
||||
}, [] as LocalMeal[]);
|
||||
|
||||
// NOTE: the table is `goals`, not `nutriphiGoals`. The unified Mana DB
|
||||
// only prefixes table names when two modules collide; goals is unique.
|
||||
const goalsQuery = useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table<LocalGoal>('nutriphiGoals').toArray();
|
||||
const all = await db.table<LocalGoal>('goals').toArray();
|
||||
return all.filter((g) => !g.deletedAt);
|
||||
}, [] as LocalGoal[]);
|
||||
|
||||
|
|
@ -44,6 +50,76 @@
|
|||
dinner: 'Abendessen',
|
||||
snack: 'Snack',
|
||||
};
|
||||
|
||||
// ─── Quick-add state ──────────────────────────────────────────
|
||||
let quickText = $state('');
|
||||
let quickSaving = $state(false);
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
let photoUploading = $state(false);
|
||||
|
||||
async function submitText() {
|
||||
const text = quickText.trim();
|
||||
if (!text || quickSaving) return;
|
||||
quickSaving = true;
|
||||
try {
|
||||
await mealMutations.create({
|
||||
mealType: suggestMealType(),
|
||||
description: text,
|
||||
});
|
||||
quickText = '';
|
||||
} catch (err) {
|
||||
console.error('quick add failed:', err);
|
||||
toast.error('Mahlzeit konnte nicht gespeichert werden');
|
||||
} finally {
|
||||
quickSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTextKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submitText();
|
||||
}
|
||||
}
|
||||
|
||||
async function onPhotoSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
photoUploading = true;
|
||||
try {
|
||||
const { upload, analysis } = await photoMutations.uploadAndAnalyze(file);
|
||||
const nutrition = analysis.totalNutrition
|
||||
? {
|
||||
calories: analysis.totalNutrition.calories ?? 0,
|
||||
protein: analysis.totalNutrition.protein ?? 0,
|
||||
carbohydrates: analysis.totalNutrition.carbohydrates ?? 0,
|
||||
fat: analysis.totalNutrition.fat ?? 0,
|
||||
fiber: analysis.totalNutrition.fiber ?? 0,
|
||||
sugar: analysis.totalNutrition.sugar ?? 0,
|
||||
}
|
||||
: null;
|
||||
await mealMutations.createFromPhoto({
|
||||
mealType: suggestMealType(),
|
||||
description: analysis.description ?? 'Mahlzeit aus Foto',
|
||||
nutrition,
|
||||
photoMediaId: upload.mediaId,
|
||||
photoUrl: upload.publicUrl,
|
||||
photoThumbnailUrl: upload.thumbnailUrl,
|
||||
confidence: analysis.confidence ?? 0.8,
|
||||
foods: analysis.foods?.length ? analysis.foods : null,
|
||||
});
|
||||
const pct =
|
||||
analysis.confidence != null ? ` · KI ${Math.round(analysis.confidence * 100)}%` : '';
|
||||
toast.success(`📷 Mahlzeit hinzugefügt${pct}`);
|
||||
} catch (err) {
|
||||
console.error('photo quick add failed:', err);
|
||||
toast.error('Foto-Analyse fehlgeschlagen');
|
||||
} finally {
|
||||
photoUploading = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<BaseListView items={todayMeals} getKey={(m) => m.id} emptyTitle="Noch keine Mahlzeiten heute">
|
||||
|
|
@ -69,6 +145,46 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick-add bar -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={quickText}
|
||||
onkeydown={onTextKeydown}
|
||||
placeholder="Was hast du gegessen?"
|
||||
disabled={quickSaving}
|
||||
class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 placeholder:text-white/30 focus:border-white/20 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => void submitText()}
|
||||
disabled={!quickText.trim() || quickSaving}
|
||||
aria-label="Mahlzeit speichern"
|
||||
title="Speichern"
|
||||
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||
>
|
||||
{quickSaving ? '…' : '↵'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => fileInput?.click()}
|
||||
disabled={photoUploading}
|
||||
aria-label="Foto aufnehmen"
|
||||
title="Foto"
|
||||
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
|
||||
>
|
||||
{photoUploading ? '…' : '📷'}
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
class="hidden"
|
||||
onchange={onPhotoSelected}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet header()}
|
||||
|
|
@ -77,14 +193,36 @@
|
|||
{/snippet}
|
||||
|
||||
{#snippet item(meal)}
|
||||
<div class="mb-1 min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">{mealTypeLabels[meal.mealType] ?? meal.mealType}</span>
|
||||
<a
|
||||
href="/nutriphi/{meal.id}"
|
||||
class="mb-1 block min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-white/50"
|
||||
>{mealTypeLabels[meal.mealType] ?? meal.mealType}</span
|
||||
>
|
||||
{#if meal.inputType === 'photo'}
|
||||
<span class="text-xs text-white/40">📷</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="truncate text-sm text-white/70">{meal.description}</p>
|
||||
</div>
|
||||
{#if meal.photoThumbnailUrl || meal.photoUrl}
|
||||
<img
|
||||
src={meal.photoThumbnailUrl ?? meal.photoUrl}
|
||||
alt={meal.description}
|
||||
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
{#if meal.nutrition}
|
||||
<span class="text-xs text-white/50">{Math.round(meal.nutrition.calories)} kcal</span>
|
||||
<span class="whitespace-nowrap text-xs text-white/50"
|
||||
>{Math.round(meal.nutrition.calories)} kcal</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="truncate text-sm text-white/70">{meal.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
</BaseListView>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue