[preset] better handling
This commit is contained in:
parent
eece92f1e6
commit
f68731a9b4
7 changed files with 465 additions and 82 deletions
|
|
@ -1,11 +1,27 @@
|
||||||
import { apiGet, apiDelete } from './client';
|
import { apiGet, apiDelete, apiPatch, apiPost } from './client';
|
||||||
import type { Preset } 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 renamePreset(id: number, name: string): Promise<Preset> {
|
||||||
|
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> {
|
||||||
|
return apiPost<PresetEntry>(`/preset/${presetId}/entry`, { product_id: productId, grams });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePresetEntry(presetId: number, entryId: number, grams: number): Promise<PresetEntry> {
|
||||||
|
return apiPatch<PresetEntry>(`/preset/${presetId}/entry/${entryId}`, { grams });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePresetEntry(presetId: number, entryId: number): Promise<void> {
|
||||||
|
return apiDelete(`/preset/${presetId}/entry/${entryId}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
fat_goal: number;
|
fat_goal: number;
|
||||||
fiber_goal: number;
|
fiber_goal: number;
|
||||||
}>({
|
}>({
|
||||||
calories_goal: diary.calories_goal || null,
|
calories_goal: null,
|
||||||
protein_goal: diary.protein_goal,
|
protein_goal: diary.protein_goal,
|
||||||
carb_goal: diary.carb_goal,
|
carb_goal: diary.carb_goal,
|
||||||
fat_goal: diary.fat_goal,
|
fat_goal: diary.fat_goal,
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
form = {
|
form = {
|
||||||
calories_goal: diary.calories_goal || null,
|
calories_goal: null, // always reset to auto when diary refreshes
|
||||||
protein_goal: diary.protein_goal,
|
protein_goal: diary.protein_goal,
|
||||||
carb_goal: diary.carb_goal,
|
carb_goal: diary.carb_goal,
|
||||||
fat_goal: diary.fat_goal,
|
fat_goal: diary.fat_goal,
|
||||||
|
|
@ -68,11 +68,13 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-zinc-900 rounded-2xl p-4">
|
<div class="bg-zinc-900 rounded-2xl p-4 relative">
|
||||||
<div class="flex items-center gap-5">
|
<!-- Mobile: ring left, bars right. Desktop: ring top-center, bars below -->
|
||||||
|
<div class="flex items-center gap-5 lg:flex-col lg:items-stretch lg:gap-4">
|
||||||
|
|
||||||
<!-- Calorie ring -->
|
<!-- Calorie ring -->
|
||||||
<div class="relative w-24 h-24 shrink-0">
|
<div class="relative w-24 h-24 shrink-0 lg:w-32 lg:h-32 lg:mx-auto">
|
||||||
<svg class="w-24 h-24 -rotate-90" viewBox="0 0 100 100">
|
<svg class="w-full h-full -rotate-90" viewBox="0 0 100 100">
|
||||||
<circle cx="50" cy="50" r="40" fill="none" stroke="#27272a" stroke-width="10" />
|
<circle cx="50" cy="50" r="40" fill="none" stroke="#27272a" stroke-width="10" />
|
||||||
<circle
|
<circle
|
||||||
cx="50" cy="50" r="40" fill="none"
|
cx="50" cy="50" r="40" fill="none"
|
||||||
|
|
@ -83,7 +85,7 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
<span class="text-xl font-bold leading-none">{kcal(diary.calories)}</span>
|
<span class="text-xl font-bold leading-none lg:text-2xl">{kcal(diary.calories)}</span>
|
||||||
<span class="text-xs text-zinc-500">
|
<span class="text-xs text-zinc-500">
|
||||||
{diary.calories_goal > 0 ? `/ ${kcal(diary.calories_goal)}` : 'kcal'}
|
{diary.calories_goal > 0 ? `/ ${kcal(diary.calories_goal)}` : 'kcal'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -113,7 +115,7 @@
|
||||||
<!-- Edit goals button -->
|
<!-- Edit goals button -->
|
||||||
<button
|
<button
|
||||||
onclick={() => sheetOpen = true}
|
onclick={() => sheetOpen = true}
|
||||||
class="self-start shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
|
class="self-start shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors lg:absolute lg:top-3 lg:right-3"
|
||||||
aria-label="Edit day goals"
|
aria-label="Edit day goals"
|
||||||
>
|
>
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -125,24 +127,26 @@
|
||||||
|
|
||||||
<Sheet open={sheetOpen} onclose={() => sheetOpen = false} title="Day goals">
|
<Sheet open={sheetOpen} onclose={() => sheetOpen = false} title="Day goals">
|
||||||
<form onsubmit={handleSave} class="space-y-4">
|
<form onsubmit={handleSave} class="space-y-4">
|
||||||
<!-- Calories: nullable — empty = auto-calculated by server from macros -->
|
<!-- Calories: null = auto-calculated by server from macros -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-zinc-400 mb-1.5">
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
Calories <span class="text-zinc-600">(kcal)</span>
|
<label class="text-sm text-zinc-400">Calories <span class="text-zinc-600">(kcal)</span></label>
|
||||||
</label>
|
{#if form.calories_goal === null}
|
||||||
|
<span class="text-xs font-medium text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded-full">auto</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="Auto"
|
value={form.calories_goal ?? diary.calories_goal}
|
||||||
value={form.calories_goal ?? ''}
|
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
const v = e.currentTarget.value;
|
const v = e.currentTarget.value;
|
||||||
form.calories_goal = v === '' ? null : Number(v);
|
form.calories_goal = v === '' ? null : Number(v);
|
||||||
}}
|
}}
|
||||||
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors"
|
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-zinc-600 mt-1">Leave empty to auto-calculate from macro goals</p>
|
<p class="text-xs text-zinc-600 mt-1">Clear to auto-calculate from macro goals</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each [
|
{#each [
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
let renameOpen = $state(false);
|
let renameOpen = $state(false);
|
||||||
let renameName = $state('');
|
let renameName = $state('');
|
||||||
let renaming = $state(false);
|
let renaming = $state(false);
|
||||||
|
let presetOpen = $state(false);
|
||||||
|
let presetName = $state('');
|
||||||
|
|
||||||
function openRename() {
|
function openRename() {
|
||||||
renameName = meal.name;
|
renameName = meal.name;
|
||||||
|
|
@ -46,11 +48,18 @@
|
||||||
queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSavePreset() {
|
function openPresetSheet() {
|
||||||
|
presetName = meal.name;
|
||||||
|
presetOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePreset(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await saveMealAsPreset(date, meal.id);
|
await saveMealAsPreset(date, meal.id, presetName.trim() || meal.name);
|
||||||
await new Promise(r => setTimeout(r, 600));
|
queryClient.invalidateQueries({ queryKey: ['presets'] });
|
||||||
|
presetOpen = false;
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +120,7 @@
|
||||||
|
|
||||||
<!-- Save as preset -->
|
<!-- Save as preset -->
|
||||||
<button
|
<button
|
||||||
onclick={handleSavePreset}
|
onclick={openPresetSheet}
|
||||||
disabled={saving || meal.entries.length === 0}
|
disabled={saving || meal.entries.length === 0}
|
||||||
class="w-8 h-8 flex items-center justify-center rounded-full text-zinc-500 hover:text-yellow-400 hover:bg-yellow-400/10 disabled:opacity-30 transition-colors"
|
class="w-8 h-8 flex items-center justify-center rounded-full text-zinc-500 hover:text-yellow-400 hover:bg-yellow-400/10 disabled:opacity-30 transition-colors"
|
||||||
aria-label="Save as preset"
|
aria-label="Save as preset"
|
||||||
|
|
@ -162,6 +171,28 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Sheet open={presetOpen} onclose={() => presetOpen = false} title="Save as preset">
|
||||||
|
<form onsubmit={handleSavePreset} class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-zinc-400 mb-1.5">Preset name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={presetName}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !presetName.trim()}
|
||||||
|
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save preset'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<Sheet open={renameOpen} onclose={() => renameOpen = false} title="Rename meal">
|
<Sheet open={renameOpen} onclose={() => renameOpen = false} title="Rename meal">
|
||||||
<form onsubmit={handleRename} class="space-y-4">
|
<form onsubmit={handleRename} class="space-y-4">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,8 @@
|
||||||
<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
|
||||||
onclick={() => grams = Math.max(1, grams - 10)}
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
|
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
|
||||||
|
|
@ -245,11 +246,13 @@
|
||||||
min="1"
|
min="1"
|
||||||
max="5000"
|
max="5000"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
|
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
|
||||||
onclick={() => grams = grams + 10}
|
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"
|
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>
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,8 @@
|
||||||
<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
|
<button
|
||||||
onclick={() => grams = Math.max(1, grams - 10)}
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
|
onclick={() => { grams = Math.max(1, grams - 10); gramsInput?.focus(); }}
|
||||||
class="w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
|
class="w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
|
||||||
>−</button>
|
>−</button>
|
||||||
<input
|
<input
|
||||||
|
|
@ -124,11 +125,13 @@
|
||||||
min="1"
|
min="1"
|
||||||
max="5000"
|
max="5000"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
|
onfocus={(e) => e.currentTarget.select()}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') handleSave(); }}
|
onkeydown={(e) => { if (e.key === 'Enter') handleSave(); }}
|
||||||
class="flex-1 bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-center text-2xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
|
class="flex-1 bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-center text-2xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onclick={() => grams = grams + 10}
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
|
onclick={() => { grams = grams + 10; gramsInput?.focus(); }}
|
||||||
class="w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
|
class="w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
|
||||||
>+</button>
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { listPresets, deletePreset } from '$lib/api/presets';
|
import { listPresets, deletePreset, renamePreset, createPresetEntry, updatePresetEntry, deletePresetEntry } from '$lib/api/presets';
|
||||||
|
import { listProducts } from '$lib/api/products';
|
||||||
import { createMealFromPreset } from '$lib/api/meals';
|
import { createMealFromPreset } from '$lib/api/meals';
|
||||||
import { today } from '$lib/utils/date';
|
import { today } from '$lib/utils/date';
|
||||||
import { kcal, g } from '$lib/utils/format';
|
import { kcal, g } from '$lib/utils/format';
|
||||||
import TopBar from '$lib/components/ui/TopBar.svelte';
|
import TopBar from '$lib/components/ui/TopBar.svelte';
|
||||||
import type { Preset } from '$lib/types/api';
|
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 ───────────────────────────────────────────────────────
|
||||||
let q = $state('');
|
let q = $state('');
|
||||||
let debouncedQ = $state('');
|
let debouncedQ = $state('');
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
let addingId = $state<number | null>(null);
|
|
||||||
let addedId = $state<number | null>(null);
|
|
||||||
let deletingId = $state<number | null>(null);
|
|
||||||
|
|
||||||
function handleSearch(value: string) {
|
function handleSearch(value: string) {
|
||||||
q = value;
|
q = value;
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
const presetsQuery = createQuery(() => ({
|
const presetsQuery = createQuery(() => ({
|
||||||
queryKey: ['presets'],
|
queryKey: ['presets'],
|
||||||
queryFn: () => listPresets(50)
|
queryFn: () => listPresets(100),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const filteredPresets = $derived(
|
const filteredPresets = $derived(
|
||||||
|
|
@ -33,6 +33,17 @@
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── 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) {
|
async function handleAddToToday(preset: Preset) {
|
||||||
addingId = preset.id;
|
addingId = preset.id;
|
||||||
try {
|
try {
|
||||||
|
|
@ -45,16 +56,117 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Delete preset ─────────────────────────────────────────────────────────
|
||||||
|
let deletingId = $state<number | null>(null);
|
||||||
|
|
||||||
async function handleDelete(preset: Preset) {
|
async function handleDelete(preset: Preset) {
|
||||||
if (!confirm(`Delete preset "${preset.name}"?`)) return;
|
if (!confirm(`Delete preset "${preset.name}"?`)) return;
|
||||||
deletingId = preset.id;
|
deletingId = preset.id;
|
||||||
try {
|
try {
|
||||||
await deletePreset(preset.id);
|
await deletePreset(preset.id);
|
||||||
|
if (expandedId === preset.id) expandedId = null;
|
||||||
queryClient.invalidateQueries({ queryKey: ['presets'] });
|
queryClient.invalidateQueries({ queryKey: ['presets'] });
|
||||||
} finally {
|
} finally {
|
||||||
deletingId = null;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-screen">
|
<div class="flex flex-col h-screen">
|
||||||
|
|
@ -93,56 +205,266 @@
|
||||||
{: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)}
|
||||||
<li class="flex items-center justify-between px-4 py-4">
|
<li>
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Preset header row -->
|
||||||
<p class="font-medium truncate">{preset.name}</p>
|
<div class="flex items-center gap-2 px-4 py-3">
|
||||||
<p class="text-xs text-zinc-500 mt-0.5">
|
<!-- Expand toggle + name -->
|
||||||
{kcal(preset.calories)} kcal · P {g(preset.protein)}g · C {g(preset.carb)}g · F {g(preset.fat)}g
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-1 ml-3 shrink-0">
|
|
||||||
<!-- Add to today -->
|
|
||||||
<button
|
<button
|
||||||
onclick={() => handleAddToToday(preset)}
|
onclick={() => toggleExpand(preset)}
|
||||||
disabled={addingId === preset.id}
|
class="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||||
class="flex items-center gap-1 px-3 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
|
||||||
<svg class="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="w-4 h-4 text-zinc-500 shrink-0 transition-transform {expandedId === preset.id ? '' : '-rotate-90'}"
|
||||||
<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" />
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
</svg>
|
>
|
||||||
{:else if addedId === preset.id}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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>
|
</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>
|
</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>
|
</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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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); 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
|
||||||
|
bind:this={editGramsInput}
|
||||||
|
type="number"
|
||||||
|
bind:value={editGrams}
|
||||||
|
min="1"
|
||||||
|
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; 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>
|
||||||
|
<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); 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
|
||||||
|
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; 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 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>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
fat_goal: number;
|
fat_goal: number;
|
||||||
fiber_goal: number;
|
fiber_goal: number;
|
||||||
}>({
|
}>({
|
||||||
calories_goal: null,
|
calories_goal: null as number | null,
|
||||||
protein_goal: 0,
|
protein_goal: 0,
|
||||||
carb_goal: 0,
|
carb_goal: 0,
|
||||||
fat_goal: 0,
|
fat_goal: 0,
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (settingsQuery.data) {
|
if (settingsQuery.data) {
|
||||||
form = {
|
form = {
|
||||||
calories_goal: settingsQuery.data.calories_goal || null,
|
calories_goal: null, // always reset to auto when settings refresh
|
||||||
protein_goal: settingsQuery.data.protein_goal,
|
protein_goal: settingsQuery.data.protein_goal,
|
||||||
carb_goal: settingsQuery.data.carb_goal,
|
carb_goal: settingsQuery.data.carb_goal,
|
||||||
fat_goal: settingsQuery.data.fat_goal,
|
fat_goal: settingsQuery.data.fat_goal,
|
||||||
|
|
@ -76,23 +76,27 @@
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Daily goals</h2>
|
<h2 class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Daily goals</h2>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Calories: nullable — empty = auto-calculated by server from macros -->
|
<!-- Calories: null = auto-calculated by server from macros -->
|
||||||
<div class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4">
|
<div class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm font-medium">Calories</p>
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-xs text-zinc-500">kcal · leave empty to auto-calculate</p>
|
<p class="text-sm font-medium">Calories</p>
|
||||||
|
{#if form.calories_goal === null}
|
||||||
|
<span class="text-xs font-medium text-zinc-500 bg-zinc-800 border border-zinc-700 px-1.5 py-0.5 rounded-full">auto</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-zinc-500">kcal · clear to auto-calculate</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="Auto"
|
value={form.calories_goal ?? settingsQuery.data?.calories_goal ?? ''}
|
||||||
value={form.calories_goal ?? ''}
|
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
const v = e.currentTarget.value;
|
const v = e.currentTarget.value;
|
||||||
form.calories_goal = v === '' ? null : Number(v);
|
form.calories_goal = v === '' ? null : Number(v);
|
||||||
}}
|
}}
|
||||||
class="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-right text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors"
|
class="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-right text-sm text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue