fooder-app/src/routes/(app)/diary/[date]/add-entry/+page.svelte

227 lines
7.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { listProducts, getProductByBarcode } from '$lib/api/products';
import { createEntry } from '$lib/api/entries';
import type { Product } from '$lib/types/api';
import TopBar from '$lib/components/ui/TopBar.svelte';
import Sheet from '$lib/components/ui/Sheet.svelte';
import BarcodeScanner from '$lib/components/ui/BarcodeScanner.svelte';
import { kcal, g } from '$lib/utils/format';
const date = $derived(page.params.date);
const mealId = $derived(Number(page.url.searchParams.get('meal_id')));
const queryClient = useQueryClient();
let q = $state('');
let debouncedQ = $state('');
let debounceTimer: ReturnType<typeof setTimeout>;
let selectedProduct = $state<Product | null>(null);
let grams = $state(100);
let submitting = $state(false);
let scannerOpen = $state(false);
let scanLoading = $state(false);
let scanError = $state<string | null>(null);
function handleSearch(value: string) {
q = value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debouncedQ = value; }, 300);
}
const productsQuery = createQuery(() => ({
queryKey: ['products', debouncedQ],
queryFn: () => listProducts(debouncedQ, 30),
staleTime: 0
}));
function selectProduct(product: Product) {
selectedProduct = product;
grams = 100;
}
async function handleBarcodeDetected(barcode: string) {
scannerOpen = false;
scanLoading = true;
scanError = null;
try {
const product = await getProductByBarcode(barcode);
selectProduct(product);
} catch (err: any) {
if (err?.status === 404) {
goto(`/products/new?barcode=${encodeURIComponent(barcode)}`);
} else {
scanError = 'Could not look up barcode. Try searching manually.';
}
} finally {
scanLoading = false;
}
}
async function handleAddEntry() {
if (!selectedProduct || !mealId) return;
submitting = true;
try {
await createEntry(mealId, selectedProduct.id, grams);
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
goto(`/diary/${date}`);
} finally {
submitting = false;
}
}
const preview = $derived(selectedProduct ? {
calories: Math.round(selectedProduct.calories * grams / 100),
protein: Math.round(selectedProduct.protein * grams / 100 * 10) / 10,
carb: Math.round(selectedProduct.carb * grams / 100 * 10) / 10,
fat: Math.round(selectedProduct.fat * grams / 100 * 10) / 10,
} : null);
</script>
{#if scannerOpen}
<BarcodeScanner
ondetect={handleBarcodeDetected}
onclose={() => scannerOpen = false}
/>
{/if}
<div class="flex flex-col h-screen">
<TopBar title="Add food" back="/diary/{date}" />
<!-- Search bar -->
<div class="px-4 py-3 border-b border-zinc-800 bg-zinc-950">
<div class="flex gap-2">
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search foods…"
value={q}
oninput={(e) => handleSearch(e.currentTarget.value)}
autofocus
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
<!-- Barcode scan button -->
<button
onclick={() => { scanError = null; scannerOpen = true; }}
disabled={scanLoading}
class="w-11 h-11 flex items-center justify-center rounded-xl bg-zinc-900 border border-zinc-700 text-zinc-400 hover:text-green-400 hover:border-green-500 disabled:opacity-50 transition-colors shrink-0"
aria-label="Scan barcode"
>
{#if scanLoading}
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m0 14v1M4 12h1m14 0h1M6.343 6.343l.707.707m9.9 9.9.707.707M6.343 17.657l.707-.707m9.9-9.9.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z" />
</svg>
{/if}
</button>
</div>
{#if scanError}
<p class="text-xs text-red-400 mt-2 px-1">{scanError}</p>
{/if}
</div>
<!-- Results -->
<main class="flex-1 overflow-y-auto">
{#if productsQuery.isPending}
<div class="space-y-px mt-2 px-4">
{#each Array(6) as _}
<div class="h-14 bg-zinc-900 rounded-xl animate-pulse mb-2"></div>
{/each}
</div>
{:else if productsQuery.data?.products.length === 0}
<div class="text-center text-zinc-500 mt-16 px-6">
<p class="text-base">No products found</p>
<p class="text-sm mt-1">Try a different name or</p>
<a href="/products/new?name={encodeURIComponent(q)}" class="mt-3 inline-block text-green-500 text-sm">
Create "{q || 'new product'}"
</a>
</div>
{:else}
<ul class="divide-y divide-zinc-800/50">
{#each productsQuery.data?.products ?? [] as product (product.id)}
<li>
<button
onclick={() => selectProduct(product)}
class="w-full flex items-center justify-between px-4 py-3.5 hover:bg-zinc-900 transition-colors text-left"
>
<div class="min-w-0">
<p class="font-medium text-sm truncate capitalize">{product.name}</p>
<p class="text-xs text-zinc-500 mt-0.5">
{kcal(product.calories)} kcal · P {g(product.protein)}g · C {g(product.carb)}g · F {g(product.fat)}g
<span class="text-zinc-600">per 100g</span>
</p>
</div>
<svg class="w-4 h-4 text-zinc-600 ml-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</li>
{/each}
</ul>
{/if}
</main>
<!-- Grams sheet -->
<Sheet
open={selectedProduct !== null}
onclose={() => selectedProduct = null}
title={selectedProduct?.name ?? ''}
>
{#if selectedProduct}
{#if preview}
<div class="grid grid-cols-4 gap-2 mb-5">
{#each [
{ label: 'kcal', value: preview.calories },
{ label: 'protein', value: preview.protein + 'g' },
{ label: 'carbs', value: preview.carb + 'g' },
{ label: 'fat', value: preview.fat + 'g' },
] as m}
<div class="bg-zinc-800 rounded-xl p-2.5 text-center">
<p class="text-base font-semibold">{m.value}</p>
<p class="text-xs text-zinc-500 mt-0.5">{m.label}</p>
</div>
{/each}
</div>
{/if}
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3 mb-5">
<button
onclick={() => grams = Math.max(1, grams - 10)}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
></button>
<input
type="number"
bind:value={grams}
min="1"
max="5000"
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
/>
<button
onclick={() => grams = grams + 10}
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
>+</button>
</div>
<button
onclick={handleAddEntry}
disabled={submitting || grams < 1}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
{submitting ? 'Adding…' : 'Add to meal'}
</button>
{/if}
</Sheet>
</div>