[api] updated
This commit is contained in:
parent
578e8089c7
commit
eece92f1e6
22 changed files with 491 additions and 121 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here
|
||||
|
|
@ -12,6 +12,7 @@ services:
|
|||
- ./vite.config.ts:/app/vite.config.ts
|
||||
environment:
|
||||
- VITE_API_URL=http://host.docker.internal:8000
|
||||
- PUBLIC_TURNSTILE_SITE_KEY=key
|
||||
#- VITE_API_URL=https://fooderapi.domandoman.xyz
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
|
|
@ -18,17 +18,18 @@ export async function login(username: string, password: string): Promise<void> {
|
|||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
body: JSON.stringify({ username, password, captcha_token: captchaToken })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
import { apiGet } from './client';
|
||||
import { apiFetch, apiPost, apiPatch } from './client';
|
||||
import type { Diary } from '$lib/types/api';
|
||||
|
||||
export function getDiary(date: string): Promise<Diary> {
|
||||
return apiGet<Diary>(`/diary?date=${date}`);
|
||||
export async function getDiary(date: string): Promise<Diary | null> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { apiPost, apiPatch, apiDelete } from './client';
|
||||
import type { Entry } from '$lib/types/api';
|
||||
|
||||
export function createEntry(mealId: number, productId: number, grams: number): Promise<Entry> {
|
||||
return apiPost<Entry>('/entry', { meal_id: mealId, product_id: productId, grams });
|
||||
export function createEntry(date: string, mealId: number, productId: number, grams: number): Promise<Entry> {
|
||||
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> {
|
||||
return apiPatch<Entry>(`/entry/${id}`, patch);
|
||||
export function updateEntry(date: string, mealId: number, id: number, patch: { grams?: number }): Promise<Entry> {
|
||||
return apiPatch<Entry>(`/diary/${date}/meal/${mealId}/entry/${id}`, patch);
|
||||
}
|
||||
|
||||
export function deleteEntry(id: number): Promise<void> {
|
||||
return apiDelete(`/entry/${id}`);
|
||||
export function deleteEntry(date: string, mealId: number, id: number): Promise<void> {
|
||||
return apiDelete(`/diary/${date}/meal/${mealId}/entry/${id}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,24 @@
|
|||
import { apiPost, apiDelete, apiPatch } from './client';
|
||||
import type { Meal, Preset } from '$lib/types/api';
|
||||
|
||||
export function createMeal(diaryId: number, name?: string): Promise<Meal> {
|
||||
return apiPost<Meal>('/meal', { diary_id: diaryId, ...(name ? { name } : {}) });
|
||||
export function createMeal(date: string, name: string): Promise<Meal> {
|
||||
return apiPost<Meal>(`/diary/${date}/meal`, { name });
|
||||
}
|
||||
|
||||
export function renameMeal(id: number, name: string): Promise<Meal> {
|
||||
return apiPatch<Meal>(`/meal/${id}`, { name });
|
||||
export function renameMeal(date: string, id: number, name: string): Promise<Meal> {
|
||||
return apiPatch<Meal>(`/diary/${date}/meal/${id}`, { name });
|
||||
}
|
||||
|
||||
export function deleteMeal(id: number): Promise<void> {
|
||||
return apiDelete(`/meal/${id}`);
|
||||
export function deleteMeal(date: string, id: number): Promise<void> {
|
||||
return apiDelete(`/diary/${date}/meal/${id}`);
|
||||
}
|
||||
|
||||
export function saveMealAsPreset(mealId: number, name?: string): Promise<Preset> {
|
||||
return apiPost<Preset>(`/meal/${mealId}/save`, name ? { name } : {});
|
||||
export function saveMealAsPreset(date: string, mealId: number, name?: string): Promise<Preset> {
|
||||
return apiPost<Preset>(`/diary/${date}/meal/${mealId}/preset`, name ? { name } : {});
|
||||
}
|
||||
|
||||
export function createMealFromPreset(diaryId: number, presetId: number, name?: string): Promise<Meal> {
|
||||
return apiPost<Meal>('/meal/from_preset', {
|
||||
diary_id: diaryId,
|
||||
export function createMealFromPreset(date: string, presetId: number, name?: string): Promise<Meal> {
|
||||
return apiPost<Meal>(`/diary/${date}/meal/from_preset`, {
|
||||
preset_id: presetId,
|
||||
...(name ? { name } : {})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
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> {
|
||||
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) });
|
||||
return apiGet<PresetList>(`/preset?${params}`);
|
||||
}
|
||||
|
||||
export function getPreset(id: number): Promise<PresetDetails> {
|
||||
return apiGet<PresetDetails>(`/preset/${id}`);
|
||||
export function listPresets(limit = 20, offset = 0): Promise<Preset[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
return apiGet<Preset[]>(`/preset?${params}`);
|
||||
}
|
||||
|
||||
export function deletePreset(id: number): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
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) });
|
||||
return apiGet<ProductList>(`/product?${params}`);
|
||||
return apiGet<Product[]>(`/product?${params}`);
|
||||
}
|
||||
|
||||
export function createProduct(data: {
|
||||
|
|
@ -18,5 +18,5 @@ export function createProduct(data: {
|
|||
}
|
||||
|
||||
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
10
src/lib/api/settings.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -1,17 +1,71 @@
|
|||
<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 { updateDiary } from '$lib/api/diary';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import Sheet from '$lib/components/ui/Sheet.svelte';
|
||||
|
||||
interface Props {
|
||||
macros: Macros;
|
||||
diary: Diary;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let { macros }: Props = $props();
|
||||
let { diary, date }: Props = $props();
|
||||
|
||||
const CALORIE_GOAL = 2000;
|
||||
const pct = $derived(Math.min(100, Math.round((macros.calories / CALORIE_GOAL) * 100)));
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 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 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>
|
||||
|
||||
<div class="bg-zinc-900 rounded-2xl p-4">
|
||||
|
|
@ -29,32 +83,92 @@
|
|||
/>
|
||||
</svg>
|
||||
<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-xs text-zinc-500">kcal</span>
|
||||
<span class="text-xl font-bold leading-none">{kcal(diary.calories)}</span>
|
||||
<span class="text-xs text-zinc-500">
|
||||
{diary.calories_goal > 0 ? `/ ${kcal(diary.calories_goal)}` : 'kcal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Macro bars -->
|
||||
<div class="flex-1 space-y-2">
|
||||
{#each [
|
||||
{ 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 class="flex-1 space-y-2 min-w-0">
|
||||
{#each macroRows as macro}
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-zinc-400 mb-1">
|
||||
<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 class="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
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>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@
|
|||
interface Props {
|
||||
meal: Meal;
|
||||
date: string;
|
||||
diaryId: number;
|
||||
}
|
||||
|
||||
let { meal, date, diaryId }: Props = $props();
|
||||
let { meal, date }: Props = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
let collapsed = $state(false);
|
||||
|
|
@ -33,7 +32,7 @@
|
|||
if (!renameName.trim()) return;
|
||||
renaming = true;
|
||||
try {
|
||||
await renameMeal(meal.id, renameName.trim());
|
||||
await renameMeal(date, meal.id, renameName.trim());
|
||||
queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
renameOpen = false;
|
||||
} finally {
|
||||
|
|
@ -43,14 +42,14 @@
|
|||
|
||||
async function handleDeleteMeal() {
|
||||
if (!confirm(`Delete meal "${meal.name}"?`)) return;
|
||||
await deleteMeal(meal.id);
|
||||
await deleteMeal(date, meal.id);
|
||||
queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
}
|
||||
|
||||
async function handleSavePreset() {
|
||||
saving = true;
|
||||
try {
|
||||
await saveMealAsPreset(meal.id);
|
||||
await saveMealAsPreset(date, meal.id);
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
} finally {
|
||||
saving = false;
|
||||
|
|
@ -58,7 +57,7 @@
|
|||
}
|
||||
|
||||
async function handleDeleteEntry(entryId: number) {
|
||||
await deleteEntry(entryId);
|
||||
await deleteEntry(date, meal.id, entryId);
|
||||
queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export async function offlineAddEntry(
|
|||
grams: number
|
||||
): Promise<void> {
|
||||
if (navigator.onLine) {
|
||||
await createEntry(mealId, product.id, grams);
|
||||
await createEntry(date, mealId, product.id, grams);
|
||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
return;
|
||||
}
|
||||
|
|
@ -64,13 +64,13 @@ export async function offlineAddEntry(
|
|||
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();
|
||||
|
||||
const macros = macrosFromProduct(product, grams);
|
||||
// Strip Svelte reactive proxy from product before storing/using in structured clone contexts
|
||||
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 => {
|
||||
if (!diary) return diary;
|
||||
|
|
@ -96,8 +96,11 @@ export async function offlineEditEntry(
|
|||
entryId: number,
|
||||
newGrams: number
|
||||
): 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) {
|
||||
await updateEntry(entryId, { grams: newGrams });
|
||||
await updateEntry(date, mealId!, entryId, { grams: newGrams });
|
||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
return;
|
||||
}
|
||||
|
|
@ -106,7 +109,7 @@ export async function offlineEditEntry(
|
|||
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();
|
||||
|
||||
queryClient.setQueryData<Diary>(['diary', date], diary => {
|
||||
|
|
@ -145,16 +148,15 @@ export async function offlineEditEntry(
|
|||
export async function offlineAddMeal(
|
||||
queryClient: QueryClient,
|
||||
date: string,
|
||||
diaryId: number,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
if (navigator.onLine) {
|
||||
await createMeal(diaryId, name || undefined);
|
||||
await createMeal(date, name || 'Meal');
|
||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
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();
|
||||
|
||||
const diary = queryClient.getQueryData<Diary>(['diary', date]);
|
||||
|
|
@ -163,7 +165,7 @@ export async function offlineAddMeal(
|
|||
id: -Date.now(),
|
||||
name: name || `Meal ${order}`,
|
||||
order,
|
||||
diary_id: diaryId,
|
||||
diary_id: diary?.id ?? 0,
|
||||
entries: [],
|
||||
calories: 0, protein: 0, carb: 0, fat: 0, fiber: 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ export interface Product {
|
|||
fat: number;
|
||||
fiber: number;
|
||||
barcode: string | null;
|
||||
usage_count_cached: number | null;
|
||||
usage_count_cached?: number | null;
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
id: number;
|
||||
grams: number;
|
||||
product_id: number;
|
||||
product: Product;
|
||||
meal_id: number;
|
||||
calories: number;
|
||||
|
|
@ -56,6 +57,11 @@ export interface Meal {
|
|||
export interface Diary {
|
||||
id: number;
|
||||
date: string;
|
||||
protein_goal: number;
|
||||
carb_goal: number;
|
||||
fat_goal: number;
|
||||
fiber_goal: number;
|
||||
calories_goal: number;
|
||||
meals: Meal[];
|
||||
calories: number;
|
||||
protein: number;
|
||||
|
|
@ -67,20 +73,29 @@ export interface Diary {
|
|||
export interface Preset {
|
||||
id: number;
|
||||
name: string;
|
||||
user_id: number;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carb: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
entries: PresetEntry[];
|
||||
}
|
||||
|
||||
export interface PresetDetails extends Preset {
|
||||
preset_entries: PresetEntry[];
|
||||
export interface UserSettings {
|
||||
id: number;
|
||||
protein_goal: number;
|
||||
carb_goal: number;
|
||||
fat_goal: number;
|
||||
fiber_goal: number;
|
||||
calories_goal: number;
|
||||
}
|
||||
|
||||
export interface PresetEntry {
|
||||
id: number;
|
||||
grams: number;
|
||||
product_id: number;
|
||||
preset_id: number;
|
||||
product: Product;
|
||||
calories: number;
|
||||
protein: number;
|
||||
|
|
@ -88,11 +103,3 @@ export interface PresetEntry {
|
|||
fat: number;
|
||||
fiber: number;
|
||||
}
|
||||
|
||||
export interface ProductList {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export interface PresetList {
|
||||
presets: Preset[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { page } from '$app/state';
|
|||
|
||||
const isDiary = $derived(page.url.pathname.startsWith('/diary'));
|
||||
const isPresets = $derived(page.url.pathname.startsWith('/presets'));
|
||||
const isSettings = $derived(page.url.pathname.startsWith('/settings'));
|
||||
</script>
|
||||
|
||||
{#if auth.isAuthenticated}
|
||||
|
|
@ -98,6 +99,16 @@ import { page } from '$app/state';
|
|||
</svg>
|
||||
Presets
|
||||
</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>
|
||||
|
||||
<button
|
||||
|
|
@ -138,6 +149,16 @@ import { page } from '$app/state';
|
|||
</svg>
|
||||
Presets
|
||||
</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
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
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 { addDays, formatDisplay, today } from '$lib/utils/date';
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -14,11 +14,12 @@
|
|||
import CommandPalette 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();
|
||||
|
||||
let calendarOpen = $state(false);
|
||||
let commandOpen = $state(false);
|
||||
let creating = $state(false);
|
||||
|
||||
const commands = $derived<Command[]>([
|
||||
...(diaryQuery.data?.meals ?? []).map(meal => ({
|
||||
|
|
@ -31,7 +32,7 @@
|
|||
id: 'add-meal',
|
||||
label: 'Add 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,
|
||||
// replacing any temporary negative-ID entries from optimistic updates
|
||||
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)));
|
||||
return diary;
|
||||
}
|
||||
}));
|
||||
|
||||
async function handleCreate() {
|
||||
creating = true;
|
||||
try {
|
||||
await createDiary(date);
|
||||
queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goDate(delta: number) {
|
||||
goto(`/diary/${addDays(date, delta)}`);
|
||||
}
|
||||
|
|
@ -114,24 +133,36 @@
|
|||
Retry
|
||||
</button>
|
||||
</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}
|
||||
{@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">
|
||||
<!-- Left: macros summary (sticky on desktop) -->
|
||||
<div class="lg:sticky lg:top-[4.5rem]">
|
||||
<MacroSummary macros={diary} />
|
||||
<MacroSummary {diary} {date} />
|
||||
</div>
|
||||
|
||||
<!-- Right: meals -->
|
||||
<div class="space-y-4">
|
||||
{#each diary.meals as meal (meal.id)}
|
||||
<MealCard {meal} {date} diaryId={diary.id} />
|
||||
<MealCard {meal} {date} />
|
||||
{/each}
|
||||
|
||||
<!-- Add meal 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"
|
||||
>
|
||||
+ Add meal
|
||||
|
|
@ -142,7 +173,7 @@
|
|||
</main>
|
||||
|
||||
<!-- FAB: Add entry (mobile only) -->
|
||||
{#if diaryQuery.data}
|
||||
{#if diaryQuery.data != null}
|
||||
<button
|
||||
onclick={() => {
|
||||
const firstMeal = diaryQuery.data?.meals[0];
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
import BarcodeScanner from '$lib/components/ui/BarcodeScanner.svelte';
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -49,13 +49,12 @@
|
|||
queryKey: ['products', debouncedQ],
|
||||
queryFn: async () => {
|
||||
if (!network.online) {
|
||||
const products = await searchCachedProducts(debouncedQ);
|
||||
return { products };
|
||||
return searchCachedProducts(debouncedQ);
|
||||
}
|
||||
const result = await listProducts(debouncedQ, 30);
|
||||
const products = await listProducts(debouncedQ, 30);
|
||||
// Cache for offline use — fire and forget
|
||||
cacheProducts(result.products);
|
||||
return result;
|
||||
cacheProducts(products);
|
||||
return products;
|
||||
},
|
||||
staleTime: 0
|
||||
}));
|
||||
|
|
@ -130,7 +129,7 @@
|
|||
oninput={(e) => handleSearch(e.currentTarget.value)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const first = productsQuery.data?.products[0];
|
||||
const first = productsQuery.data?.[0];
|
||||
if (first) selectProduct(first);
|
||||
}
|
||||
}}
|
||||
|
|
@ -172,7 +171,7 @@
|
|||
<div class="h-14 bg-zinc-900 rounded-xl animate-pulse mb-2"></div>
|
||||
{/each}
|
||||
</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">
|
||||
{#if !network.online}
|
||||
<p class="text-base">No cached products match "{q}"</p>
|
||||
|
|
@ -187,7 +186,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-zinc-800/50">
|
||||
{#each productsQuery.data?.products ?? [] as product (product.id)}
|
||||
{#each productsQuery.data ?? [] as product (product.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectProduct(product)}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
import TopBar from '$lib/components/ui/TopBar.svelte';
|
||||
import { kcal, g } from '$lib/utils/format';
|
||||
|
||||
const date = $derived(page.params.date);
|
||||
const diaryId = $derived(Number(page.url.searchParams.get('diary_id')));
|
||||
const date = $derived(page.params.date!);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
type Tab = 'new' | 'preset';
|
||||
|
|
@ -30,15 +29,15 @@
|
|||
}
|
||||
|
||||
const presetsQuery = createQuery(() => ({
|
||||
queryKey: ['presets', presetDebounced],
|
||||
queryFn: () => listPresets(presetDebounced, 30)
|
||||
queryKey: ['presets'],
|
||||
queryFn: () => listPresets(30)
|
||||
}));
|
||||
|
||||
async function handleCreateNew(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
try {
|
||||
await offlineAddMeal(queryClient, date, diaryId, mealName);
|
||||
await offlineAddMeal(queryClient, date, mealName);
|
||||
goto(`/diary/${date}`);
|
||||
} finally {
|
||||
submitting = false;
|
||||
|
|
@ -49,7 +48,7 @@
|
|||
submitting = true;
|
||||
error = '';
|
||||
try {
|
||||
await createMealFromPreset(diaryId, preset.id, preset.name);
|
||||
await createMealFromPreset(date, preset.id, preset.name);
|
||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
goto(`/diary/${date}`);
|
||||
} catch {
|
||||
|
|
@ -123,14 +122,14 @@
|
|||
<div class="h-16 bg-zinc-900 rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</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">
|
||||
<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?.presets ?? [] as preset (preset.id)}
|
||||
{#each (presetsQuery.data ?? []).filter(p => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase())) as preset (preset.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => handleFromPreset(preset)}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { network } from '$lib/offline/network.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 queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
if (!confirm('Remove this entry?')) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteEntry(entryId);
|
||||
await deleteEntry(date, entry!.meal_id, entryId);
|
||||
await queryClient.invalidateQueries({ queryKey: ['diary', date] });
|
||||
goto(`/diary/${date}`);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
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 { addDays, formatDisplay, today } from '$lib/utils/date';
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
id: 'add-meal',
|
||||
label: 'Add 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');
|
||||
}
|
||||
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)));
|
||||
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">
|
||||
<!-- Left: macros summary (sticky on desktop) -->
|
||||
<div class="lg:sticky lg:top-[4.5rem]">
|
||||
<MacroSummary macros={diary} />
|
||||
<MacroSummary {diary} {date} />
|
||||
</div>
|
||||
|
||||
<!-- Right: meals -->
|
||||
<div class="space-y-4">
|
||||
{#each diary.meals as meal (meal.id)}
|
||||
<MealCard {meal} {date} diaryId={diary.id} />
|
||||
<MealCard {meal} {date} />
|
||||
{/each}
|
||||
|
||||
<!-- Add meal 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"
|
||||
>
|
||||
+ Add meal
|
||||
|
|
@ -137,7 +142,7 @@
|
|||
</main>
|
||||
|
||||
<!-- FAB: Add entry (mobile only) -->
|
||||
{#if diaryQuery.data}
|
||||
{#if diaryQuery.data != null}
|
||||
<button
|
||||
onclick={() => {
|
||||
const firstMeal = diaryQuery.data?.meals[0];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { listPresets, deletePreset } from '$lib/api/presets';
|
||||
import { getDiary } from '$lib/api/diary';
|
||||
import { createMealFromPreset } from '$lib/api/meals';
|
||||
import { today } from '$lib/utils/date';
|
||||
import { kcal, g } from '$lib/utils/format';
|
||||
|
|
@ -24,15 +23,20 @@
|
|||
}
|
||||
|
||||
const presetsQuery = createQuery(() => ({
|
||||
queryKey: ['presets', debouncedQ],
|
||||
queryFn: () => listPresets(debouncedQ, 50)
|
||||
queryKey: ['presets'],
|
||||
queryFn: () => listPresets(50)
|
||||
}));
|
||||
|
||||
const filteredPresets = $derived(
|
||||
(presetsQuery.data ?? []).filter(p =>
|
||||
!debouncedQ || p.name.toLowerCase().includes(debouncedQ.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
async function handleAddToToday(preset: Preset) {
|
||||
addingId = preset.id;
|
||||
try {
|
||||
const diary = await getDiary(today());
|
||||
await createMealFromPreset(diary.id, preset.id, preset.name);
|
||||
await createMealFromPreset(today(), preset.id, preset.name);
|
||||
queryClient.invalidateQueries({ queryKey: ['diary', today()] });
|
||||
addedId = preset.id;
|
||||
setTimeout(() => { addedId = null; }, 1500);
|
||||
|
|
@ -81,14 +85,14 @@
|
|||
</div>
|
||||
{:else if presetsQuery.isError}
|
||||
<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">
|
||||
<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.presets as preset (preset.id)}
|
||||
{#each filteredPresets as preset (preset.id)}
|
||||
<li class="flex items-center justify-between px-4 py-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium truncate">{preset.name}</p>
|
||||
|
|
|
|||
137
src/routes/(app)/settings/+page.svelte
Normal file
137
src/routes/(app)/settings/+page.svelte
Normal 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>
|
||||
|
|
@ -1,13 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { register, login } from '$lib/api/auth';
|
||||
import { register } from '$lib/api/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { today } from '$lib/utils/date';
|
||||
import { onMount } from 'svelte';
|
||||
import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let confirm = $state('');
|
||||
let error = $state('');
|
||||
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) {
|
||||
e.preventDefault();
|
||||
|
|
@ -16,10 +38,13 @@
|
|||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
if (!captchaToken) {
|
||||
error = 'Please complete the captcha';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
await register(username, password);
|
||||
await login(username, password);
|
||||
await register(username, password, captchaToken);
|
||||
goto(`/diary/${today()}`);
|
||||
} catch (err: unknown) {
|
||||
const e2 = err as { detail?: { detail?: string } };
|
||||
|
|
@ -71,13 +96,15 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div bind:this={captchaContainer}></div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
|
|
|
|||
Loading…
Reference in a new issue