194 lines
6.6 KiB
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>
|