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

194 lines
6.6 KiB
Svelte

<script lang="ts">
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { createMealFromPreset } from "$lib/api/meals";
import { offlineAddMeal } from "$lib/offline/mutations";
import { network } from "$lib/offline/network.svelte";
import { listPresets } from "$lib/api/presets";
import type { Preset } from "$lib/types/api";
import TopBar from "$lib/components/ui/TopBar.svelte";
import { kcal, g } from "$lib/utils/format";
import { today } from "$lib/utils/date";
const date = $derived(
page.params.date === "today" ? today() : page.params.date!,
);
const queryClient = useQueryClient();
type Tab = "new" | "preset";
let tab = $state<Tab>("new");
let mealName = $state("");
let presetQ = $state("");
let presetDebounced = $state("");
let presetTimer: ReturnType<typeof setTimeout>;
let submitting = $state(false);
let error = $state("");
let mealNameInput = $state<HTMLInputElement | null>(null);
let presetQInput = $state<HTMLInputElement | null>(null);
$effect(() => {
if (tab === "new") setTimeout(() => mealNameInput?.focus(), 50);
else setTimeout(() => presetQInput?.focus(), 50);
});
function handlePresetSearch(v: string) {
presetQ = v;
clearTimeout(presetTimer);
presetTimer = setTimeout(() => {
presetDebounced = v;
}, 300);
}
const presetsQuery = createQuery(() => ({
queryKey: ["presets"],
queryFn: () => listPresets(30),
}));
async function handleCreateNew(e: SubmitEvent) {
e.preventDefault();
submitting = true;
try {
await offlineAddMeal(queryClient, date, mealName);
goto(`/diary/${date}`);
} catch {
// mutation was queued offline — navigate back anyway
goto(`/diary/${date}`);
} finally {
submitting = false;
}
}
async function handleFromPreset(preset: Preset) {
submitting = true;
error = "";
try {
await createMealFromPreset(date, preset.id, preset.name);
await queryClient.invalidateQueries({ queryKey: ["diary", date] });
goto(`/diary/${date}`);
} catch {
error = "Failed to add preset";
submitting = false;
}
}
</script>
<div class="flex flex-col h-screen">
<TopBar title="Add meal" back="/diary/{date}" />
<!-- Tabs -->
<div class="flex border-b border-zinc-800 bg-zinc-950">
{#each [{ id: "new", label: "New meal" }, { id: "preset", label: "From preset", offlineDisabled: true }] as t}
<button
onclick={() => (tab = t.id as Tab)}
disabled={t.offlineDisabled && !network.online}
class="flex-1 py-3 text-sm font-medium transition-colors border-b-2 disabled:opacity-40 {tab ===
t.id
? 'border-green-500 text-green-400'
: 'border-transparent text-zinc-500 hover:text-zinc-300'}"
>
{t.label}
</button>
{/each}
</div>
<main class="flex-1 overflow-y-auto">
{#if tab === "new"}
<form onsubmit={handleCreateNew} class="px-4 py-6 space-y-4">
<div>
<label for="meal-name" class="block text-sm text-zinc-400 mb-2"
>Meal name <span class="text-zinc-600">(optional)</span></label
>
<input
bind:this={mealNameInput}
id="meal-name"
type="text"
bind:value={mealName}
placeholder="e.g. Breakfast, Lunch…"
autofocus
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors"
/>
</div>
<button
type="submit"
disabled={submitting}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
{submitting
? "Creating…"
: network.online
? "Create meal"
: "Create meal (offline)"}
</button>
</form>
{:else}
<!-- Preset search -->
<div class="px-4 py-3 border-b border-zinc-800">
<input
bind:this={presetQInput}
type="search"
placeholder="Search presets…"
value={presetQ}
oninput={(e) => handlePresetSearch(e.currentTarget.value)}
autofocus
class="w-full bg-zinc-900 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"
/>
</div>
{#if error}
<p class="text-red-400 text-sm px-4 pt-3">{error}</p>
{/if}
{#if presetsQuery.isPending}
<div class="space-y-2 p-4">
{#each Array(4) as _}
<div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div>
{/each}
</div>
{:else if (presetsQuery.data ?? []).filter((p) => !presetDebounced || p.name
.toLowerCase()
.includes(presetDebounced.toLowerCase())).length === 0}
<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</p>
</div>
{:else}
<ul class="divide-y divide-zinc-800/50">
{#each (presetsQuery.data ?? []).filter((p) => !presetDebounced || p.name
.toLowerCase()
.includes(presetDebounced.toLowerCase())) as preset (preset.id)}
<li>
<button
onclick={() => handleFromPreset(preset)}
disabled={submitting}
class="w-full flex items-center justify-between px-4 py-4 hover:bg-zinc-900 transition-colors text-left disabled:opacity-50"
>
<div>
<p class="font-medium">{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>
<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}
{/if}
</main>
</div>