[refactor]
This commit is contained in:
parent
f4cf2b89e0
commit
eb54fa59d9
17 changed files with 315 additions and 316 deletions
|
|
@ -5,6 +5,26 @@ import { network } from '$lib/offline/network.svelte';
|
||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
detail: unknown;
|
||||||
|
constructor(message: string, status: number, detail: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractErrorMessage(err: unknown, fallback = 'Something went wrong'): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
const d = err.detail as Record<string, unknown> | null;
|
||||||
|
if (typeof d?.detail === 'string') return d.detail;
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
let isRefreshing = false;
|
let isRefreshing = false;
|
||||||
let refreshPromise: Promise<string | null> | null = null;
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
|
@ -121,7 +141,7 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
return {} as T;
|
return {} as T;
|
||||||
}
|
}
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err });
|
throw new ApiError(`POST ${path} failed: ${res.status}`, res.status, err);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
import { updateUserSettings } from "$lib/api/settings";
|
import { updateUserSettings } from "$lib/api/settings";
|
||||||
import { useQueryClient } from "@tanstack/svelte-query";
|
import { useQueryClient } from "@tanstack/svelte-query";
|
||||||
import Sheet from "$lib/components/ui/Sheet.svelte";
|
import Sheet from "$lib/components/ui/Sheet.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
diary: Diary;
|
diary: Diary;
|
||||||
|
|
@ -192,16 +194,8 @@
|
||||||
well?
|
well?
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<Button variant="secondary" onclick={() => (syncSettingsOpen = false)}>No</Button>
|
||||||
onclick={() => (syncSettingsOpen = false)}
|
<Button variant="primary-flex" onclick={handleSyncToSettings}>Yes</Button>
|
||||||
class="flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors"
|
|
||||||
>No</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onclick={handleSyncToSettings}
|
|
||||||
class="flex-1 bg-green-600 hover:bg-green-500 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>Yes</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
|
@ -220,16 +214,17 @@
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
|
variant="sheet"
|
||||||
|
size="sm"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
value={form.calories_goal ?? diary.calories_goal}
|
value={form.calories_goal ?? diary.calories_goal}
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
const v = e.currentTarget.value;
|
const v = (e.currentTarget as HTMLInputElement).value;
|
||||||
form.calories_goal = v === "" ? null : Number(v);
|
form.calories_goal = v === "" ? null : Number(v);
|
||||||
}}
|
}}
|
||||||
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-zinc-600 mt-1">
|
<p class="text-xs text-zinc-600 mt-1">
|
||||||
Clear to auto-calculate from macro goals
|
Clear to auto-calculate from macro goals
|
||||||
|
|
@ -242,22 +237,19 @@
|
||||||
>{field.label}
|
>{field.label}
|
||||||
<span class="text-zinc-600">({field.unit})</span></label
|
<span class="text-zinc-600">({field.unit})</span></label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
|
variant="sheet"
|
||||||
|
size="sm"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
bind:value={form[field.key]}
|
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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={saving}>
|
||||||
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"}
|
{saving ? "Saving…" : "Save"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
import { deleteEntry } from "$lib/api/entries";
|
import { deleteEntry } from "$lib/api/entries";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import Sheet from "$lib/components/ui/Sheet.svelte";
|
import Sheet from "$lib/components/ui/Sheet.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
meal: Meal;
|
meal: Meal;
|
||||||
|
|
@ -92,6 +94,8 @@
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onclick={() => (collapsed = !collapsed)}
|
onclick={() => (collapsed = !collapsed)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-label="{meal.name}, {collapsed ? 'expand' : 'collapse'}"
|
||||||
class="flex-1 flex items-center gap-2 text-left min-w-0"
|
class="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -253,22 +257,18 @@
|
||||||
<form onsubmit={handleSavePreset} class="space-y-4">
|
<form onsubmit={handleSavePreset} class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-zinc-400 mb-1.5">Preset name</label>
|
<label class="block text-sm text-zinc-400 mb-1.5">Preset name</label>
|
||||||
<input
|
<Input
|
||||||
bind:this={presetNameInput}
|
bind:el={presetNameInput}
|
||||||
|
variant="sheet"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={presetName}
|
bind:value={presetName}
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button type="submit" disabled={saving || !presetName.trim()}>
|
||||||
type="submit"
|
|
||||||
disabled={saving || !presetName.trim()}
|
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{saving ? "Saving…" : "Save preset"}
|
{saving ? "Saving…" : "Save preset"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
|
@ -278,20 +278,16 @@
|
||||||
title="Rename meal"
|
title="Rename meal"
|
||||||
>
|
>
|
||||||
<form onsubmit={handleRename} class="space-y-4">
|
<form onsubmit={handleRename} class="space-y-4">
|
||||||
<input
|
<Input
|
||||||
bind:this={renameNameInput}
|
bind:el={renameNameInput}
|
||||||
|
variant="sheet"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={renameName}
|
bind:value={renameName}
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<Button type="submit" disabled={renaming || !renameName.trim()}>
|
||||||
type="submit"
|
|
||||||
disabled={renaming || !renameName.trim()}
|
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{renaming ? "Saving…" : "Save"}
|
{renaming ? "Saving…" : "Save"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
|
||||||
25
src/lib/components/ui/Button.svelte
Normal file
25
src/lib/components/ui/Button.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props extends HTMLButtonAttributes {
|
||||||
|
variant?: "primary" | "secondary" | "primary-flex";
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { variant = "primary", class: className = "", children, ...rest }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const variantClasses: Record<string, string> = {
|
||||||
|
primary:
|
||||||
|
"w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors",
|
||||||
|
secondary:
|
||||||
|
"flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors",
|
||||||
|
"primary-flex":
|
||||||
|
"flex-1 bg-green-600 hover:bg-green-500 rounded-xl py-3 font-semibold transition-colors",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="{variantClasses[variant]} {className}" {...rest}>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
69
src/lib/components/ui/GramsStepper.svelte
Normal file
69
src/lib/components/ui/GramsStepper.svelte
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
inputEl?: HTMLInputElement | null;
|
||||||
|
onEnter?: () => void;
|
||||||
|
size?: "sm" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(100),
|
||||||
|
min = 1,
|
||||||
|
max,
|
||||||
|
step = 10,
|
||||||
|
inputEl = $bindable(null),
|
||||||
|
onEnter,
|
||||||
|
size = "sm",
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const btnClass =
|
||||||
|
size === "lg"
|
||||||
|
? "w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
|
||||||
|
: "w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
size === "lg"
|
||||||
|
? "flex-1 bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-center text-2xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
|
||||||
|
: "flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrease"
|
||||||
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
|
onclick={() => {
|
||||||
|
value = Math.max(min, value - step);
|
||||||
|
}}
|
||||||
|
class={btnClass}
|
||||||
|
>−</button>
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
type="number"
|
||||||
|
bind:value
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuenow={value}
|
||||||
|
aria-valuemax={max}
|
||||||
|
inputmode="decimal"
|
||||||
|
autofocus
|
||||||
|
onfocus={(e) => e.currentTarget.select()}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") onEnter?.();
|
||||||
|
}}
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Increase"
|
||||||
|
onpointerdown={(e) => e.preventDefault()}
|
||||||
|
onclick={() => {
|
||||||
|
value = value + step;
|
||||||
|
}}
|
||||||
|
class={btnClass}
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
30
src/lib/components/ui/Input.svelte
Normal file
30
src/lib/components/ui/Input.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props extends Omit<HTMLInputAttributes, "value" | "size"> {
|
||||||
|
variant?: "default" | "sheet";
|
||||||
|
size?: "md" | "sm";
|
||||||
|
value?: string | number | null;
|
||||||
|
el?: HTMLInputElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
value = $bindable<string | number | null>(),
|
||||||
|
el = $bindable(null),
|
||||||
|
class: className = "",
|
||||||
|
...rest
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={el}
|
||||||
|
bind:value
|
||||||
|
class="w-full {variant === 'sheet'
|
||||||
|
? 'bg-zinc-800'
|
||||||
|
: 'bg-zinc-900'} border border-zinc-700 rounded-xl px-4 {size === 'sm'
|
||||||
|
? 'py-2.5'
|
||||||
|
: 'py-3'} text-zinc-100 focus:outline-none focus:border-green-500 transition-colors {className}"
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
|
@ -44,9 +44,9 @@
|
||||||
<svelte:window onkeydown={handleKey} />
|
<svelte:window onkeydown={handleKey} />
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop: purely visual overlay; keyboard (Escape) handled on svelte:window -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
|
role="presentation"
|
||||||
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdrop}
|
onclick={handleBackdrop}
|
||||||
></div>
|
></div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ let dbInstance: IDBPDatabase | null = null;
|
||||||
|
|
||||||
export async function getDb() {
|
export async function getDb() {
|
||||||
if (dbInstance) return dbInstance;
|
if (dbInstance) return dbInstance;
|
||||||
|
try {
|
||||||
dbInstance = await openDB(DB_NAME, DB_VERSION, {
|
dbInstance = await openDB(DB_NAME, DB_VERSION, {
|
||||||
upgrade(db, oldVersion) {
|
upgrade(db, oldVersion) {
|
||||||
if (oldVersion < 1) {
|
if (oldVersion < 1) {
|
||||||
|
|
@ -30,6 +31,10 @@ export async function getDb() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return dbInstance;
|
return dbInstance;
|
||||||
|
} catch (e) {
|
||||||
|
dbInstance = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Diary cache ──────────────────────────────────────────────────────────────
|
// ── Diary cache ──────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export async function syncOfflineQueue(): Promise<number> {
|
||||||
synced++;
|
synced++;
|
||||||
} else {
|
} else {
|
||||||
// Non-retryable failure (e.g. 400 validation error) — drop it to unblock the queue
|
// Non-retryable failure (e.g. 400 validation error) — drop it to unblock the queue
|
||||||
|
console.warn(`[sync] dropping failed mutation ${mutation.method} ${mutation.url}: ${res.status}`);
|
||||||
await dequeueMutation(mutation.id!);
|
await dequeueMutation(mutation.id!);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@
|
||||||
import { listProducts, getProductByBarcode } from "$lib/api/products";
|
import { listProducts, getProductByBarcode } from "$lib/api/products";
|
||||||
import { cacheProducts, searchCachedProducts } from "$lib/offline/db";
|
import { cacheProducts, searchCachedProducts } from "$lib/offline/db";
|
||||||
import { offlineAddEntry } from "$lib/offline/mutations";
|
import { offlineAddEntry } from "$lib/offline/mutations";
|
||||||
|
import { ApiError, extractErrorMessage } from "$lib/api/client";
|
||||||
import { network } from "$lib/offline/network.svelte";
|
import { network } from "$lib/offline/network.svelte";
|
||||||
import type { Product } from "$lib/types/api";
|
import type { Product } from "$lib/types/api";
|
||||||
import TopBar from "$lib/components/ui/TopBar.svelte";
|
import TopBar from "$lib/components/ui/TopBar.svelte";
|
||||||
import Sheet from "$lib/components/ui/Sheet.svelte";
|
import Sheet from "$lib/components/ui/Sheet.svelte";
|
||||||
import BarcodeScanner from "$lib/components/ui/BarcodeScanner.svelte";
|
import BarcodeScanner from "$lib/components/ui/BarcodeScanner.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
|
import GramsStepper from "$lib/components/ui/GramsStepper.svelte";
|
||||||
import { kcal, g } from "$lib/utils/format";
|
import { kcal, g } from "$lib/utils/format";
|
||||||
import { today } from "$lib/utils/date";
|
import { today } from "$lib/utils/date";
|
||||||
|
|
||||||
|
|
@ -36,11 +40,6 @@
|
||||||
|
|
||||||
let gramsInput = $state<HTMLInputElement | null>(null);
|
let gramsInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!selectedProduct) setTimeout(() => searchInput?.focus(), 50);
|
|
||||||
else setTimeout(() => gramsInput?.focus(), 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleSearch(value: string) {
|
function handleSearch(value: string) {
|
||||||
q = value;
|
q = value;
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
|
|
@ -64,7 +63,7 @@
|
||||||
return searchCachedProducts(debouncedQ);
|
return searchCachedProducts(debouncedQ);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 30_000,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function selectProduct(product: Product) {
|
function selectProduct(product: Product) {
|
||||||
|
|
@ -80,8 +79,8 @@
|
||||||
try {
|
try {
|
||||||
const product = await getProductByBarcode(barcode);
|
const product = await getProductByBarcode(barcode);
|
||||||
selectProduct(product);
|
selectProduct(product);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
if (err?.status === 404) {
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
goto(`/products/new?barcode=${encodeURIComponent(barcode)}`);
|
goto(`/products/new?barcode=${encodeURIComponent(barcode)}`);
|
||||||
} else {
|
} else {
|
||||||
scanError = "Could not look up barcode. Try searching manually.";
|
scanError = "Could not look up barcode. Try searching manually.";
|
||||||
|
|
@ -98,8 +97,8 @@
|
||||||
try {
|
try {
|
||||||
await offlineAddEntry(queryClient, date, mealId, selectedProduct, grams);
|
await offlineAddEntry(queryClient, date, mealId, selectedProduct, grams);
|
||||||
goto(`/diary/${date}`);
|
goto(`/diary/${date}`);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
error = e.message ?? "Failed to add entry";
|
error = extractErrorMessage(e, "Failed to add entry");
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +114,11 @@
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!selectedProduct) setTimeout(() => searchInput?.focus(), 50);
|
||||||
|
else setTimeout(() => gramsInput?.focus(), 50);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if scannerOpen}
|
{#if scannerOpen}
|
||||||
|
|
@ -144,20 +148,21 @@
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<Input
|
||||||
bind:this={searchInput}
|
bind:el={searchInput}
|
||||||
autofocus
|
autofocus
|
||||||
type="search"
|
type="search"
|
||||||
|
size="sm"
|
||||||
placeholder="Search foods…"
|
placeholder="Search foods…"
|
||||||
value={q}
|
value={q}
|
||||||
oninput={(e) => handleSearch(e.currentTarget.value)}
|
oninput={(e) => handleSearch((e.currentTarget as HTMLInputElement).value)}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
const first = productsQuery.data?.[0];
|
const first = productsQuery.data?.[0];
|
||||||
if (first) selectProduct(first);
|
if (first) selectProduct(first);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
|
class="text-sm placeholder-zinc-500 pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -295,54 +300,24 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
|
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="mb-4">
|
||||||
<button
|
<GramsStepper bind:value={grams} bind:inputEl={gramsInput} onEnter={handleAddEntry} />
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => {
|
|
||||||
grams = Math.max(1, grams - 10);
|
|
||||||
}}
|
|
||||||
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
|
|
||||||
>−</button
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
bind:this={gramsInput}
|
|
||||||
type="number"
|
|
||||||
bind:value={grams}
|
|
||||||
min="1"
|
|
||||||
max="5000"
|
|
||||||
inputmode="decimal"
|
|
||||||
autofocus
|
|
||||||
onfocus={(e) => e.currentTarget.select()}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter") handleAddEntry();
|
|
||||||
}}
|
|
||||||
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => {
|
|
||||||
grams = grams + 10;
|
|
||||||
}}
|
|
||||||
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
|
|
||||||
>+</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-red-400 text-sm mb-3">{error}</p>
|
<p class="text-red-400 text-sm mb-3">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
onclick={handleAddEntry}
|
onclick={handleAddEntry}
|
||||||
disabled={submitting || grams < 1}
|
disabled={submitting || grams < 1}
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting
|
||||||
? "Adding…"
|
? "Adding…"
|
||||||
: network.online
|
: network.online
|
||||||
? "Add to meal"
|
? "Add to meal"
|
||||||
: "Add to meal (offline)"}
|
: "Add to meal (offline)"}
|
||||||
</button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import { listPresets } from "$lib/api/presets";
|
import { listPresets } from "$lib/api/presets";
|
||||||
import type { Preset } from "$lib/types/api";
|
import type { Preset } from "$lib/types/api";
|
||||||
import TopBar from "$lib/components/ui/TopBar.svelte";
|
import TopBar from "$lib/components/ui/TopBar.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
import { kcal, g } from "$lib/utils/format";
|
import { kcal, g } from "$lib/utils/format";
|
||||||
import { today } from "$lib/utils/date";
|
import { today } from "$lib/utils/date";
|
||||||
|
|
||||||
|
|
@ -45,6 +47,12 @@
|
||||||
queryFn: () => listPresets(30),
|
queryFn: () => listPresets(30),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const filteredPresets = $derived(
|
||||||
|
(presetsQuery.data ?? []).filter(
|
||||||
|
(p) => !presetDebounced || p.name.toLowerCase().includes(presetDebounced.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
async function handleCreateNew(e: SubmitEvent) {
|
async function handleCreateNew(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submitting = true;
|
submitting = true;
|
||||||
|
|
@ -99,39 +107,36 @@
|
||||||
<label for="meal-name" class="block text-sm text-zinc-400 mb-2"
|
<label for="meal-name" class="block text-sm text-zinc-400 mb-2"
|
||||||
>Meal name <span class="text-zinc-600">(optional)</span></label
|
>Meal name <span class="text-zinc-600">(optional)</span></label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
bind:this={mealNameInput}
|
bind:el={mealNameInput}
|
||||||
id="meal-name"
|
id="meal-name"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={mealName}
|
bind:value={mealName}
|
||||||
placeholder="e.g. Breakfast, Lunch…"
|
placeholder="e.g. Breakfast, Lunch…"
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors"
|
class="placeholder-zinc-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button type="submit" disabled={submitting}>
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{submitting
|
{submitting
|
||||||
? "Creating…"
|
? "Creating…"
|
||||||
: network.online
|
: network.online
|
||||||
? "Create meal"
|
? "Create meal"
|
||||||
: "Create meal (offline)"}
|
: "Create meal (offline)"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Preset search -->
|
<!-- Preset search -->
|
||||||
<div class="px-4 py-3 border-b border-zinc-800">
|
<div class="px-4 py-3 border-b border-zinc-800">
|
||||||
<input
|
<Input
|
||||||
bind:this={presetQInput}
|
bind:el={presetQInput}
|
||||||
type="search"
|
type="search"
|
||||||
|
size="sm"
|
||||||
placeholder="Search presets…"
|
placeholder="Search presets…"
|
||||||
value={presetQ}
|
value={presetQ}
|
||||||
oninput={(e) => handlePresetSearch(e.currentTarget.value)}
|
oninput={(e) => handlePresetSearch((e.currentTarget as HTMLInputElement).value)}
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
|
class="text-sm placeholder-zinc-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -145,18 +150,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 ?? []).filter((p) => !presetDebounced || p.name
|
{:else if filteredPresets.length === 0}
|
||||||
.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 ?? []).filter((p) => !presetDebounced || p.name
|
{#each filteredPresets as preset (preset.id)}
|
||||||
.toLowerCase()
|
|
||||||
.includes(presetDebounced.toLowerCase())) as preset (preset.id)}
|
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleFromPreset(preset)}
|
onclick={() => handleFromPreset(preset)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
import { offlineEditEntry } from "$lib/offline/mutations";
|
import { offlineEditEntry } from "$lib/offline/mutations";
|
||||||
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";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import GramsStepper from "$lib/components/ui/GramsStepper.svelte";
|
||||||
import { today } from "$lib/utils/date";
|
import { today } from "$lib/utils/date";
|
||||||
|
|
||||||
const date = $derived(
|
const date = $derived(
|
||||||
|
|
@ -133,53 +135,27 @@
|
||||||
<!-- Grams input -->
|
<!-- Grams input -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
|
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
|
||||||
<div class="flex items-center gap-3">
|
<GramsStepper
|
||||||
<button
|
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => {
|
|
||||||
grams = Math.max(1, grams - 10);
|
|
||||||
}}
|
|
||||||
class="w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
|
|
||||||
>−</button
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
bind:this={gramsInput}
|
|
||||||
type="number"
|
|
||||||
bind:value={grams}
|
bind:value={grams}
|
||||||
min="1"
|
bind:inputEl={gramsInput}
|
||||||
max="5000"
|
max={5000}
|
||||||
inputmode="decimal"
|
size="lg"
|
||||||
autofocus
|
onEnter={handleSave}
|
||||||
onfocus={(e) => e.currentTarget.select()}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter") handleSave();
|
|
||||||
}}
|
|
||||||
class="flex-1 bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-center text-2xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => {
|
|
||||||
grams = grams + 10;
|
|
||||||
}}
|
|
||||||
class="w-12 h-12 rounded-xl bg-zinc-900 hover:bg-zinc-800 transition-colors text-xl font-medium flex items-center justify-center"
|
|
||||||
>+</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="px-4 pb-[calc(5rem+var(--safe-bottom))] lg:pb-6">
|
<div class="px-4 pb-[calc(5rem+var(--safe-bottom))] lg:pb-6">
|
||||||
<button
|
<Button
|
||||||
onclick={handleSave}
|
onclick={handleSave}
|
||||||
disabled={saving || grams < 1 || grams === entry.grams}
|
disabled={saving || grams < 1 || grams === entry.grams}
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-40 rounded-xl py-3.5 font-semibold transition-colors"
|
|
||||||
>
|
>
|
||||||
{saving
|
{saving
|
||||||
? "Saving…"
|
? "Saving…"
|
||||||
: network.online
|
: network.online
|
||||||
? "Save changes"
|
? "Save changes"
|
||||||
: "Save changes (offline)"}
|
: "Save changes (offline)"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@
|
||||||
import { kcal, g } from "$lib/utils/format";
|
import { kcal, g } from "$lib/utils/format";
|
||||||
import TopBar from "$lib/components/ui/TopBar.svelte";
|
import TopBar from "$lib/components/ui/TopBar.svelte";
|
||||||
import Sheet from "$lib/components/ui/Sheet.svelte";
|
import Sheet from "$lib/components/ui/Sheet.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
|
import GramsStepper from "$lib/components/ui/GramsStepper.svelte";
|
||||||
import type { Preset, PresetEntry, Product } from "$lib/types/api";
|
import type { Preset, PresetEntry, Product } from "$lib/types/api";
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -248,12 +251,13 @@
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
|
size="sm"
|
||||||
placeholder="Search presets…"
|
placeholder="Search presets…"
|
||||||
value={q}
|
value={q}
|
||||||
oninput={(e) => handleSearch(e.currentTarget.value)}
|
oninput={(e) => handleSearch((e.currentTarget as HTMLInputElement).value)}
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
|
class="text-sm placeholder-zinc-500 pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -511,21 +515,17 @@
|
||||||
title="Create new preset"
|
title="Create new preset"
|
||||||
>
|
>
|
||||||
<form onsubmit={handleCreate} class="space-y-4">
|
<form onsubmit={handleCreate} class="space-y-4">
|
||||||
<input
|
<Input
|
||||||
bind:this={creatingNameInput}
|
bind:el={creatingNameInput}
|
||||||
|
variant="sheet"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={creatingName}
|
bind:value={creatingName}
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<Button type="submit" disabled={creatingSaving || !creatingName.trim()}>
|
||||||
type="submit"
|
|
||||||
disabled={creatingSaving || !creatingName.trim()}
|
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{creatingSaving ? "Saving…" : "Save"}
|
{creatingSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
|
@ -536,21 +536,17 @@
|
||||||
title="Rename preset"
|
title="Rename preset"
|
||||||
>
|
>
|
||||||
<form onsubmit={handleRename} class="space-y-4">
|
<form onsubmit={handleRename} class="space-y-4">
|
||||||
<input
|
<Input
|
||||||
bind:this={renameNameInput}
|
bind:el={renameNameInput}
|
||||||
|
variant="sheet"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={renameName}
|
bind:value={renameName}
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<Button type="submit" disabled={renaming || !renameName.trim()}>
|
||||||
type="submit"
|
|
||||||
disabled={renaming || !renameName.trim()}
|
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{renaming ? "Saving…" : "Save"}
|
{renaming ? "Saving…" : "Save"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
|
@ -565,44 +561,11 @@
|
||||||
<p class="text-sm text-zinc-400">{editingEntry.entry.product.name}</p>
|
<p class="text-sm text-zinc-400">{editingEntry.entry.product.name}</p>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
|
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
|
||||||
<div class="flex items-center gap-3">
|
<GramsStepper bind:value={editGrams} bind:inputEl={editGramsInput} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => {
|
|
||||||
editGrams = Math.max(1, editGrams - 10);
|
|
||||||
}}
|
|
||||||
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
|
|
||||||
>−</button
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
bind:this={editGramsInput}
|
|
||||||
type="number"
|
|
||||||
bind:value={editGrams}
|
|
||||||
min="1"
|
|
||||||
autofocus
|
|
||||||
inputmode="decimal"
|
|
||||||
onfocus={(e) => e.currentTarget.select()}
|
|
||||||
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => {
|
|
||||||
editGrams = editGrams + 10;
|
|
||||||
}}
|
|
||||||
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
|
|
||||||
>+</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button type="submit" disabled={editSaving || editGrams < 1}>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={editSaving || editGrams < 1}
|
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{editSaving ? "Saving…" : "Save"}
|
{editSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
@ -620,14 +583,16 @@
|
||||||
{#if selectedProduct === null}
|
{#if selectedProduct === null}
|
||||||
<!-- Product search -->
|
<!-- Product search -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<input
|
<Input
|
||||||
bind:this={productQInput}
|
bind:el={productQInput}
|
||||||
|
variant="sheet"
|
||||||
type="search"
|
type="search"
|
||||||
|
size="sm"
|
||||||
placeholder="Search foods…"
|
placeholder="Search foods…"
|
||||||
value={productQ}
|
value={productQ}
|
||||||
oninput={(e) => handleProductSearch(e.currentTarget.value)}
|
oninput={(e) => handleProductSearch((e.currentTarget as HTMLInputElement).value)}
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-green-500 transition-colors"
|
class="text-sm placeholder-zinc-500"
|
||||||
/>
|
/>
|
||||||
{#if productsQuery.isPending && productDebouncedQ}
|
{#if productsQuery.isPending && productDebouncedQ}
|
||||||
<p class="text-sm text-zinc-500 text-center py-4">Searching…</p>
|
<p class="text-sm text-zinc-500 text-center py-4">Searching…</p>
|
||||||
|
|
@ -673,52 +638,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
|
<label class="block text-sm text-zinc-400 mb-2">Grams</label>
|
||||||
<div class="flex items-center gap-3">
|
<GramsStepper bind:value={addGrams} bind:inputEl={addGramsInput} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => {
|
|
||||||
addGrams = Math.max(1, addGrams - 10);
|
|
||||||
}}
|
|
||||||
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
|
|
||||||
>−</button
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
bind:this={addGramsInput}
|
|
||||||
type="number"
|
|
||||||
bind:value={addGrams}
|
|
||||||
min="1"
|
|
||||||
autofocus
|
|
||||||
inputmode="decimal"
|
|
||||||
onfocus={(e) => e.currentTarget.select()}
|
|
||||||
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-2.5 text-center text-xl font-semibold focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onpointerdown={(e) => e.preventDefault()}
|
|
||||||
onclick={() => {
|
|
||||||
addGrams = addGrams + 10;
|
|
||||||
}}
|
|
||||||
class="w-11 h-11 rounded-xl bg-zinc-800 hover:bg-zinc-700 transition-colors text-lg font-medium flex items-center justify-center"
|
|
||||||
>+</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<Button variant="secondary" type="button" onclick={() => (selectedProduct = null)}>
|
||||||
type="button"
|
|
||||||
onclick={() => (selectedProduct = null)}
|
|
||||||
class="flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="primary-flex" type="submit" disabled={addSaving || addGrams < 1}>
|
||||||
type="submit"
|
|
||||||
disabled={addSaving || addGrams < 1}
|
|
||||||
class="flex-1 bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{addSaving ? "Adding…" : "Add"}
|
{addSaving ? "Adding…" : "Add"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { createProduct } from "$lib/api/products";
|
import { createProduct } from "$lib/api/products";
|
||||||
import TopBar from "$lib/components/ui/TopBar.svelte";
|
import TopBar from "$lib/components/ui/TopBar.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
|
|
||||||
let name = $state(page.url.searchParams.get("name") ?? "");
|
let name = $state(page.url.searchParams.get("name") ?? "");
|
||||||
let barcode = $state(page.url.searchParams.get("barcode") ?? "");
|
let barcode = $state(page.url.searchParams.get("barcode") ?? "");
|
||||||
|
|
@ -52,15 +54,15 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="name" class="block text-sm text-zinc-400 mb-2">Name</label>
|
<label for="name" class="block text-sm text-zinc-400 mb-2">Name</label>
|
||||||
<input
|
<Input
|
||||||
bind:this={nameInput}
|
bind:el={nameInput}
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder="e.g. Chicken breast"
|
placeholder="e.g. Chicken breast"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-green-500 transition-colors"
|
class="placeholder-zinc-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -69,7 +71,7 @@
|
||||||
<label for="protein" class="block text-sm text-zinc-400 mb-2"
|
<label for="protein" class="block text-sm text-zinc-400 mb-2"
|
||||||
>Protein (g)</label
|
>Protein (g)</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="protein"
|
id="protein"
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={protein}
|
bind:value={protein}
|
||||||
|
|
@ -77,14 +79,13 @@
|
||||||
step="0.1"
|
step="0.1"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="carb" class="block text-sm text-zinc-400 mb-2"
|
<label for="carb" class="block text-sm text-zinc-400 mb-2"
|
||||||
>Carbs (g)</label
|
>Carbs (g)</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="carb"
|
id="carb"
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={carb}
|
bind:value={carb}
|
||||||
|
|
@ -92,14 +93,13 @@
|
||||||
step="0.1"
|
step="0.1"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="fat" class="block text-sm text-zinc-400 mb-2"
|
<label for="fat" class="block text-sm text-zinc-400 mb-2"
|
||||||
>Fat (g)</label
|
>Fat (g)</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="fat"
|
id="fat"
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={fat}
|
bind:value={fat}
|
||||||
|
|
@ -107,14 +107,13 @@
|
||||||
step="0.1"
|
step="0.1"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="fiber" class="block text-sm text-zinc-400 mb-2"
|
<label for="fiber" class="block text-sm text-zinc-400 mb-2"
|
||||||
>Fiber (g)</label
|
>Fiber (g)</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="fiber"
|
id="fiber"
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={fiber}
|
bind:value={fiber}
|
||||||
|
|
@ -122,7 +121,6 @@
|
||||||
step="0.1"
|
step="0.1"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,13 +136,9 @@
|
||||||
<p class="text-red-400 text-sm">{error}</p>
|
<p class="text-red-400 text-sm">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={submitting || !name.trim()}>
|
||||||
type="submit"
|
|
||||||
disabled={submitting || !name.trim()}
|
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{submitting ? "Saving…" : "Save product"}
|
{submitting ? "Saving…" : "Save product"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { updateDiary } from "$lib/api/diary";
|
import { updateDiary } from "$lib/api/diary";
|
||||||
import TopBar from "$lib/components/ui/TopBar.svelte";
|
import TopBar from "$lib/components/ui/TopBar.svelte";
|
||||||
import Sheet from "$lib/components/ui/Sheet.svelte";
|
import Sheet from "$lib/components/ui/Sheet.svelte";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
import { today } from "$lib/utils/date";
|
import { today } from "$lib/utils/date";
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -101,7 +102,7 @@
|
||||||
>
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-sm font-medium">Calories</p>
|
<p id="label-calories" class="text-sm font-medium">Calories</p>
|
||||||
{#if form.calories_goal === null}
|
{#if form.calories_goal === null}
|
||||||
<span
|
<span
|
||||||
class="text-xs font-medium text-zinc-500 bg-zinc-800 border border-zinc-700 px-1.5 py-0.5 rounded-full"
|
class="text-xs font-medium text-zinc-500 bg-zinc-800 border border-zinc-700 px-1.5 py-0.5 rounded-full"
|
||||||
|
|
@ -114,6 +115,8 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
id="setting-calories"
|
||||||
|
aria-labelledby="label-calories"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
|
|
@ -133,10 +136,12 @@
|
||||||
class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4"
|
class="bg-zinc-900 rounded-xl px-4 py-3 flex items-center gap-4"
|
||||||
>
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm font-medium">{field.label}</p>
|
<p id="label-{field.key}" class="text-sm font-medium">{field.label}</p>
|
||||||
<p class="text-xs text-zinc-500">{field.unit} per day</p>
|
<p class="text-xs text-zinc-500">{field.unit} per day</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
id="setting-{field.key}"
|
||||||
|
aria-labelledby="label-{field.key}"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
|
|
@ -152,14 +157,13 @@
|
||||||
<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={saving}
|
disabled={saving}
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors
|
class={saved ? "bg-zinc-700 hover:bg-zinc-700" : ""}
|
||||||
{saved ? 'bg-zinc-700 hover:bg-zinc-700' : ''}"
|
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : saved ? "Saved!" : "Save"}
|
{saving ? "Saving…" : saved ? "Saved!" : "Save"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -174,15 +178,7 @@
|
||||||
Override today's diary macro goals with these values as well?
|
Override today's diary macro goals with these values as well?
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<Button variant="secondary" onclick={() => (syncDiaryOpen = false)}>No</Button>
|
||||||
onclick={() => (syncDiaryOpen = false)}
|
<Button variant="primary-flex" onclick={handleSyncToDiary}>Yes</Button>
|
||||||
class="flex-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl py-3 text-sm font-medium transition-colors"
|
|
||||||
>No</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onclick={handleSyncToDiary}
|
|
||||||
class="flex-1 bg-green-600 hover:bg-green-500 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>Yes</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
import { auth } from "$lib/auth/store.svelte";
|
import { auth } from "$lib/auth/store.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { today } from "$lib/utils/date";
|
import { today } from "$lib/utils/date";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
|
|
||||||
let username = $state("");
|
let username = $state("");
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
|
|
@ -39,13 +41,12 @@
|
||||||
for="username"
|
for="username"
|
||||||
class="block text-sm font-medium text-zinc-400 mb-1">Username</label
|
class="block text-sm font-medium text-zinc-400 mb-1">Username</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -54,13 +55,12 @@
|
||||||
for="password"
|
for="password"
|
||||||
class="block text-sm font-medium text-zinc-400 mb-1">Password</label
|
class="block text-sm font-medium text-zinc-400 mb-1">Password</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -68,13 +68,9 @@
|
||||||
<p class="text-red-400 text-sm">{error}</p>
|
<p class="text-red-400 text-sm">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={loading}>
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
class="w-full bg-green-600 hover:bg-green-500 disabled:opacity-50 rounded-xl py-3 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "Signing in…" : "Sign in"}
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="text-center mt-6 text-zinc-500 text-sm">
|
<p class="text-center mt-6 text-zinc-500 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { PUBLIC_TURNSTILE_SITE_KEY } from "$env/static/public";
|
import { PUBLIC_TURNSTILE_SITE_KEY } from "$env/static/public";
|
||||||
|
import { extractErrorMessage } from "$lib/api/client";
|
||||||
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
|
|
||||||
let username = $state("");
|
let username = $state("");
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
|
|
@ -52,8 +55,7 @@
|
||||||
await register(username, password, captchaToken);
|
await register(username, password, captchaToken);
|
||||||
goto(`/settings`);
|
goto(`/settings`);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const e2 = err as { detail?: { detail?: string } };
|
error = extractErrorMessage(err, "Registration failed");
|
||||||
error = e2.detail?.detail ?? "Registration failed";
|
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
@ -70,13 +72,12 @@
|
||||||
for="username"
|
for="username"
|
||||||
class="block text-sm font-medium text-zinc-400 mb-1">Username</label
|
class="block text-sm font-medium text-zinc-400 mb-1">Username</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -85,13 +86,12 @@
|
||||||
for="password"
|
for="password"
|
||||||
class="block text-sm font-medium text-zinc-400 mb-1">Password</label
|
class="block text-sm font-medium text-zinc-400 mb-1">Password</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -101,13 +101,12 @@
|
||||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||||
>Confirm password</label
|
>Confirm password</label
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
id="confirm"
|
id="confirm"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={confirm}
|
bind:value={confirm}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
required
|
||||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 focus:outline-none focus:border-green-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -117,13 +116,9 @@
|
||||||
<p class="text-red-400 text-sm">{error}</p>
|
<p class="text-red-400 text-sm">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={loading || !captchaToken}>
|
||||||
type="submit"
|
|
||||||
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 ? "Creating account…" : "Create account"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="text-center mt-6 text-zinc-500 text-sm">
|
<p class="text-center mt-6 text-zinc-500 text-sm">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue