create empty preset
This commit is contained in:
parent
a219b4449a
commit
0bb87b3933
4 changed files with 986 additions and 678 deletions
|
|
@ -6,6 +6,10 @@ export function listPresets(limit = 20, offset = 0): Promise<Preset[]> {
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,38 @@
|
||||||
<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(() => {
|
||||||
|
|
@ -42,11 +44,13 @@
|
||||||
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);
|
||||||
|
|
@ -56,13 +60,13 @@
|
||||||
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) {
|
||||||
|
|
@ -76,7 +80,7 @@
|
||||||
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;
|
||||||
|
|
@ -86,28 +90,33 @@
|
||||||
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}
|
||||||
|
|
||||||
|
|
@ -118,8 +127,18 @@
|
||||||
<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"
|
||||||
|
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>
|
</svg>
|
||||||
<input
|
<input
|
||||||
bind:this={searchInput}
|
bind:this={searchInput}
|
||||||
|
|
@ -128,7 +147,7 @@
|
||||||
value={q}
|
value={q}
|
||||||
oninput={(e) => handleSearch(e.currentTarget.value)}
|
oninput={(e) => handleSearch(e.currentTarget.value)}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
const first = productsQuery.data?.[0];
|
const first = productsQuery.data?.[0];
|
||||||
if (first) selectProduct(first);
|
if (first) selectProduct(first);
|
||||||
}
|
}
|
||||||
|
|
@ -140,18 +159,41 @@
|
||||||
<!-- Barcode scan button (online only) -->
|
<!-- Barcode scan button (online only) -->
|
||||||
{#if network.online}
|
{#if network.online}
|
||||||
<button
|
<button
|
||||||
onclick={() => { scanError = null; scannerOpen = true; }}
|
onclick={() => {
|
||||||
|
scanError = null;
|
||||||
|
scannerOpen = true;
|
||||||
|
}}
|
||||||
disabled={scanLoading}
|
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"
|
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"
|
aria-label="Scan barcode"
|
||||||
>
|
>
|
||||||
{#if scanLoading}
|
{#if scanLoading}
|
||||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -179,8 +221,11 @@
|
||||||
{: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)}"
|
||||||
|
class="mt-3 inline-block text-green-500 hover:text-green-300 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Create "{q || "new product"}"
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -193,14 +238,28 @@
|
||||||
class="w-full flex items-center justify-between px-4 py-3.5 hover:bg-zinc-900 transition-colors text-left"
|
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">
|
<div class="min-w-0">
|
||||||
<p class="font-medium text-sm truncate capitalize">{product.name}</p>
|
<p class="font-medium text-sm truncate capitalize">
|
||||||
|
{product.name}
|
||||||
|
</p>
|
||||||
<p class="text-xs text-zinc-500 mt-0.5">
|
<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
|
{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>
|
<span class="text-zinc-600">per 100g</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-4 h-4 text-zinc-600 ml-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -212,18 +271,16 @@
|
||||||
<!-- Grams sheet -->
|
<!-- Grams sheet -->
|
||||||
<Sheet
|
<Sheet
|
||||||
open={selectedProduct !== null}
|
open={selectedProduct !== null}
|
||||||
onclose={() => { selectedProduct = null; error = ''; }}
|
onclose={() => {
|
||||||
title={selectedProduct?.name ?? ''}
|
selectedProduct = null;
|
||||||
|
error = "";
|
||||||
|
}}
|
||||||
|
title={selectedProduct?.name ?? ""}
|
||||||
>
|
>
|
||||||
{#if selectedProduct}
|
{#if selectedProduct}
|
||||||
{#if preview}
|
{#if preview}
|
||||||
<div class="grid grid-cols-4 gap-2 mb-5">
|
<div class="grid grid-cols-4 gap-2 mb-5">
|
||||||
{#each [
|
{#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: '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">
|
<div class="bg-zinc-800 rounded-xl p-2.5 text-center">
|
||||||
<p class="text-base font-semibold">{m.value}</p>
|
<p class="text-base font-semibold">{m.value}</p>
|
||||||
<p class="text-xs text-zinc-500 mt-0.5">{m.label}</p>
|
<p class="text-xs text-zinc-500 mt-0.5">{m.label}</p>
|
||||||
|
|
@ -236,9 +293,13 @@
|
||||||
<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={() => {
|
||||||
|
grams = Math.max(1, 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"
|
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>
|
>−</button
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
bind:this={gramsInput}
|
bind:this={gramsInput}
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -247,14 +308,20 @@
|
||||||
max="5000"
|
max="5000"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
onfocus={(e) => e.currentTarget.select()}
|
onfocus={(e) => e.currentTarget.select()}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') handleAddEntry(); }}
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAddEntry();
|
||||||
|
}}
|
||||||
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"
|
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
|
<button
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
onclick={() => { grams = grams + 10; gramsInput?.focus(); }}
|
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"
|
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>
|
>+</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|
@ -266,7 +333,11 @@
|
||||||
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
|
||||||
|
? "Adding…"
|
||||||
|
: network.online
|
||||||
|
? "Add to meal"
|
||||||
|
: "Add to meal (offline)"}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,47 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||||
import { listPresets, deletePreset, renamePreset, createPresetEntry, updatePresetEntry, deletePresetEntry } from '$lib/api/presets';
|
import {
|
||||||
import { listProducts } from '$lib/api/products';
|
listPresets,
|
||||||
import { createMealFromPreset } from '$lib/api/meals';
|
createPreset,
|
||||||
import { today } from '$lib/utils/date';
|
deletePreset,
|
||||||
import { kcal, g } from '$lib/utils/format';
|
renamePreset,
|
||||||
import TopBar from '$lib/components/ui/TopBar.svelte';
|
createPresetEntry,
|
||||||
import Sheet from '$lib/components/ui/Sheet.svelte';
|
updatePresetEntry,
|
||||||
import type { Preset, PresetEntry, Product } from '$lib/types/api';
|
deletePresetEntry,
|
||||||
|
} from "$lib/api/presets";
|
||||||
|
import { listProducts } from "$lib/api/products";
|
||||||
|
import { createMealFromPreset } from "$lib/api/meals";
|
||||||
|
import { today } from "$lib/utils/date";
|
||||||
|
import { kcal, g } from "$lib/utils/format";
|
||||||
|
import TopBar from "$lib/components/ui/TopBar.svelte";
|
||||||
|
import Sheet from "$lib/components/ui/Sheet.svelte";
|
||||||
|
import type { Preset, PresetEntry, Product } from "$lib/types/api";
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// ── Search / filter ───────────────────────────────────────────────────────
|
// ── Search / filter ───────────────────────────────────────────────────────
|
||||||
let q = $state('');
|
let q = $state("");
|
||||||
let debouncedQ = $state('');
|
let debouncedQ = $state("");
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
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 presetsQuery = createQuery(() => ({
|
const presetsQuery = createQuery(() => ({
|
||||||
queryKey: ['presets'],
|
queryKey: ["presets"],
|
||||||
queryFn: () => listPresets(100),
|
queryFn: () => listPresets(100),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const filteredPresets = $derived(
|
const filteredPresets = $derived(
|
||||||
(presetsQuery.data ?? []).filter(p =>
|
(presetsQuery.data ?? []).filter(
|
||||||
!debouncedQ || p.name.toLowerCase().includes(debouncedQ.toLowerCase())
|
(p) =>
|
||||||
)
|
!debouncedQ || p.name.toLowerCase().includes(debouncedQ.toLowerCase()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Expand/collapse ───────────────────────────────────────────────────────
|
// ── Expand/collapse ───────────────────────────────────────────────────────
|
||||||
|
|
@ -48,14 +59,33 @@
|
||||||
addingId = preset.id;
|
addingId = preset.id;
|
||||||
try {
|
try {
|
||||||
await createMealFromPreset(today(), preset.id, preset.name);
|
await createMealFromPreset(today(), preset.id, preset.name);
|
||||||
queryClient.invalidateQueries({ queryKey: ['diary', today()] });
|
queryClient.invalidateQueries({ queryKey: ["diary", today()] });
|
||||||
addedId = preset.id;
|
addedId = preset.id;
|
||||||
setTimeout(() => { addedId = null; }, 1500);
|
setTimeout(() => {
|
||||||
|
addedId = null;
|
||||||
|
}, 1500);
|
||||||
} finally {
|
} finally {
|
||||||
addingId = null;
|
addingId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Create preset ─────────────────────────────────────────────────────────
|
||||||
|
let creating = $state<boolean>(false);
|
||||||
|
let creatingName = $state("");
|
||||||
|
let creatingSaving = $state(false);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
creatingSaving = true;
|
||||||
|
try {
|
||||||
|
await createPreset(creatingName.trim());
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["presets"] });
|
||||||
|
creating = false;
|
||||||
|
creatingName = "";
|
||||||
|
} finally {
|
||||||
|
creatingSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Delete preset ─────────────────────────────────────────────────────────
|
// ── Delete preset ─────────────────────────────────────────────────────────
|
||||||
let deletingId = $state<number | null>(null);
|
let deletingId = $state<number | null>(null);
|
||||||
|
|
||||||
|
|
@ -65,7 +95,7 @@
|
||||||
try {
|
try {
|
||||||
await deletePreset(preset.id);
|
await deletePreset(preset.id);
|
||||||
if (expandedId === preset.id) expandedId = null;
|
if (expandedId === preset.id) expandedId = null;
|
||||||
queryClient.invalidateQueries({ queryKey: ['presets'] });
|
queryClient.invalidateQueries({ queryKey: ["presets"] });
|
||||||
} finally {
|
} finally {
|
||||||
deletingId = null;
|
deletingId = null;
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +103,7 @@
|
||||||
|
|
||||||
// ── Rename preset ─────────────────────────────────────────────────────────
|
// ── Rename preset ─────────────────────────────────────────────────────────
|
||||||
let renamePreset_: Preset | null = $state(null);
|
let renamePreset_: Preset | null = $state(null);
|
||||||
let renameName = $state('');
|
let renameName = $state("");
|
||||||
let renaming = $state(false);
|
let renaming = $state(false);
|
||||||
|
|
||||||
function openRename(preset: Preset) {
|
function openRename(preset: Preset) {
|
||||||
|
|
@ -87,7 +117,7 @@
|
||||||
renaming = true;
|
renaming = true;
|
||||||
try {
|
try {
|
||||||
await renamePreset(renamePreset_.id, renameName.trim());
|
await renamePreset(renamePreset_.id, renameName.trim());
|
||||||
queryClient.invalidateQueries({ queryKey: ['presets'] });
|
queryClient.invalidateQueries({ queryKey: ["presets"] });
|
||||||
renamePreset_ = null;
|
renamePreset_ = null;
|
||||||
} finally {
|
} finally {
|
||||||
renaming = false;
|
renaming = false;
|
||||||
|
|
@ -95,7 +125,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit entry grams ──────────────────────────────────────────────────────
|
// ── Edit entry grams ──────────────────────────────────────────────────────
|
||||||
let editingEntry = $state<{ presetId: number; entry: PresetEntry } | null>(null);
|
let editingEntry = $state<{ presetId: number; entry: PresetEntry } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
let editGrams = $state(0);
|
let editGrams = $state(0);
|
||||||
let editSaving = $state(false);
|
let editSaving = $state(false);
|
||||||
let editGramsInput = $state<HTMLInputElement | null>(null);
|
let editGramsInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
@ -110,8 +142,12 @@
|
||||||
if (!editingEntry) return;
|
if (!editingEntry) return;
|
||||||
editSaving = true;
|
editSaving = true;
|
||||||
try {
|
try {
|
||||||
await updatePresetEntry(editingEntry.presetId, editingEntry.entry.id, editGrams);
|
await updatePresetEntry(
|
||||||
queryClient.invalidateQueries({ queryKey: ['presets'] });
|
editingEntry.presetId,
|
||||||
|
editingEntry.entry.id,
|
||||||
|
editGrams,
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["presets"] });
|
||||||
editingEntry = null;
|
editingEntry = null;
|
||||||
} finally {
|
} finally {
|
||||||
editSaving = false;
|
editSaving = false;
|
||||||
|
|
@ -121,13 +157,13 @@
|
||||||
// ── Delete entry ──────────────────────────────────────────────────────────
|
// ── Delete entry ──────────────────────────────────────────────────────────
|
||||||
async function handleDeleteEntry(presetId: number, entryId: number) {
|
async function handleDeleteEntry(presetId: number, entryId: number) {
|
||||||
await deletePresetEntry(presetId, entryId);
|
await deletePresetEntry(presetId, entryId);
|
||||||
queryClient.invalidateQueries({ queryKey: ['presets'] });
|
queryClient.invalidateQueries({ queryKey: ["presets"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add entry to preset ───────────────────────────────────────────────────
|
// ── Add entry to preset ───────────────────────────────────────────────────
|
||||||
let addEntryPresetId = $state<number | null>(null);
|
let addEntryPresetId = $state<number | null>(null);
|
||||||
let productQ = $state('');
|
let productQ = $state("");
|
||||||
let productDebouncedQ = $state('');
|
let productDebouncedQ = $state("");
|
||||||
let productDebounceTimer: ReturnType<typeof setTimeout>;
|
let productDebounceTimer: ReturnType<typeof setTimeout>;
|
||||||
let selectedProduct = $state<Product | null>(null);
|
let selectedProduct = $state<Product | null>(null);
|
||||||
let addGrams = $state(100);
|
let addGrams = $state(100);
|
||||||
|
|
@ -136,8 +172,8 @@
|
||||||
|
|
||||||
function openAddEntry(presetId: number) {
|
function openAddEntry(presetId: number) {
|
||||||
addEntryPresetId = presetId;
|
addEntryPresetId = presetId;
|
||||||
productQ = '';
|
productQ = "";
|
||||||
productDebouncedQ = '';
|
productDebouncedQ = "";
|
||||||
selectedProduct = null;
|
selectedProduct = null;
|
||||||
addGrams = 100;
|
addGrams = 100;
|
||||||
}
|
}
|
||||||
|
|
@ -145,11 +181,13 @@
|
||||||
function handleProductSearch(v: string) {
|
function handleProductSearch(v: string) {
|
||||||
productQ = v;
|
productQ = v;
|
||||||
clearTimeout(productDebounceTimer);
|
clearTimeout(productDebounceTimer);
|
||||||
productDebounceTimer = setTimeout(() => { productDebouncedQ = v; }, 300);
|
productDebounceTimer = setTimeout(() => {
|
||||||
|
productDebouncedQ = v;
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
const productsQuery = createQuery(() => ({
|
const productsQuery = createQuery(() => ({
|
||||||
queryKey: ['products', productDebouncedQ],
|
queryKey: ["products", productDebouncedQ],
|
||||||
queryFn: () => listProducts(productDebouncedQ, 30),
|
queryFn: () => listProducts(productDebouncedQ, 30),
|
||||||
enabled: addEntryPresetId !== null,
|
enabled: addEntryPresetId !== null,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|
@ -161,7 +199,7 @@
|
||||||
addSaving = true;
|
addSaving = true;
|
||||||
try {
|
try {
|
||||||
await createPresetEntry(addEntryPresetId, selectedProduct.id, addGrams);
|
await createPresetEntry(addEntryPresetId, selectedProduct.id, addGrams);
|
||||||
queryClient.invalidateQueries({ queryKey: ['presets'] });
|
queryClient.invalidateQueries({ queryKey: ["presets"] });
|
||||||
addEntryPresetId = null;
|
addEntryPresetId = null;
|
||||||
} finally {
|
} finally {
|
||||||
addSaving = false;
|
addSaving = false;
|
||||||
|
|
@ -175,8 +213,18 @@
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<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="relative">
|
<div class="relative">
|
||||||
<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"
|
||||||
|
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>
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
|
@ -200,8 +248,14 @@
|
||||||
{:else if !filteredPresets.length}
|
{:else if !filteredPresets.length}
|
||||||
<div class="text-center text-zinc-500 mt-16 px-6">
|
<div class="text-center text-zinc-500 mt-16 px-6">
|
||||||
<p>No presets yet</p>
|
<p>No presets yet</p>
|
||||||
<p class="text-sm mt-1">Save a meal as preset from the diary view</p>
|
<p class="text-sm mt-1">Save a meal as preset from the diary view or</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (creating = true)}
|
||||||
|
class="w-full rounded-xl py-4 text-green-500 hover:text-green-300 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Add preset
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y divide-zinc-800/50">
|
<ul class="divide-y divide-zinc-800/50">
|
||||||
{#each filteredPresets as preset (preset.id)}
|
{#each filteredPresets as preset (preset.id)}
|
||||||
|
|
@ -214,15 +268,27 @@
|
||||||
class="flex-1 flex items-center gap-2 text-left min-w-0"
|
class="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 text-zinc-500 shrink-0 transition-transform {expandedId === preset.id ? '' : '-rotate-90'}"
|
class="w-4 h-4 text-zinc-500 shrink-0 transition-transform {expandedId ===
|
||||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
preset.id
|
||||||
|
? ''
|
||||||
|
: '-rotate-90'}"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-medium truncate">{preset.name}</p>
|
<p class="font-medium truncate">{preset.name}</p>
|
||||||
<p class="text-xs text-zinc-500 mt-0.5">
|
<p class="text-xs text-zinc-500 mt-0.5">
|
||||||
{kcal(preset.calories)} kcal · P {g(preset.protein)}g · C {g(preset.carb)}g · F {g(preset.fat)}g
|
{kcal(preset.calories)} kcal · P {g(preset.protein)}g · C {g(
|
||||||
|
preset.carb,
|
||||||
|
)}g · F {g(preset.fat)}g
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -239,17 +305,47 @@
|
||||||
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-300 disabled:opacity-50'}"
|
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-300 disabled:opacity-50'}"
|
||||||
>
|
>
|
||||||
{#if addingId === preset.id}
|
{#if addingId === preset.id}
|
||||||
<svg class="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<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" />
|
class="w-3 h-3 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>
|
</svg>
|
||||||
{:else if addedId === preset.id}
|
{:else if addedId === preset.id}
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
class="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Added
|
Added
|
||||||
{:else}
|
{:else}
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
class="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Today
|
Today
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -261,8 +357,18 @@
|
||||||
class="w-8 h-8 flex items-center justify-center rounded-full text-zinc-500 hover:text-blue-400 hover:bg-blue-400/10 transition-colors"
|
class="w-8 h-8 flex items-center justify-center rounded-full text-zinc-500 hover:text-blue-400 hover:bg-blue-400/10 transition-colors"
|
||||||
aria-label="Rename preset"
|
aria-label="Rename preset"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -273,8 +379,18 @@
|
||||||
class="w-8 h-8 flex items-center justify-center rounded-full text-zinc-600 hover:text-red-400 hover:bg-red-400/10 disabled:opacity-30 transition-colors"
|
class="w-8 h-8 flex items-center justify-center rounded-full text-zinc-600 hover:text-red-400 hover:bg-red-400/10 disabled:opacity-30 transition-colors"
|
||||||
aria-label="Delete preset"
|
aria-label="Delete preset"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -284,7 +400,9 @@
|
||||||
{#if expandedId === preset.id}
|
{#if expandedId === preset.id}
|
||||||
<div class="px-4 pb-3 border-t border-zinc-800/60">
|
<div class="px-4 pb-3 border-t border-zinc-800/60">
|
||||||
{#if preset.entries.length === 0}
|
{#if preset.entries.length === 0}
|
||||||
<p class="text-sm text-zinc-600 py-3 text-center">No entries yet</p>
|
<p class="text-sm text-zinc-600 py-3 text-center">
|
||||||
|
No entries yet
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y divide-zinc-800/50">
|
<ul class="divide-y divide-zinc-800/50">
|
||||||
{#each preset.entries as entry (entry.id)}
|
{#each preset.entries as entry (entry.id)}
|
||||||
|
|
@ -293,16 +411,30 @@
|
||||||
onclick={() => openEditEntry(preset.id, entry)}
|
onclick={() => openEditEntry(preset.id, entry)}
|
||||||
class="flex-1 text-left min-w-0 group"
|
class="flex-1 text-left min-w-0 group"
|
||||||
>
|
>
|
||||||
<p class="text-sm font-medium truncate">{entry.product.name}</p>
|
<p class="text-sm font-medium truncate">
|
||||||
<p class="text-xs text-zinc-500">{entry.grams}g · {kcal(entry.calories)} kcal</p>
|
{entry.product.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-zinc-500">
|
||||||
|
{entry.grams}g · {kcal(entry.calories)} kcal
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleDeleteEntry(preset.id, entry.id)}
|
onclick={() => handleDeleteEntry(preset.id, entry.id)}
|
||||||
class="w-7 h-7 flex items-center justify-center rounded-full text-zinc-600 hover:text-red-400 hover:bg-red-400/10 transition-colors ml-2 shrink-0"
|
class="w-7 h-7 flex items-center justify-center rounded-full text-zinc-600 hover:text-red-400 hover:bg-red-400/10 transition-colors ml-2 shrink-0"
|
||||||
aria-label="Delete entry"
|
aria-label="Delete entry"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -315,8 +447,18 @@
|
||||||
onclick={() => openAddEntry(preset.id)}
|
onclick={() => openAddEntry(preset.id)}
|
||||||
class="mt-2 flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-green-400 transition-colors"
|
class="mt-2 flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-green-400 transition-colors"
|
||||||
>
|
>
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4" />
|
class="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2.5"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Add food
|
Add food
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -324,13 +466,52 @@
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onclick={() => (creating = true)}
|
||||||
|
class="w-full rounded-xl py-4 text-zinc-500 hover:text-zinc-300 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
+ Add preset
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create preset sheet -->
|
||||||
|
<Sheet
|
||||||
|
open={creating === true}
|
||||||
|
onclose={() => {
|
||||||
|
creating = false;
|
||||||
|
creatingName = "";
|
||||||
|
}}
|
||||||
|
title="Create new preset"
|
||||||
|
>
|
||||||
|
<form onsubmit={handleCreate} class="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={creatingName}
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creatingSaving || !creatingName.trim()}
|
||||||
|
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{creatingSaving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<!-- Rename preset sheet -->
|
<!-- Rename preset sheet -->
|
||||||
<Sheet open={renamePreset_ !== null} onclose={() => renamePreset_ = null} title="Rename preset">
|
<Sheet
|
||||||
|
open={renamePreset_ !== null}
|
||||||
|
onclose={() => (renamePreset_ = null)}
|
||||||
|
title="Rename preset"
|
||||||
|
>
|
||||||
<form onsubmit={handleRename} class="space-y-4">
|
<form onsubmit={handleRename} class="space-y-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -344,23 +525,33 @@
|
||||||
disabled={renaming || !renameName.trim()}
|
disabled={renaming || !renameName.trim()}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{renaming ? 'Saving…' : 'Save'}
|
{renaming ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<!-- Edit entry grams sheet -->
|
<!-- Edit entry grams sheet -->
|
||||||
<Sheet open={editingEntry !== null} onclose={() => editingEntry = null} title="Edit entry">
|
<Sheet
|
||||||
|
open={editingEntry !== null}
|
||||||
|
onclose={() => (editingEntry = null)}
|
||||||
|
title="Edit entry"
|
||||||
|
>
|
||||||
{#if editingEntry}
|
{#if editingEntry}
|
||||||
<form onsubmit={handleEditEntry} class="space-y-4">
|
<form onsubmit={handleEditEntry} class="space-y-4">
|
||||||
<p class="text-sm text-zinc-400">{editingEntry.entry.product.name}</p>
|
<p class="text-sm text-zinc-400">{editingEntry.entry.product.name}</p>
|
||||||
<div>
|
<div>
|
||||||
<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">
|
<div class="flex items-center gap-3">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
onclick={() => { editGrams = Math.max(1, editGrams - 10); editGramsInput?.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">−</button>
|
editGrams = Math.max(1, editGrams - 10);
|
||||||
|
editGramsInput?.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
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
bind:this={editGramsInput}
|
bind:this={editGramsInput}
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -370,10 +561,16 @@
|
||||||
onfocus={(e) => e.currentTarget.select()}
|
onfocus={(e) => e.currentTarget.select()}
|
||||||
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"
|
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 type="button"
|
<button
|
||||||
|
type="button"
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
onclick={() => { editGrams = editGrams + 10; editGramsInput?.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">+</button>
|
editGrams = editGrams + 10;
|
||||||
|
editGramsInput?.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -381,14 +578,21 @@
|
||||||
disabled={editSaving || editGrams < 1}
|
disabled={editSaving || editGrams < 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"
|
||||||
>
|
>
|
||||||
{editSaving ? 'Saving…' : 'Save'}
|
{editSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<!-- Add entry to preset sheet -->
|
<!-- Add entry to preset sheet -->
|
||||||
<Sheet open={addEntryPresetId !== null} onclose={() => { addEntryPresetId = null; selectedProduct = null; }} title="Add food to preset">
|
<Sheet
|
||||||
|
open={addEntryPresetId !== null}
|
||||||
|
onclose={() => {
|
||||||
|
addEntryPresetId = null;
|
||||||
|
selectedProduct = null;
|
||||||
|
}}
|
||||||
|
title="Add food to preset"
|
||||||
|
>
|
||||||
{#if addEntryPresetId !== null}
|
{#if addEntryPresetId !== null}
|
||||||
{#if selectedProduct === null}
|
{#if selectedProduct === null}
|
||||||
<!-- Product search -->
|
<!-- Product search -->
|
||||||
|
|
@ -404,16 +608,27 @@
|
||||||
{#if productsQuery.isPending && productDebouncedQ}
|
{#if productsQuery.isPending && productDebouncedQ}
|
||||||
<p class="text-sm text-zinc-500 text-center py-4">Searching…</p>
|
<p class="text-sm text-zinc-500 text-center py-4">Searching…</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="max-h-64 overflow-y-auto divide-y divide-zinc-800/50 -mx-4">
|
<ul
|
||||||
|
class="max-h-64 overflow-y-auto divide-y divide-zinc-800/50 -mx-4"
|
||||||
|
>
|
||||||
{#each productsQuery.data ?? [] as product (product.id)}
|
{#each productsQuery.data ?? [] as product (product.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
onclick={() => { selectedProduct = product; addGrams = 100; }}
|
onclick={() => {
|
||||||
|
selectedProduct = product;
|
||||||
|
addGrams = 100;
|
||||||
|
}}
|
||||||
class="w-full flex items-center justify-between px-4 py-3 hover:bg-zinc-800 transition-colors text-left"
|
class="w-full flex items-center justify-between px-4 py-3 hover:bg-zinc-800 transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium truncate capitalize">{product.name}</p>
|
<p class="text-sm font-medium truncate capitalize">
|
||||||
<p class="text-xs text-zinc-500">{kcal(product.calories)} kcal · P {g(product.protein)}g · C {g(product.carb)}g · F {g(product.fat)}g <span class="text-zinc-600">/ 100g</span></p>
|
{product.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-zinc-500">
|
||||||
|
{kcal(product.calories)} kcal · P {g(product.protein)}g ·
|
||||||
|
C {g(product.carb)}g · F {g(product.fat)}g
|
||||||
|
<span class="text-zinc-600">/ 100g</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -425,16 +640,26 @@
|
||||||
<!-- Grams input -->
|
<!-- Grams input -->
|
||||||
<form onsubmit={handleAddEntry} class="space-y-4">
|
<form onsubmit={handleAddEntry} class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium capitalize mb-0.5">{selectedProduct.name}</p>
|
<p class="text-sm font-medium capitalize mb-0.5">
|
||||||
<p class="text-xs text-zinc-500">{kcal(selectedProduct.calories)} kcal / 100g</p>
|
{selectedProduct.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-zinc-500">
|
||||||
|
{kcal(selectedProduct.calories)} kcal / 100g
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<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">
|
<div class="flex items-center gap-3">
|
||||||
<button type="button"
|
<button
|
||||||
|
type="button"
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
onclick={() => { addGrams = Math.max(1, addGrams - 10); addGramsInput?.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">−</button>
|
addGrams = Math.max(1, addGrams - 10);
|
||||||
|
addGramsInput?.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
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
bind:this={addGramsInput}
|
bind:this={addGramsInput}
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -445,15 +670,24 @@
|
||||||
onfocus={(e) => e.currentTarget.select()}
|
onfocus={(e) => e.currentTarget.select()}
|
||||||
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"
|
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 type="button"
|
<button
|
||||||
|
type="button"
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
onclick={() => { addGrams = addGrams + 10; addGramsInput?.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">+</button>
|
addGrams = addGrams + 10;
|
||||||
|
addGramsInput?.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="button" onclick={() => selectedProduct = null}
|
<button
|
||||||
class="flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors">
|
type="button"
|
||||||
|
onclick={() => (selectedProduct = null)}
|
||||||
|
class="flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -461,7 +695,7 @@
|
||||||
disabled={addSaving || addGrams < 1}
|
disabled={addSaving || addGrams < 1}
|
||||||
class="flex-1 bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
class="flex-1 bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
{addSaving ? 'Adding…' : 'Add'}
|
{addSaving ? "Adding…" : "Add"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue