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