create empty preset

This commit is contained in:
Piotr Domański 2026-04-08 20:14:36 +02:00
parent a219b4449a
commit 0bb87b3933
4 changed files with 986 additions and 678 deletions

View file

@ -2,26 +2,30 @@ import { apiGet, apiDelete, apiPatch, apiPost } from './client';
import type { Preset, PresetEntry } from '$lib/types/api'; import type { Preset, PresetEntry } from '$lib/types/api';
export function listPresets(limit = 20, offset = 0): Promise<Preset[]> { export function listPresets(limit = 20, offset = 0): Promise<Preset[]> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
return apiGet<Preset[]>(`/preset?${params}`); return apiGet<Preset[]>(`/preset?${params}`);
}
export function createPreset(name: string): Promise<Preset> {
return apiPost<Preset>(`/preset/`, { name });
} }
export function renamePreset(id: number, name: string): Promise<Preset> { export function renamePreset(id: number, name: string): Promise<Preset> {
return apiPatch<Preset>(`/preset/${id}`, { name }); return apiPatch<Preset>(`/preset/${id}`, { name });
} }
export function deletePreset(id: number): Promise<void> { export function deletePreset(id: number): Promise<void> {
return apiDelete(`/preset/${id}`); return apiDelete(`/preset/${id}`);
} }
export function createPresetEntry(presetId: number, productId: number, grams: number): Promise<PresetEntry> { export function createPresetEntry(presetId: number, productId: number, grams: number): Promise<PresetEntry> {
return apiPost<PresetEntry>(`/preset/${presetId}/entry`, { product_id: productId, grams }); return apiPost<PresetEntry>(`/preset/${presetId}/entry`, { product_id: productId, grams });
} }
export function updatePresetEntry(presetId: number, entryId: number, grams: number): Promise<PresetEntry> { export function updatePresetEntry(presetId: number, entryId: number, grams: number): Promise<PresetEntry> {
return apiPatch<PresetEntry>(`/preset/${presetId}/entry/${entryId}`, { grams }); return apiPatch<PresetEntry>(`/preset/${presetId}/entry/${entryId}`, { grams });
} }
export function deletePresetEntry(presetId: number, entryId: number): Promise<void> { export function deletePresetEntry(presetId: number, entryId: number): Promise<void> {
return apiDelete(`/preset/${presetId}/entry/${entryId}`); return apiDelete(`/preset/${presetId}/entry/${entryId}`);
} }

View file

