[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
|
- ./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"
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 } : {})
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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
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">
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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">
|
<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'}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue