227 lines
7.9 KiB
Svelte
227 lines
7.9 KiB
Svelte
<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>
|