@ -1,273 +1,344 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from "$app/state";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { listProducts, getProductByBarcode } from '$lib/api/products'; import { listProducts, getProductByBarcode } from "$lib/api/products";
import { cacheProducts, searchCachedProducts } from '$lib/offline/db'; import { cacheProducts, searchCachedProducts } from "$lib/offline/db";
import { offlineAddEntry } from '$lib/offline/mutations'; import { offlineAddEntry } from "$lib/offline/mutations";
import { network } from '$lib/offline/network.svelte'; import { network } from "$lib/offline/network.svelte";
import type { Product } from '$lib/types/api'; import type { Product } from "$lib/types/api";
import TopBar from '$lib/components/ui/TopBar.svelte'; import TopBar from "$lib/components/ui/TopBar.svelte";
import Sheet from '$lib/components/ui/Sheet.svelte'; import Sheet from "$lib/components/ui/Sheet.svelte";
import BarcodeScanner from '$lib/components/ui/BarcodeScanner.svelte'; import BarcodeScanner from "$lib/components/ui/BarcodeScanner.svelte";
import { kcal, g } from '$lib/utils/format'; import { kcal, g } from "$lib/utils/format";
const date = $derived(page.params.date!); const date = $derived(page.params.date!);
const mealId = $derived(Number(page.url.searchParams.get('meal_id'))); const mealId = $derived(Number(page.url.searchParams.get("meal_id")));
const queryClient = useQueryClient(); const queryClient = useQueryClient();
let q = $state(''); let q = $state("");
let debouncedQ = $state(''); let debouncedQ = $state("");
let debounceTimer: ReturnType<typeof setTimeout>; let debounceTimer: ReturnType<typeof setTimeout>;
let selectedProduct = $state<Product | null>(null); let selectedProduct = $state<Product | null>(null);
let grams = $state(100); let grams = $state(100);
let submitting = $state(false); let submitting = $state(false);
let error = $state(''); let error = $state("");
let scannerOpen = $state(false); let scannerOpen = $state(false);
let scanLoading = $state(false); let scanLoading = $state(false);
let scanError = $state<string | null>(null); let scanError = $state<string | null>(null);
let searchInput = $state<HTMLInputElement | null>(null); let searchInput = $state<HTMLInputElement | null>(null);
$effect(() => { if (searchInput) setTimeout(() => searchInput?.focus(), 50); }); $effect(() => {
if (searchInput) setTimeout(() => searchInput?.focus(), 50);
});
let gramsInput = $state<HTMLInputElement | null>(null); let gramsInput = $state<HTMLInputElement | null>(null);
$effect(() => { $effect(() => {
if (selectedProduct && gramsInput) { if (selectedProduct && gramsInput) {
setTimeout(() => gramsInput?.focus(), 50); setTimeout(() => gramsInput?.focus(), 50);
} }
}); });
function handleSearch(value: string) { function handleSearch(value: string) {
q = value; q = value;
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debouncedQ = value; }, 300); debounceTimer = setTimeout(() => {
} debouncedQ = value;
}, 300);
}
const productsQuery = createQuery(() => ({ const productsQuery = createQuery(() => ({
queryKey: ['products', debouncedQ], queryKey: ["products", debouncedQ],
queryFn: async () => { queryFn: async () => {
if (!network.online) { if (!network.online) {
return searchCachedProducts(debouncedQ); return searchCachedProducts(debouncedQ);
} }
const products = await listProducts(debouncedQ, 30); const products = await listProducts(debouncedQ, 30);
// Cache for offline use — fire and forget // Cache for offline use — fire and forget
cacheProducts(products); cacheProducts(products);
return products; return products;
}, },
staleTime: 0 staleTime: 0,
})); }));
function selectProduct(product: Product) { function selectProduct(product: Product) {
selectedProduct = product; selectedProduct = product;
grams = 100; grams = 100;
error = ''; error = "";
} }
async function handleBarcodeDetected(barcode: string) { async function handleBarcodeDetected(barcode: string) {
scannerOpen = false; scannerOpen = false;
scanLoading = true; scanLoading = true;
scanError = null; scanError = null;
try { try {
const product = await getProductByBarcode(barcode); const product = await getProductByBarcode(barcode);
selectProduct(product); selectProduct(product);
} catch (err: any) { } catch (err: any) {
if (err?.status === 404) { if (err?.status === 404) {
goto(`/products/new?barcode=${encodeURIComponent(barcode)}`); goto(`/products/new?barcode=${encodeURIComponent(barcode)}`);
} else { } else {
scanError = 'Could not look up barcode. Try searching manually.'; scanError = "Could not look up barcode. Try searching manually.";
} }
} finally { } finally {
scanLoading = false; scanLoading = false;
} }
} }
async function handleAddEntry() { async function handleAddEntry() {
if (!selectedProduct || !mealId) return; if (!selectedProduct || !mealId) return;
submitting = true; submitting = true;
error = ''; error = "";
try { try {
await offlineAddEntry(queryClient, date, mealId, selectedProduct, grams); await offlineAddEntry(queryClient, date, mealId, selectedProduct, grams);
goto(`/diary/${date}`); goto(`/diary/${date}`);
} catch (e: any) { } catch (e: any) {
error = e.message ?? 'Failed to add entry'; error = e.message ?? "Failed to add entry";
submitting = false; submitting = false;
} }
} }
const preview = $derived(selectedProduct ? { const preview = $derived(
calories: Math.round(selectedProduct.calories * grams / 100), selectedProduct
protein: Math.round(selectedProduct.protein * grams / 100 * 10) / 10, ? {
carb: Math.round(selectedProduct.carb * grams / 100 * 10) / 10, calories: Math.round((selectedProduct.calories * grams) / 100),
fat: Math.round(selectedProduct.fat * grams / 100 * 10) / 10, protein:
} : null); 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> </script>
{#if scannerOpen} {#if scannerOpen}
<BarcodeScanner <BarcodeScanner
ondetect={handleBarcodeDetected} ondetect={handleBarcodeDetected}
onclose={() => scannerOpen = false} onclose={() => (scannerOpen = false)}
/> />
{/if} {/if}
<div class="flex flex-col h-screen"> <div class="flex flex-col h-screen">
<TopBar title="Add food" back="/diary/{date}" /> <TopBar title="Add food" back="/diary/{date}" />
<!-- Search bar --> <!-- Search bar -->
<div class="px-4 py-3 border-b border-zinc-800 bg-zinc-950"> <div class="px-4 py-3 border-b border-zinc-800 bg-zinc-950">
<div class="flex gap-2"> <div class="flex gap-2">
<div class="relative flex-1"> <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"> <svg
<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" /> class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500"
</svg> fill="none"
<input stroke="currentColor"
bind:this={searchInput} viewBox="0 0 24 24"
type="search" >
placeholder="Search foods…" <path
value={q} stroke-linecap="round"
oninput={(e) => handleSearch(e.currentTarget.value)} stroke-linejoin="round"
onkeydown={(e) => { stroke-width="2"
if (e.key === 'Enter') { d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
const first = productsQuery.data?.[0]; />
if (first) selectProduct(first); </svg>
} <input
}} bind:this={searchInput}
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" type="search"
/> placeholder="Search foods…"
</div> value={q}
oninput={(e) => handleSearch(e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === "Enter") {
const first = productsQuery.data?.[0];
if (first) selectProduct(first);
}
}}
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 (online only) --> <!-- Barcode scan button (online only) -->
{#if network.online} {#if network.online}
<button <button
onclick={() => { scanError = null; scannerOpen = true; }} onclick={() => {
disabled={scanLoading} scanError = null;
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" scannerOpen = true;
aria-label="Scan barcode" }}
> disabled={scanLoading}
{#if 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"
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"> aria-label="Scan barcode"
<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> {#if scanLoading}
{:else} <svg
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-5 h-5 animate-spin"
<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" /> fill="none"
</svg> stroke="currentColor"
{/if} viewBox="0 0 24 24"
</button> >
{/if} <path
</div> 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>
{/if}
</div>
{#if scanError} {#if scanError}
<p class="text-xs text-red-400 mt-2 px-1">{scanError}</p> <p class="text-xs text-red-400 mt-2 px-1">{scanError}</p>
{/if} {/if}
</div> </div>
<!-- Results --> <!-- Results -->
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
{#if productsQuery.isPending} {#if productsQuery.isPending}
<div class="space-y-px mt-2 px-4"> <div class="space-y-px mt-2 px-4">
{#each Array(6) as _} {#each Array(6) as _}
<div class="h-14 bg-zinc-900 rounded-xl animate-pulse mb-2"></div> <div class="h-14 bg-zinc-900 rounded-xl animate-pulse mb-2"></div>
{/each} {/each}
</div> </div>
{:else if productsQuery.data?.length === 0} {:else if productsQuery.data?.length === 0}
<div class="text-center text-zinc-500 mt-16 px-6"> <div class="text-center text-zinc-500 mt-16 px-6">
{#if !network.online} {#if !network.online}
<p class="text-base">No cached products match "{q}"</p> <p class="text-base">No cached products match "{q}"</p>
<p class="text-sm mt-1">Connect to internet to search all products</p> <p class="text-sm mt-1">Connect to internet to search all products</p>
{:else} {:else}
<p class="text-base">No products found</p> <p class="text-base">No products found</p>
<p class="text-sm mt-1">Try a different name or</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"> <a
Create "{q || 'new product'}" href="/products/new?name={encodeURIComponent(q)}"
</a> class="mt-3 inline-block text-green-500 hover:text-green-300 transition-colors text-sm"
{/if} >
</div> Create "{q || "new product"}"
{:else} </a>
<ul class="divide-y divide-zinc-800/50"> {/if}
{#each productsQuery.data ?? [] as product (product.id)} </div>
<li> {:else}
<button <ul class="divide-y divide-zinc-800/50">
onclick={() => selectProduct(product)} {#each productsQuery.data ?? [] as product (product.id)}
class="w-full flex items-center justify-between px-4 py-3.5 hover:bg-zinc-900 transition-colors text-left" <li>
> <button
<div class="min-w-0"> onclick={() => selectProduct(product)}
<p class="font-medium text-sm truncate capitalize">{product.name}</p> class="w-full flex items-center justify-between px-4 py-3.5 hover:bg-zinc-900 transition-colors text-left"
<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 <div class="min-w-0">
<span class="text-zinc-600">per 100g</span> <p class="font-medium text-sm truncate capitalize">
</p> {product.name}
</div> </p>
<svg class="w-4 h-4 text-zinc-600 ml-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <p class="text-xs text-zinc-500 mt-0.5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> {kcal(product.calories)} kcal · P {g(product.protein)}g · C {g(
</svg> product.carb,
</button> )}g · F {g(product.fat)}g
</li> <span class="text-zinc-600">per 100g</span>
{/each} </p>
</ul> </div>
{/if} <svg
</main> 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 --> <!-- Grams sheet -->
<Sheet <Sheet
open={selectedProduct !== null} open={selectedProduct !== null}
onclose={() => { selectedProduct = null; error = ''; }} onclose={() => {
title={selectedProduct?.name ?? ''} selectedProduct = null;
> error = "";
{#if selectedProduct} }}
{#if preview} title={selectedProduct?.name ?? ""}
<div class="grid grid-cols-4 gap-2 mb-5"> >
{#each [ {#if selectedProduct}
{ label: 'kcal', value: preview.calories }, {#if preview}
{ label: 'protein', value: preview.protein + 'g' }, <div class="grid grid-cols-4 gap-2 mb-5">
{ label: 'carbs', value: preview.carb + 'g' }, {#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}
{ label: 'fat', value: preview.fat + 'g' }, <div class="bg-zinc-800 rounded-xl p-2.5 text-center">
] as m} <p class="text-base font-semibold">{m.value}</p>
<div class="bg-zinc-800 rounded-xl p-2.5 text-center"> <p class="text-xs text-zinc-500 mt-0.5">{m.label}</p>
<p class="text-base font-semibold">{m.value}</p> </div>
<p class="text-xs text-zinc-500 mt-0.5">{m.label}</p> {/each}
</div> </div>
{/each} {/if}
</div>
{/if}
<label class="block text-sm text-zinc-400 mb-2">Grams</label> <label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<button <button
onpointerdown={(e) => e.preventDefault()} onpointerdown={(e) => e.preventDefault()}
onclick={() => { grams = Math.max(1, grams - 10); gramsInput?.focus(); }} onclick={() => {
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" grams = Math.max(1, grams - 10);
></button> gramsInput?.focus();
<input }}
bind:this={gramsInput} 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"
type="number" ></button
bind:value={grams} >
min="1" <input
max="5000" bind:this={gramsInput}
inputmode="decimal" type="number"
onfocus={(e) => e.currentTarget.select()} bind:value={grams}
onkeydown={(e) => { if (e.key === 'Enter') handleAddEntry(); }} min="1"
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" max="5000"
/> inputmode="decimal"
<button onfocus={(e) => e.currentTarget.select()}
onpointerdown={(e) => e.preventDefault()} onkeydown={(e) => {
onclick={() => { grams = grams + 10; gramsInput?.focus(); }} if (e.key === "Enter") handleAddEntry();
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> 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"
</div> />
<button
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
grams = grams + 10;
gramsInput?.focus();
}}
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>
{#if error} {#if error}
<p class="text-red-400 text-sm mb-3">{error}</p> <p class="text-red-400 text-sm mb-3">{error}</p>
{/if} {/if}
<button <button
onclick={handleAddEntry} onclick={handleAddEntry}
disabled={submitting || grams < 1} 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" class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
> >
{submitting ? 'Adding…' : network.online ? 'Add to meal' : 'Add to meal (offline)'} {submitting
</button> ? "Adding…"
{/if} : network.online
</Sheet> ? "Add to meal"
: "Add to meal (offline)"}
</button>
{/if}
</Sheet>
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { register } from "$lib/api/auth"; import { register } from "$lib/api/auth";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { today } from "$lib/utils/date";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { PUBLIC_TURNSTILE_SITE_KEY } from "$env/static/public"; import { PUBLIC_TURNSTILE_SITE_KEY } from "$env/static/public";