fooder-app/src/routes/(app)/presets/+page.svelte
2026-04-15 21:35:14 +02:00

701 lines
25 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 { createQuery, useQueryClient } from "@tanstack/svelte-query";
import {
listPresets,
createPreset,
deletePreset,
renamePreset,
createPresetEntry,
updatePresetEntry,
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();
// ── Search / filter ───────────────────────────────────────────────────────
let q = $state("");
let debouncedQ = $state("");
let debounceTimer: ReturnType<typeof setTimeout>;
function handleSearch(value: string) {
q = value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debouncedQ = value;
}, 300);
}
const presetsQuery = createQuery(() => ({
queryKey: ["presets"],
queryFn: () => listPresets(100),
}));
const filteredPresets = $derived(
(presetsQuery.data ?? []).filter(
(p) =>
!debouncedQ || p.name.toLowerCase().includes(debouncedQ.toLowerCase()),
),
);
// ── Expand/collapse ───────────────────────────────────────────────────────
let expandedId = $state<number | null>(null);
function toggleExpand(preset: Preset) {
expandedId = expandedId === preset.id ? null : preset.id;
}
// ── Add to today ──────────────────────────────────────────────────────────
let addingId = $state<number | null>(null);
let addedId = $state<number | null>(null);
async function handleAddToToday(preset: Preset) {
addingId = preset.id;
try {
await createMealFromPreset(today(), preset.id, preset.name);
queryClient.invalidateQueries({ queryKey: ["diary", today()] });
addedId = preset.id;
setTimeout(() => {
addedId = null;
}, 1500);
} finally {
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 ─────────────────────────────────────────────────────────
let deletingId = $state<number | null>(null);
async function handleDelete(preset: Preset) {
if (!confirm(`Delete preset "${preset.name}"?`)) return;
deletingId = preset.id;
try {
await deletePreset(preset.id);
if (expandedId === preset.id) expandedId = null;
queryClient.invalidateQueries({ queryKey: ["presets"] });
} finally {
deletingId = null;
}
}
// ── Rename preset ─────────────────────────────────────────────────────────
let renamePreset_: Preset | null = $state(null);
let renameName = $state("");
let renaming = $state(false);
function openRename(preset: Preset) {
renamePreset_ = preset;
renameName = preset.name;
}
async function handleRename(e: SubmitEvent) {
e.preventDefault();
if (!renamePreset_ || !renameName.trim()) return;
renaming = true;
try {
await renamePreset(renamePreset_.id, renameName.trim());
queryClient.invalidateQueries({ queryKey: ["presets"] });
renamePreset_ = null;
} finally {
renaming = false;
}
}
// ── Edit entry grams ──────────────────────────────────────────────────────
let editingEntry = $state<{ presetId: number; entry: PresetEntry } | null>(
null,
);
let editGrams = $state(0);
let editSaving = $state(false);
let editGramsInput = $state<HTMLInputElement | null>(null);
function openEditEntry(presetId: number, entry: PresetEntry) {
editingEntry = { presetId, entry };
editGrams = entry.grams;
}
async function handleEditEntry(e: SubmitEvent) {
e.preventDefault();
if (!editingEntry) return;
editSaving = true;
try {
await updatePresetEntry(
editingEntry.presetId,
editingEntry.entry.id,
editGrams,
);
queryClient.invalidateQueries({ queryKey: ["presets"] });
editingEntry = null;
} finally {
editSaving = false;
}
}
// ── Delete entry ──────────────────────────────────────────────────────────
async function handleDeleteEntry(presetId: number, entryId: number) {
await deletePresetEntry(presetId, entryId);
queryClient.invalidateQueries({ queryKey: ["presets"] });
}
// ── Add entry to preset ───────────────────────────────────────────────────
let addEntryPresetId = $state<number | null>(null);
let productQ = $state("");
let productDebouncedQ = $state("");
let productDebounceTimer: ReturnType<typeof setTimeout>;
let selectedProduct = $state<Product | null>(null);
let addGrams = $state(100);
let addSaving = $state(false);
let addGramsInput = $state<HTMLInputElement | null>(null);
function openAddEntry(presetId: number) {
addEntryPresetId = presetId;
productQ = "";
productDebouncedQ = "";
selectedProduct = null;
addGrams = 100;
}
function handleProductSearch(v: string) {
productQ = v;
clearTimeout(productDebounceTimer);
productDebounceTimer = setTimeout(() => {
productDebouncedQ = v;
}, 300);
}
const productsQuery = createQuery(() => ({
queryKey: ["products", productDebouncedQ],
queryFn: () => listProducts(productDebouncedQ, 30),
enabled: addEntryPresetId !== null,
staleTime: 30_000,
}));
async function handleAddEntry(e: SubmitEvent) {
e.preventDefault();
if (!addEntryPresetId || !selectedProduct) return;
addSaving = true;
try {
await createPresetEntry(addEntryPresetId, selectedProduct.id, addGrams);
queryClient.invalidateQueries({ queryKey: ["presets"] });
addEntryPresetId = null;
} finally {
addSaving = false;
}
}
</script>
<div class="flex flex-col h-screen">
<TopBar title="Presets" back="/diary/{today()}" />
<!-- Search -->
<div class="px-4 py-3 border-b border-zinc-800 bg-zinc-950">
<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"
>
<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 presets…"
value={q}
oninput={(e) => handleSearch(e.currentTarget.value)}
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>
</div>
<main class="flex-1 overflow-y-auto">
{#if presetsQuery.isPending}
<div class="space-y-2 p-4">
{#each Array(5) as _}
<div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div>
{/each}
</div>
{:else if presetsQuery.isError}
<p class="text-center text-zinc-500 mt-16">Could not load presets</p>
{:else if !filteredPresets.length}
<div class="text-center text-zinc-500 mt-16 px-6">
<p>No presets yet</p>
<p class="text-sm mt-1">Save a meal as preset from the diary view or</p>
</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}
<ul class="divide-y divide-zinc-800/50">
{#each filteredPresets as preset (preset.id)}
<li>
<!-- Preset header row -->
<div class="flex items-center gap-2 px-4 py-3">
<!-- Expand toggle + name -->
<button
onclick={() => toggleExpand(preset)}
class="flex-1 flex items-center gap-2 text-left min-w-0"
>
<svg
class="w-4 h-4 text-zinc-500 shrink-0 transition-transform {expandedId ===
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"
/>
</svg>
<div class="min-w-0">
<p class="font-medium truncate">{preset.name}</p>
<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
</p>
</div>
</button>
<!-- Action buttons -->
<div class="flex items-center gap-1 shrink-0">
<!-- Add to today -->
<button
onclick={() => handleAddToToday(preset)}
disabled={addingId === preset.id}
class="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors
{addedId === preset.id
? 'bg-green-600/20 text-green-400'
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-300 disabled:opacity-50'}"
>
{#if addingId === preset.id}
<svg
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>
{:else if addedId === preset.id}
<svg
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>
Added
{:else}
<svg
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>
Today
{/if}
</button>
<!-- Rename -->
<button
onclick={() => openRename(preset)}
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"
>
<svg
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>
</button>
<!-- Delete -->
<button
onclick={() => handleDelete(preset)}
disabled={deletingId === preset.id}
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"
>
<svg
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>
</button>
</div>
</div>
<!-- Expanded entries -->
{#if expandedId === preset.id}
<div class="px-4 pb-3 border-t border-zinc-800/60">
{#if preset.entries.length === 0}
<p class="text-sm text-zinc-600 py-3 text-center">
No entries yet
</p>
{:else}
<ul class="divide-y divide-zinc-800/50">
{#each preset.entries as entry (entry.id)}
<li class="flex items-center justify-between py-2.5">
<button
onclick={() => openEditEntry(preset.id, entry)}
class="flex-1 text-left min-w-0 group"
>
<p class="text-sm font-medium truncate">
{entry.product.name}
</p>
<p class="text-xs text-zinc-500">
{entry.grams}g · {kcal(entry.calories)} kcal
</p>
</button>
<button
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"
aria-label="Delete entry"
>
<svg
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>
</button>
</li>
{/each}
</ul>
{/if}
<!-- Add entry button -->
<button
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"
>
<svg
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>
Add food
</button>
</div>
{/if}
</li>
{/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>
{/if}
</main>
</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 -->
<Sheet
open={renamePreset_ !== null}
onclose={() => (renamePreset_ = null)}
title="Rename preset"
>
<form onsubmit={handleRename} class="space-y-4">
<input
type="text"
bind:value={renameName}
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={renaming || !renameName.trim()}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
{renaming ? "Saving…" : "Save"}
</button>
</form>
</Sheet>
<!-- Edit entry grams sheet -->
<Sheet
open={editingEntry !== null}
onclose={() => (editingEntry = null)}
title="Edit entry"
>
{#if editingEntry}
<form onsubmit={handleEditEntry} class="space-y-4">
<p class="text-sm text-zinc-400">{editingEntry.entry.product.name}</p>
<div>
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3">
<button
type="button"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
editGrams = Math.max(1, editGrams - 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
bind:this={editGramsInput}
type="number"
bind:value={editGrams}
min="1"
autofocus
inputmode="decimal"
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"
/>
<button
type="button"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
editGrams = editGrams + 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>
</div>
<button
type="submit"
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"
>
{editSaving ? "Saving…" : "Save"}
</button>
</form>
{/if}
</Sheet>
<!-- Add entry to preset sheet -->
<Sheet
open={addEntryPresetId !== null}
onclose={() => {
addEntryPresetId = null;
selectedProduct = null;
}}
title="Add food to preset"
>
{#if addEntryPresetId !== null}
{#if selectedProduct === null}
<!-- Product search -->
<div class="space-y-3">
<input
type="search"
placeholder="Search foods…"
value={productQ}
oninput={(e) => handleProductSearch(e.currentTarget.value)}
autofocus
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
/>
{#if productsQuery.isPending && productDebouncedQ}
<p class="text-sm text-zinc-500 text-center py-4">Searching…</p>
{:else}
<ul
class="max-h-64 overflow-y-auto divide-y divide-zinc-800/50 -mx-4"
>
{#each productsQuery.data ?? [] as product (product.id)}
<li>
<button
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"
>
<div class="min-w-0">
<p class="text-sm font-medium truncate capitalize">
{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>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{:else}
<!-- Grams input -->
<form onsubmit={handleAddEntry} class="space-y-4">
<div>
<p class="text-sm font-medium capitalize mb-0.5">
{selectedProduct.name}
</p>
<p class="text-xs text-zinc-500">
{kcal(selectedProduct.calories)} kcal / 100g
</p>
</div>
<div>
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
<div class="flex items-center gap-3">
<button
type="button"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
addGrams = Math.max(1, addGrams - 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
bind:this={addGramsInput}
type="number"
bind:value={addGrams}
min="1"
autofocus
inputmode="decimal"
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"
/>
<button
type="button"
onpointerdown={(e) => e.preventDefault()}
onclick={() => {
addGrams = addGrams + 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>
</div>
<div class="flex gap-2">
<button
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
</button>
<button
type="submit"
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"
>
{addSaving ? "Adding…" : "Add"}
</button>
</div>
</form>
{/if}
{/if}
</Sheet>