[api] updated

This commit is contained in:
Piotr Domański 2026-04-07 22:39:28 +02:00
parent 578e8089c7
commit eece92f1e6
22 changed files with 491 additions and 121 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here

View file

@ -12,6 +12,7 @@ services:
- ./vite.config.ts:/app/vite.config.ts - ./vite.config.ts:/app/vite.config.ts
environment: environment:
- VITE_API_URL=http://host.docker.internal:8000 - VITE_API_URL=http://host.docker.internal:8000
- PUBLIC_TURNSTILE_SITE_KEY=key
#- VITE_API_URL=https://fooderapi.domandoman.xyz #- VITE_API_URL=https://fooderapi.domandoman.xyz
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View file

@ -1,5 +1,5 @@
import { auth } from '$lib/auth/store.svelte'; import { auth } from '$lib/auth/store.svelte';
import type { Token, User } from '$lib/types/api'; import type { Token } from '$lib/types/api';
const BASE = '/api'; const BASE = '/api';
@ -18,17 +18,18 @@ export async function login(username: string, password: string): Promise<void> {
auth.setTokens(token.access_token, token.refresh_token); auth.setTokens(token.access_token, token.refresh_token);
} }
export async function register(username: string, password: string): Promise<User> { export async function register(username: string, password: string, captchaToken: string): Promise<void> {
const res = await fetch(`${BASE}/user`, { const res = await fetch(`${BASE}/user`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password, captcha_token: captchaToken })
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw Object.assign(new Error('Registration failed'), { status: res.status, detail: err }); throw Object.assign(new Error('Registration failed'), { status: res.status, detail: err });
} }
return res.json(); const token: Token = await res.json();
auth.setTokens(token.access_token, token.refresh_token);
} }
export async function tryRestoreSession(): Promise<boolean> { export async function tryRestoreSession(): Promise<boolean> {

View file

@ -1,6 +1,23 @@
import { apiGet } from './client'; import { apiFetch, apiPost, apiPatch } from './client';
import type { Diary } from '$lib/types/api'; import type { Diary } from '$lib/types/api';
export function getDiary(date: string): Promise<Diary> { export async function getDiary(date: string): Promise<Diary | null> {
return apiGet<Diary>(`/diary?date=${date}`); const res = await apiFetch(`/diary/${date}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`GET /diary/${date} failed: ${res.status}`);
return res.json();
}
export function createDiary(date: string): Promise<Diary> {
return apiPost<Diary>('/diary', { date });
}
export function updateDiary(date: string, patch: {
protein_goal?: number | null;
carb_goal?: number | null;
fat_goal?: number | null;
fiber_goal?: number | null;
calories_goal?: number | null;
}): Promise<Diary> {
return apiPatch<Diary>(`/diary/${date}`, patch);
} }

View file

@ -1,14 +1,14 @@
import { apiPost, apiPatch, apiDelete } from './client'; import { apiPost, apiPatch, apiDelete } from './client';
import type { Entry } from '$lib/types/api'; import type { Entry } from '$lib/types/api';
export function createEntry(mealId: number, productId: number, grams: number): Promise<Entry> { export function createEntry(date: string, mealId: number, productId: number, grams: number): Promise<Entry> {
return apiPost<Entry>('/entry', { meal_id: mealId, product_id: productId, grams }); return apiPost<Entry>(`/diary/${date}/meal/${mealId}/entry`, { product_id: productId, grams });
} }
export function updateEntry(id: number, patch: { grams?: number; product_id?: number; meal_id?: number }): Promise<Entry> { export function updateEntry(date: string, mealId: number, id: number, patch: { grams?: number }): Promise<Entry> {
return apiPatch<Entry>(`/entry/${id}`, patch); return apiPatch<Entry>(`/diary/${date}/meal/${mealId}/entry/${id}`, patch);
} }
export function deleteEntry(id: number): Promise<void> { export function deleteEntry(date: string, mealId: number, id: number): Promise<void> {
return apiDelete(`/entry/${id}`); return apiDelete(`/diary/${date}/meal/${mealId}/entry/${id}`);
} }

View file

@ -1,25 +1,24 @@
import { apiPost, apiDelete, apiPatch } from './client'; import { apiPost, apiDelete, apiPatch } from './client';
import type { Meal, Preset } from '$lib/types/api'; import type { Meal, Preset } from '$lib/types/api';
export function createMeal(diaryId: number, name?: string): Promise<Meal> { export function createMeal(date: string, name: string): Promise<Meal> {
return apiPost<Meal>('/meal', { diary_id: diaryId, ...(name ? { name } : {}) }); return apiPost<Meal>(`/diary/${date}/meal`, { name });
} }
export function renameMeal(id: number, name: string): Promise<Meal> { export function renameMeal(date: string, id: number, name: string): Promise<Meal> {
return apiPatch<Meal>(`/meal/${id}`, { name }); return apiPatch<Meal>(`/diary/${date}/meal/${id}`, { name });
} }
export function deleteMeal(id: number): Promise<void> { export function deleteMeal(date: string, id: number): Promise<void> {
return apiDelete(`/meal/${id}`); return apiDelete(`/diary/${date}/meal/${id}`);
} }
export function saveMealAsPreset(mealId: number, name?: string): Promise<Preset> { export function saveMealAsPreset(date: string, mealId: number, name?: string): Promise<Preset> {
return apiPost<Preset>(`/meal/${mealId}/save`, name ? { name } : {}); return apiPost<Preset>(`/diary/${date}/meal/${mealId}/preset`, name ? { name } : {});
} }
export function createMealFromPreset(diaryId: number, presetId: number, name?: string): Promise<Meal> { export function createMealFromPreset(date: string, presetId: number, name?: string): Promise<Meal> {
return apiPost<Meal>('/meal/from_preset', { return apiPost<Meal>(`/diary/${date}/meal/from_preset`, {
diary_id: diaryId,
preset_id: presetId, preset_id: presetId,
...(name ? { name } : {}) ...(name ? { name } : {})
}); });

View file

@ -1,13 +1,9 @@
import { apiGet, apiDelete } from './client'; import { apiGet, apiDelete } from './client';
import type { PresetList, PresetDetails } from '$lib/types/api'; import type { Preset } from '$lib/types/api';
export function listPresets(q = '', limit = 20, offset = 0): Promise<PresetList> { export function listPresets(limit = 20, offset = 0): Promise<Preset[]> {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) }); const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
return apiGet<PresetList>(`/preset?${params}`); return apiGet<Preset[]>(`/preset?${params}`);
}
export function getPreset(id: number): Promise<PresetDetails> {
return apiGet<PresetDetails>(`/preset/${id}`);
} }
export function deletePreset(id: number): Promise<void> { export function deletePreset(id: number): Promise<void> {

View file

@ -1,9 +1,9 @@
import { apiGet, apiPost } from './client'; import { apiGet, apiPost } from './client';
import type { Product, ProductList } from '$lib/types/api'; import type { Product } from '$lib/types/api';
export function listProducts(q = '', limit = 20, offset = 0): Promise<ProductList> { export function listProducts(q = '', limit = 20, offset = 0): Promise<Product[]> {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) }); const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) });
return apiGet<ProductList>(`/product?${params}`); return apiGet<Product[]>(`/product?${params}`);
} }
export function createProduct(data: { export function createProduct(data: {
@ -18,5 +18,5 @@ export function createProduct(data: {
} }
export function getProductByBarcode(barcode: string): Promise<Product> { export function getProductByBarcode(barcode: string): Promise<Product> {
return apiGet<Product>(`/product/by_barcode?barcode=${encodeURIComponent(barcode)}`); return apiGet<Product>(`/product/barcode/${encodeURIComponent(barcode)}`);
} }

10
src/lib/api/settings.ts Normal file
View file

@ -0,0 +1,10 @@
import { apiGet, apiPatch } from './client';
import type { UserSettings } from '$lib/types/api';
export function getUserSettings(): Promise<UserSettings> {
return apiGet<UserSettings>('/user/settings');
}
export function updateUserSettings(patch: Omit<Partial<UserSettings>, 'id' | 'calories_goal'> & { calories_goal?: number | null }): Promise<UserSettings> {
return apiPatch<UserSettings>('/user/settings', patch);
}

View file

@ -1,17 +1,71 @@
<script lang="ts"> <script lang="ts">
import type { Macros } from '$lib/types/api'; import type { Diary } from '$lib/types/api';
import { kcal, g } from '$lib/utils/format'; import { kcal, g } from '$lib/utils/format';
import { updateDiary } from '$lib/api/diary';
import { useQueryClient } from '@tanstack/svelte-query';
import Sheet from '$lib/components/ui/Sheet.svelte';
interface Props { interface Props {
macros: Macros; diary: Diary;
date: string;
} }
let { macros }: Props = $props(); let { diary, date }: Props = $props();
const CALORIE_GOAL = 2000; const queryClient = useQueryClient();
const pct = $derived(Math.min(100, Math.round((macros.calories / CALORIE_GOAL) * 100)));
// Calorie ring
const pct = $derived(diary.calories_goal > 0
? Math.min(100, Math.round((diary.calories / diary.calories_goal) * 100))
: 0);
const circumference = 2 * Math.PI * 40; const circumference = 2 * Math.PI * 40;
const dash = $derived(circumference * (pct / 100)); const dash = $derived(circumference * (pct / 100));
const macroRows = $derived([
{ label: 'Protein', value: g(diary.protein), goal: diary.protein_goal, color: 'bg-blue-500' },
{ label: 'Carbs', value: g(diary.carb), goal: diary.carb_goal, color: 'bg-yellow-500' },
{ label: 'Fat', value: g(diary.fat), goal: diary.fat_goal, color: 'bg-orange-500' },
{ label: 'Fiber', value: g(diary.fiber), goal: diary.fiber_goal, color: 'bg-green-500' },
]);
// Goal editing sheet
let sheetOpen = $state(false);
let saving = $state(false);
let form = $state<{
calories_goal: number | null;
protein_goal: number;
carb_goal: number;
fat_goal: number;
fiber_goal: number;
}>({
calories_goal: diary.calories_goal || null,
protein_goal: diary.protein_goal,
carb_goal: diary.carb_goal,
fat_goal: diary.fat_goal,
fiber_goal: diary.fiber_goal,
});
$effect(() => {
form = {
calories_goal: diary.calories_goal || null,
protein_goal: diary.protein_goal,
carb_goal: diary.carb_goal,
fat_goal: diary.fat_goal,
fiber_goal: diary.fiber_goal,
};
});
async function handleSave(e: SubmitEvent) {
e.preventDefault();
saving = true;
try {
await updateDiary(date, form);
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
sheetOpen = false;
} finally {
saving = false;
}
}
</script> </script>
<div class="bg-zinc-900 rounded-2xl p-4"> <div class="bg-zinc-900 rounded-2xl p-4">
@ -29,32 +83,92 @@
/> />
</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(macros.calories)}</span> <span class="text-xl font-bold leading-none">{kcal(diary.calories)}</span>
<span class="text-xs text-zinc-500">kcal</span> <span class="text-xs text-zinc-500">
{diary.calories_goal > 0 ? `/ ${kcal(diary.calories_goal)}` : 'kcal'}
</span>
</div> </div>
</div> </div>
<!-- Macro bars --> <!-- Macro bars -->
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2 min-w-0">
{#each [ {#each macroRows as macro}
{ label: 'Protein', value: g(macros.protein), color: 'bg-blue-500', max: 150 },
{ label: 'Carbs', value: g(macros.carb), color: 'bg-yellow-500', max: 300 },
{ label: 'Fat', value: g(macros.fat), color: 'bg-orange-500', max: 100 },
{ label: 'Fiber', value: g(macros.fiber), color: 'bg-green-500', max: 40 }
] as macro}
<div> <div>
<div class="flex justify-between text-xs text-zinc-400 mb-1"> <div class="flex justify-between text-xs text-zinc-400 mb-1">
<span>{macro.label}</span> <span>{macro.label}</span>
<span class="font-medium text-zinc-200">{macro.value}g</span> <span class="font-medium text-zinc-200">
{macro.value}g{macro.goal > 0 ? ` / ${macro.goal}g` : ''}
</span>
</div> </div>
<div class="h-1.5 bg-zinc-800 rounded-full overflow-hidden"> <div class="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div <div
class="h-full {macro.color} rounded-full transition-all duration-500" class="h-full {macro.color} rounded-full transition-all duration-500"
style="width: {Math.min(100, (macro.value / macro.max) * 100)}%" style="width: {macro.goal > 0 ? Math.min(100, (macro.value / macro.goal) * 100) : 0}%"
></div> ></div>
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
<!-- Edit goals button -->
<button
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"
aria-label="Edit day goals"
>
<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" 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>
</div> </div>
</div> </div>
<Sheet open={sheetOpen} onclose={() => sheetOpen = false} title="Day goals">
<form onsubmit={handleSave} class="space-y-4">
<!-- Calories: nullable — empty = auto-calculated by server from macros -->
<div>
<label class="block text-sm text-zinc-400 mb-1.5">
Calories <span class="text-zinc-600">(kcal)</span>
</label>
<input
type="number"
min="0"
step="1"
placeholder="Auto"
value={form.calories_goal ?? ''}
oninput={(e) => {
const v = e.currentTarget.value;
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"
/>
<p class="text-xs text-zinc-600 mt-1">Leave empty to auto-calculate from macro goals</p>
</div>
{#each [
{ label: 'Protein', key: 'protein_goal' as const, unit: 'g' },
{ label: 'Carbs', key: 'carb_goal' as const, unit: 'g' },
{ label: 'Fat', key: 'fat_goal' as const, unit: 'g' },
{ label: 'Fiber', key: 'fiber_goal' as const, unit: 'g' },
] as field}
<div>
<label class="block text-sm text-zinc-400 mb-1.5">{field.label} <span class="text-zinc-600">({field.unit})</span></label>
<input
type="number"
min="0"
step="1"
bind:value={form[field.key]}
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"
/>
</div>
{/each}
<button
type="submit"
disabled={saving}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
>
{saving ? 'Saving…' : 'Save'}
</button>
</form>
</Sheet>

View file

@ -11,10 +11,9 @@
interface Props { interface Props {
meal: Meal; meal: Meal;
date: string; date: string;
diaryId: number;
} }
let { meal, date, diaryId }: Props = $props(); let { meal, date }: Props = $props();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
let collapsed = $state(false); let collapsed = $state(false);
@ -33,7 +32,7 @@
if (!renameName.trim()) return; if (!renameName.trim()) return;
renaming = true; renaming = true;
try { try {
await renameMeal(meal.id, renameName.trim()); await renameMeal(date, meal.id, renameName.trim());
queryClient.invalidateQueries({ queryKey: ['diary', date] }); queryClient.invalidateQueries({ queryKey: ['diary', date] });
renameOpen = false; renameOpen = false;
} finally { } finally {
@ -43,14 +42,14 @@
async function handleDeleteMeal() { async function handleDeleteMeal() {
if (!confirm(`Delete meal "${meal.name}"?`)) return; if (!confirm(`Delete meal "${meal.name}"?`)) return;
await deleteMeal(meal.id); await deleteMeal(date, meal.id);
queryClient.invalidateQueries({ queryKey: ['diary', date] }); queryClient.invalidateQueries({ queryKey: ['diary', date] });
} }
async function handleSavePreset() { async function handleSavePreset() {
saving = true; saving = true;
try { try {
await saveMealAsPreset(meal.id); await saveMealAsPreset(date, meal.id);
await new Promise(r => setTimeout(r, 600)); await new Promise(r => setTimeout(r, 600));
} finally { } finally {
saving = false; saving = false;
@ -58,7 +57,7 @@
} }
async function handleDeleteEntry(entryId: number) { async function handleDeleteEntry(entryId: number) {
await deleteEntry(entryId); await deleteEntry(date, meal.id, entryId);
queryClient.invalidateQueries({ queryKey: ['diary', date] }); queryClient.invalidateQueries({ queryKey: ['diary', date] });
} }

View file

@ -55,7 +55,7 @@ export async function offlineAddEntry(
grams: number grams: number
): Promise<void> { ): Promise<void> {
if (navigator.onLine) { if (navigator.onLine) {
await createEntry(mealId, product.id, grams); await createEntry(date, mealId, product.id, grams);
await queryClient.invalidateQueries({ queryKey: ['diary', date] }); await queryClient.invalidateQueries({ queryKey: ['diary', date] });
return; return;
} }
@ -64,13 +64,13 @@ export async function offlineAddEntry(
throw new Error('Cannot add entry to an unsaved meal — please sync first.'); throw new Error('Cannot add entry to an unsaved meal — please sync first.');
} }
await enqueueMutation({ method: 'POST', url: '/entry', body: { meal_id: mealId, product_id: product.id, grams } }); await enqueueMutation({ method: 'POST', url: `/diary/${date}/meal/${mealId}/entry`, body: { product_id: product.id, grams } });
network.incrementPending(); network.incrementPending();
const macros = macrosFromProduct(product, grams); const macros = macrosFromProduct(product, grams);
// Strip Svelte reactive proxy from product before storing/using in structured clone contexts // Strip Svelte reactive proxy from product before storing/using in structured clone contexts
const plainProduct: Product = JSON.parse(JSON.stringify(product)); const plainProduct: Product = JSON.parse(JSON.stringify(product));
const fakeEntry: Entry = { id: -Date.now(), grams, product: plainProduct, meal_id: mealId, ...macros }; const fakeEntry: Entry = { id: -Date.now(), grams, product_id: product.id, product: plainProduct, meal_id: mealId, ...macros };
queryClient.setQueryData<Diary>(['diary', date], diary => { queryClient.setQueryData<Diary>(['diary', date], diary => {
if (!diary) return diary; if (!diary) return diary;
@ -96,8 +96,11 @@ export async function offlineEditEntry(
entryId: number, entryId: number,
newGrams: number newGrams: number
): Promise<void> { ): Promise<void> {
const diary = queryClient.getQueryData<Diary>(['diary', date]);
const mealId = diary?.meals.find(m => m.entries.some(e => e.id === entryId))?.id;
if (navigator.onLine) { if (navigator.onLine) {
await updateEntry(entryId, { grams: newGrams }); await updateEntry(date, mealId!, entryId, { grams: newGrams });
await queryClient.invalidateQueries({ queryKey: ['diary', date] }); await queryClient.invalidateQueries({ queryKey: ['diary', date] });
return; return;
} }
@ -106,7 +109,7 @@ export async function offlineEditEntry(
throw new Error('Cannot edit an unsaved entry — please sync first.'); throw new Error('Cannot edit an unsaved entry — please sync first.');
} }
await enqueueMutation({ method: 'PATCH', url: `/entry/${entryId}`, body: { grams: newGrams } }); await enqueueMutation({ method: 'PATCH', url: `/diary/${date}/meal/${mealId}/entry/${entryId}`, body: { grams: newGrams } });
network.incrementPending(); network.incrementPending();
queryClient.setQueryData<Diary>(['diary', date], diary => { queryClient.setQueryData<Diary>(['diary', date], diary => {
@ -145,16 +148,15 @@ export async function offlineEditEntry(
export async function offlineAddMeal( export async function offlineAddMeal(
queryClient: QueryClient, queryClient: QueryClient,
date: string, date: string,
diaryId: number,
name: string name: string
): Promise<void> { ): Promise<void> {
if (navigator.onLine) { if (navigator.onLine) {
await createMeal(diaryId, name || undefined); await createMeal(date, name || 'Meal');
await queryClient.invalidateQueries({ queryKey: ['diary', date] }); await queryClient.invalidateQueries({ queryKey: ['diary', date] });
return; return;
} }
await enqueueMutation({ method: 'POST', url: '/meal', body: { diary_id: diaryId, name: name || undefined } }); await enqueueMutation({ method: 'POST', url: `/diary/${date}/meal`, body: { name: name || 'Meal' } });
network.incrementPending(); network.incrementPending();
const diary = queryClient.getQueryData<Diary>(['diary', date]); const diary = queryClient.getQueryData<Diary>(['diary', date]);
@ -163,7 +165,7 @@ export async function offlineAddMeal(
id: -Date.now(), id: -Date.now(),
name: name || `Meal ${order}`, name: name || `Meal ${order}`,
order, order,
diary_id: diaryId, diary_id: diary?.id ?? 0,
entries: [], entries: [],
calories: 0, protein: 0, carb: 0, fat: 0, fiber: 0, calories: 0, protein: 0, carb: 0, fat: 0, fiber: 0,
}; };

View file

@ -25,12 +25,13 @@ export interface Product {
fat: number; fat: number;
fiber: number; fiber: number;
barcode: string | null; barcode: string | null;
usage_count_cached: number | null; usage_count_cached?: number | null;
} }
export interface Entry { export interface Entry {
id: number; id: number;
grams: number; grams: number;
product_id: number;
product: Product; product: Product;
meal_id: number; meal_id: number;
calories: number; calories: number;
@ -56,6 +57,11 @@ export interface Meal {
export interface Diary { export interface Diary {
id: number; id: number;
date: string; date: string;
protein_goal: number;
carb_goal: number;
fat_goal: number;
fiber_goal: number;
calories_goal: number;
meals: Meal[]; meals: Meal[];
calories: number; calories: number;
protein: number; protein: number;
@ -67,20 +73,29 @@ export interface Diary {
export interface Preset { export interface Preset {
id: number; id: number;
name: string; name: string;
user_id: number;
calories: number; calories: number;
protein: number; protein: number;
carb: number; carb: number;
fat: number; fat: number;
fiber: number; fiber: number;
entries: PresetEntry[];
} }
export interface PresetDetails extends Preset { export interface UserSettings {
preset_entries: PresetEntry[]; id: number;
protein_goal: number;
carb_goal: number;
fat_goal: number;
fiber_goal: number;
calories_goal: number;
} }
export interface PresetEntry { export interface PresetEntry {
id: number; id: number;
grams: number; grams: number;
product_id: number;
preset_id: number;
product: Product; product: Product;
calories: number; calories: number;
protein: number; protein: number;
@ -88,11 +103,3 @@ export interface PresetEntry {
fat: number; fat: number;
fiber: number; fiber: number;
} }
export interface ProductList {
products: Product[];
}
export interface PresetList {
presets: Preset[];
}

View file

@ -46,6 +46,7 @@ import { page } from '$app/state';
const isDiary = $derived(page.url.pathname.startsWith('/diary')); const isDiary = $derived(page.url.pathname.startsWith('/diary'));
const isPresets = $derived(page.url.pathname.startsWith('/presets')); const isPresets = $derived(page.url.pathname.startsWith('/presets'));
const isSettings = $derived(page.url.pathname.startsWith('/settings'));
</script> </script>
{#if auth.isAuthenticated} {#if auth.isAuthenticated}
@ -98,6 +99,16 @@ import { page } from '$app/state';
</svg> </svg>
Presets Presets
</a> </a>
<a
href="/settings"
class="flex items-center gap-2.5 px-3 py-2 rounded-xl text-sm font-medium transition-colors
{isSettings ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900'}"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
</nav> </nav>
<button <button
@ -138,6 +149,16 @@ import { page } from '$app/state';
</svg> </svg>
Presets Presets
</a> </a>
<a
href="/settings"
class="flex-1 flex flex-col items-center gap-1 py-3 text-xs font-medium transition-colors
{isSettings ? 'text-green-400' : 'text-zinc-500 hover:text-zinc-300'}"
>
<svg 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
<button <button
onclick={handleLogout} onclick={handleLogout}
class="flex-1 flex flex-col items-center gap-1 py-3 text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors" class="flex-1 flex flex-col items-center gap-1 py-3 text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors"

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getDiary } from '$lib/api/diary'; import { getDiary, createDiary } from '$lib/api/diary';
import { getCachedDiary, cacheDiary } from '$lib/offline/db'; import { getCachedDiary, cacheDiary } from '$lib/offline/db';
import { addDays, formatDisplay, today } from '$lib/utils/date'; import { addDays, formatDisplay, today } from '$lib/utils/date';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -14,11 +14,12 @@
import CommandPalette from '$lib/components/ui/CommandPalette.svelte'; import CommandPalette from '$lib/components/ui/CommandPalette.svelte';
import type { Command } from '$lib/components/ui/CommandPalette.svelte'; import type { Command } from '$lib/components/ui/CommandPalette.svelte';
const date = $derived(page.params.date); const date = $derived(page.params.date!);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
let calendarOpen = $state(false); let calendarOpen = $state(false);
let commandOpen = $state(false); let commandOpen = $state(false);
let creating = $state(false);
const commands = $derived<Command[]>([ const commands = $derived<Command[]>([
...(diaryQuery.data?.meals ?? []).map(meal => ({ ...(diaryQuery.data?.meals ?? []).map(meal => ({
@ -31,7 +32,7 @@
id: 'add-meal', id: 'add-meal',
label: 'Add meal', label: 'Add meal',
keywords: ['m', 'meal'], keywords: ['m', 'meal'],
action: () => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`) action: () => goto(`/diary/${date}/add-meal`)
} }
]); ]);
@ -64,11 +65,29 @@
// Online: fetch fresh data — this overwrites both TQ cache and IndexedDB, // Online: fetch fresh data — this overwrites both TQ cache and IndexedDB,
// replacing any temporary negative-ID entries from optimistic updates // replacing any temporary negative-ID entries from optimistic updates
const diary = await getDiary(date); const diary = await getDiary(date);
if (diary === null) {
if (date === today()) {
const created = await createDiary(date);
await cacheDiary(JSON.parse(JSON.stringify(created)));
return created;
}
return null;
}
await cacheDiary(JSON.parse(JSON.stringify(diary))); await cacheDiary(JSON.parse(JSON.stringify(diary)));
return diary; return diary;
} }
})); }));
async function handleCreate() {
creating = true;
try {
await createDiary(date);
queryClient.invalidateQueries({ queryKey: ['diary', date] });
} finally {
creating = false;
}
}
function goDate(delta: number) { function goDate(delta: number) {
goto(`/diary/${addDays(date, delta)}`); goto(`/diary/${addDays(date, delta)}`);
} }
@ -114,24 +133,36 @@
Retry Retry
</button> </button>
</div> </div>
{:else if diaryQuery.data === null}
<div class="text-center text-zinc-500 mt-20">
<p class="text-lg">No diary for {formatDisplay(date)}</p>
<p class="text-sm mt-1">No entries have been tracked for this date.</p>
<button
onclick={handleCreate}
disabled={creating}
class="mt-4 px-5 py-2.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white rounded-xl text-sm font-medium transition-colors"
>
{creating ? 'Creating…' : 'Create diary'}
</button>
</div>
{:else if diaryQuery.data} {:else if diaryQuery.data}
{@const diary = diaryQuery.data} {@const diary = diaryQuery.data}
<div class="space-y-4 lg:grid lg:grid-cols-[300px_1fr] lg:gap-6 lg:space-y-0 lg:items-start"> <div class="space-y-4 lg:grid lg:grid-cols-[300px_1fr] lg:gap-6 lg:space-y-0 lg:items-start">
<!-- Left: macros summary (sticky on desktop) --> <!-- Left: macros summary (sticky on desktop) -->
<div class="lg:sticky lg:top-[4.5rem]"> <div class="lg:sticky lg:top-[4.5rem]">
<MacroSummary macros={diary} /> <MacroSummary {diary} {date} />
</div> </div>
<!-- Right: meals --> <!-- Right: meals -->
<div class="space-y-4"> <div class="space-y-4">
{#each diary.meals as meal (meal.id)} {#each diary.meals as meal (meal.id)}
<MealCard {meal} {date} diaryId={diary.id} /> <MealCard {meal} {date} />
{/each} {/each}
<!-- Add meal button --> <!-- Add meal button -->
<button <button
onclick={() => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`)} onclick={() => goto(`/diary/${date}/add-meal`)}
class="w-full border border-dashed border-zinc-700 rounded-2xl py-4 text-zinc-500 hover:text-zinc-300 hover:border-zinc-500 transition-colors text-sm font-medium" class="w-full border border-dashed border-zinc-700 rounded-2xl py-4 text-zinc-500 hover:text-zinc-300 hover:border-zinc-500 transition-colors text-sm font-medium"
> >
+ Add meal + Add meal
@ -142,7 +173,7 @@
</main> </main>
<!-- FAB: Add entry (mobile only) --> <!-- FAB: Add entry (mobile only) -->
{#if diaryQuery.data} {#if diaryQuery.data != null}
<button <button
onclick={() => { onclick={() => {
const firstMeal = diaryQuery.data?.meals[0]; const firstMeal = diaryQuery.data?.meals[0];

View file

@ -12,7 +12,7 @@
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();
@ -49,13 +49,12 @@
queryKey: ['products', debouncedQ], queryKey: ['products', debouncedQ],
queryFn: async () => { queryFn: async () => {
if (!network.online) { if (!network.online) {
const products = await searchCachedProducts(debouncedQ); return searchCachedProducts(debouncedQ);
return { products };
} }
const result = await listProducts(debouncedQ, 30); const products = await listProducts(debouncedQ, 30);
// Cache for offline use — fire and forget // Cache for offline use — fire and forget
cacheProducts(result.products); cacheProducts(products);
return result; return products;
}, },
staleTime: 0 staleTime: 0
})); }));
@ -130,7 +129,7 @@
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?.products[0]; const first = productsQuery.data?.[0];
if (first) selectProduct(first); if (first) selectProduct(first);
} }
}} }}
@ -172,7 +171,7 @@
<div class="h-14 bg-zinc-900 rounded-xl animate-pulse mb-2"></div> <div class="h-14 bg-zinc-900 rounded-xl animate-pulse mb-2"></div>
{/each} {/each}
</div> </div>
{:else if productsQuery.data?.products.length === 0} {:else if productsQuery.data?.length === 0}
<div class="text-center text-zinc-500 mt-16 px-6"> <div class="text-center text-zinc-500 mt-16 px-6">
{#if !network.online} {#if !network.online}
<p class="text-base">No cached products match "{q}"</p> <p class="text-base">No cached products match "{q}"</p>
@ -187,7 +186,7 @@
</div> </div>
{:else} {:else}
<ul class="divide-y divide-zinc-800/50"> <ul class="divide-y divide-zinc-800/50">
{#each productsQuery.data?.products ?? [] as product (product.id)} {#each productsQuery.data ?? [] as product (product.id)}
<li> <li>
<button <button
onclick={() => selectProduct(product)} onclick={() => selectProduct(product)}

View file

@ -10,8 +10,7 @@
import TopBar from '$lib/components/ui/TopBar.svelte'; import TopBar from '$lib/components/ui/TopBar.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 diaryId = $derived(Number(page.url.searchParams.get('diary_id')));
const queryClient = useQueryClient(); const queryClient = useQueryClient();
type Tab = 'new' | 'preset'; type Tab = 'new' | 'preset';
@ -30,15 +29,15 @@
} }
const presetsQuery = createQuery(() => ({ const presetsQuery = createQuery(() => ({
queryKey: ['presets', presetDebounced], queryKey: ['presets'],
queryFn: () => listPresets(presetDebounced, 30) queryFn: () => listPresets(30)
})); }));
async function handleCreateNew(e: SubmitEvent) { async function handleCreateNew(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
submitting = true; submitting = true;
try { try {
await offlineAddMeal(queryClient, date, diaryId, mealName); await offlineAddMeal(queryClient, date, mealName);
goto(`/diary/${date}`); goto(`/diary/${date}`);
} finally { } finally {
submitting = false; submitting = false;
@ -49,7 +48,7 @@
submitting = true; submitting = true;
error = ''; error = '';
try { try {
await createMealFromPreset(diaryId, preset.id, preset.name); await createMealFromPreset(date, preset.id, preset.name);
await queryClient.invalidateQueries({ queryKey: ['diary', date] }); await queryClient.invalidateQueries({ queryKey: ['diary', date] });
goto(`/diary/${date}`); goto(`/diary/${date}`);
} catch { } catch {
@ -123,14 +122,14 @@
<div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div> <div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div>
{/each} {/each}
</div> </div>
{:else if presetsQuery.data?.presets.length === 0} {: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"> <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</p>
</div> </div>
{:else} {:else}
<ul class="divide-y divide-zinc-800/50"> <ul class="divide-y divide-zinc-800/50">
{#each presetsQuery.data?.presets ?? [] as preset (preset.id)} {#each (presetsQuery.data ?? []).filter(p => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase())) as preset (preset.id)}
<li> <li>
<button <button
onclick={() => handleFromPreset(preset)} onclick={() => handleFromPreset(preset)}

View file

@ -7,7 +7,7 @@
import { network } from '$lib/offline/network.svelte'; import { network } from '$lib/offline/network.svelte';
import TopBar from '$lib/components/ui/TopBar.svelte'; import TopBar from '$lib/components/ui/TopBar.svelte';
const date = $derived(page.params.date); const date = $derived(page.params.date!);
const entryId = $derived(Number(page.params.entry_id)); const entryId = $derived(Number(page.params.entry_id));
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -52,7 +52,7 @@
if (!confirm('Remove this entry?')) return; if (!confirm('Remove this entry?')) return;
deleting = true; deleting = true;
try { try {
await deleteEntry(entryId); await deleteEntry(date, entry!.meal_id, entryId);
await queryClient.invalidateQueries({ queryKey: ['diary', date] }); await queryClient.invalidateQueries({ queryKey: ['diary', date] });
goto(`/diary/${date}`); goto(`/diary/${date}`);
} finally { } finally {

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getDiary } from '$lib/api/diary'; import { getDiary, createDiary } from '$lib/api/diary';
import { getCachedDiary, cacheDiary } from '$lib/offline/db'; import { getCachedDiary, cacheDiary } from '$lib/offline/db';
import { addDays, formatDisplay, today } from '$lib/utils/date'; import { addDays, formatDisplay, today } from '$lib/utils/date';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -30,7 +30,7 @@
id: 'add-meal', id: 'add-meal',
label: 'Add meal', label: 'Add meal',
keywords: ['m', 'meal'], keywords: ['m', 'meal'],
action: () => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`) action: () => goto(`/diary/${date}/add-meal`)
} }
]); ]);
@ -59,6 +59,11 @@
throw new Error('Offline and no cached data'); throw new Error('Offline and no cached data');
} }
const diary = await getDiary(date); const diary = await getDiary(date);
if (diary === null) {
const created = await createDiary(date);
await cacheDiary(JSON.parse(JSON.stringify(created)));
return created;
}
await cacheDiary(JSON.parse(JSON.stringify(diary))); await cacheDiary(JSON.parse(JSON.stringify(diary)));
return diary; return diary;
} }
@ -115,18 +120,18 @@
<div class="space-y-4 lg:grid lg:grid-cols-[300px_1fr] lg:gap-6 lg:space-y-0 lg:items-start"> <div class="space-y-4 lg:grid lg:grid-cols-[300px_1fr] lg:gap-6 lg:space-y-0 lg:items-start">
<!-- Left: macros summary (sticky on desktop) --> <!-- Left: macros summary (sticky on desktop) -->
<div class="lg:sticky lg:top-[4.5rem]"> <div class="lg:sticky lg:top-[4.5rem]">
<MacroSummary macros={diary} /> <MacroSummary {diary} {date} />
</div> </div>
<!-- Right: meals --> <!-- Right: meals -->
<div class="space-y-4"> <div class="space-y-4">
{#each diary.meals as meal (meal.id)} {#each diary.meals as meal (meal.id)}
<MealCard {meal} {date} diaryId={diary.id} /> <MealCard {meal} {date} />
{/each} {/each}
<!-- Add meal button --> <!-- Add meal button -->
<button <button
onclick={() => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`)} onclick={() => goto(`/diary/${date}/add-meal`)}
class="w-full border border-dashed border-zinc-700 rounded-2xl py-4 text-zinc-500 hover:text-zinc-300 hover:border-zinc-500 transition-colors text-sm font-medium" class="w-full border border-dashed border-zinc-700 rounded-2xl py-4 text-zinc-500 hover:text-zinc-300 hover:border-zinc-500 transition-colors text-sm font-medium"
> >
+ Add meal + Add meal
@ -137,7 +142,7 @@
</main> </main>
<!-- FAB: Add entry (mobile only) --> <!-- FAB: Add entry (mobile only) -->
{#if diaryQuery.data} {#if diaryQuery.data != null}
<button <button
onclick={() => { onclick={() => {
const firstMeal = diaryQuery.data?.meals[0]; const firstMeal = diaryQuery.data?.meals[0];

View file

@ -1,7 +1,6 @@
<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 } from '$lib/api/presets';
import { getDiary } from '$lib/api/diary';
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';
@ -24,15 +23,20 @@
} }
const presetsQuery = createQuery(() => ({ const presetsQuery = createQuery(() => ({
queryKey: ['presets', debouncedQ], queryKey: ['presets'],
queryFn: () => listPresets(debouncedQ, 50) queryFn: () => listPresets(50)
})); }));
const filteredPresets = $derived(
(presetsQuery.data ?? []).filter(p =>
!debouncedQ || p.name.toLowerCase().includes(debouncedQ.toLowerCase())
)
);
async function handleAddToToday(preset: Preset) { async function handleAddToToday(preset: Preset) {
addingId = preset.id; addingId = preset.id;
try { try {
const diary = await getDiary(today()); await createMealFromPreset(today(), preset.id, preset.name);
await createMealFromPreset(diary.id, 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);
@ -81,14 +85,14 @@
</div> </div>
{:else if presetsQuery.isError} {:else if presetsQuery.isError}
<p class="text-center text-zinc-500 mt-16">Could not load presets</p> <p class="text-center text-zinc-500 mt-16">Could not load presets</p>
{:else if !presetsQuery.data?.presets.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</p>
</div> </div>
{:else} {:else}
<ul class="divide-y divide-zinc-800/50"> <ul class="divide-y divide-zinc-800/50">
{#each presetsQuery.data.presets as preset (preset.id)} {#each filteredPresets as preset (preset.id)}
<li class="flex items-center justify-between px-4 py-4"> <li class="flex items-center justify-between px-4 py-4">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="font-medium truncate">{preset.name}</p> <p class="font-medium truncate">{preset.name}</p>

View file

@ -0,0 +1,137 @@
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getUserSettings, updateUserSettings } from '$lib/api/settings';
import TopBar from '$lib/components/ui/TopBar.svelte';
import { today } from '$lib/utils/date';
const queryClient = useQueryClient();
const settingsQuery = createQuery(() => ({
queryKey: ['user-settings'],
queryFn: getUserSettings,
staleTime: 5 * 60 * 1000,
}));
let saving = $state(false);
let saved = $state(false);
let error = $state('');
let form = $state<{
calories_goal: number | null;
protein_goal: number;
carb_goal: number;
fat_goal: number;
fiber_goal: number;
}>({
calories_goal: null,
protein_goal: 0,
carb_goal: 0,
fat_goal: 0,
fiber_goal: 0,
});
$effect(() => {
if (settingsQuery.data) {
form = {
calories_goal: settingsQuery.data.calories_goal || null,
protein_goal: settingsQuery.data.protein_goal,
carb_goal: settingsQuery.data.carb_goal,
fat_goal: settingsQuery.data.fat_goal,
fiber_goal: settingsQuery.data.fiber_goal,
};
}
});
async function handleSave(e: SubmitEvent) {
e.preventDefault();
saving = true;
error = '';
try {
await updateUserSettings(form);
queryClient.invalidateQueries({ queryKey: ['user-settings'] });
saved = true;
setTimeout(() => { saved = false; }, 2000);
} catch {
error = 'Failed to save settings';
} finally {
saving = false;
}
}
</script>
<div class="flex flex-col h-screen">
<TopBar title="Settings" back="/diary/{today()}" />
<main class="flex-1 overflow-y-auto px-4 py-6">
{#if settingsQuery.isPending}
<div class="space-y-4">
{#each Array(5) as _}
<div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div>
{/each}
</div>
{:else if settingsQuery.isError}
<p class="text-center text-zinc-500 mt-16">Could not load settings</p>
{:else}
<form onsubmit={handleSave} class="space-y-6">
<section>
<h2 class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Daily goals</h2>
<div class="space-y-3">
<!-- Calories: nullable — empty = auto-calculated by server from macros -->
<div class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4">
<div class="flex-1">
<p class="text-sm font-medium">Calories</p>
<p class="text-xs text-zinc-500">kcal · leave empty to auto-calculate</p>
</div>
<input
type="number"
min="0"
step="1"
placeholder="Auto"
value={form.calories_goal ?? ''}
oninput={(e) => {
const v = e.currentTarget.value;
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"
/>
</div>
{#each [
{ label: 'Protein', key: 'protein_goal' as const, unit: 'g' },
{ label: 'Carbs', key: 'carb_goal' as const, unit: 'g' },
{ label: 'Fat', key: 'fat_goal' as const, unit: 'g' },
{ label: 'Fiber', key: 'fiber_goal' as const, unit: 'g' },
] as field}
<div class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4">
<div class="flex-1">
<p class="text-sm font-medium">{field.label}</p>
<p class="text-xs text-zinc-500">{field.unit} per day</p>
</div>
<input
type="number"
min="0"
step="1"
bind:value={form[field.key]}
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>
{/each}
</div>
</section>
{#if error}
<p class="text-red-400 text-sm">{error}</p>
{/if}
<button
type="submit"
disabled={saving}
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors
{saved ? 'bg-zinc-700 hover:bg-zinc-700' : ''}"
>
{saving ? 'Saving…' : saved ? 'Saved!' : 'Save'}
</button>
</form>
{/if}
</main>
</div>

View file

@ -1,13 +1,35 @@
<script lang="ts"> <script lang="ts">
import { register, login } 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 { today } from '$lib/utils/date';
import { onMount } from 'svelte';
import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
let username = $state(''); let username = $state('');
let password = $state(''); let password = $state('');
let confirm = $state(''); let confirm = $state('');
let error = $state(''); let error = $state('');
let loading = $state(false); let loading = $state(false);
let captchaToken = $state('');
let captchaContainer = $state<HTMLDivElement | null>(null);
onMount(() => {
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
script.onload = () => {
if (captchaContainer) {
(window as any).turnstile.render(captchaContainer, {
sitekey: PUBLIC_TURNSTILE_SITE_KEY,
callback: (token: string) => { captchaToken = token; },
'expired-callback': () => { captchaToken = ''; },
});
}
};
document.head.appendChild(script);
return () => { document.head.removeChild(script); };
});
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
@ -16,10 +38,13 @@
error = 'Passwords do not match'; error = 'Passwords do not match';
return; return;
} }
if (!captchaToken) {
error = 'Please complete the captcha';
return;
}
loading = true; loading = true;
try { try {
await register(username, password); await register(username, password, captchaToken);
await login(username, password);
goto(`/diary/${today()}`); goto(`/diary/${today()}`);
} catch (err: unknown) { } catch (err: unknown) {
const e2 = err as { detail?: { detail?: string } }; const e2 = err as { detail?: { detail?: string } };
@ -71,13 +96,15 @@
/> />
</div> </div>
<div bind:this={captchaContainer}></div>
{#if error} {#if error}
<p class="text-red-400 text-sm">{error}</p> <p class="text-red-400 text-sm">{error}</p>
{/if} {/if}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || !captchaToken}
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"
> >
{loading ? 'Creating account…' : 'Create account'} {loading ? 'Creating account…' : 'Create account'}