feat(frontend): responsive design for mobile (RWD)

- Layout: mobile hamburger + drawer nav (backdrop button + sibling nav),
  desktop sidebar hidden on small screens, p-4 md:p-8 main padding
- Products: card list view on mobile, flex-wrap filters
- Lab results: card list view on mobile
- ProductForm: responsive grids (grid-cols-1 sm:grid-cols-2), skin
  profile checkboxes 2→3 cols, active ingredient row restructured
  (name+✕ in flex row, percent/strength/irritation in 3-col grid),
  section headers stack on mobile
- Skin snapshots: date+icons on one row, badges on separate row below
- Product [id] header: back link stacked above title, redundant badge removed
- Routines header: flex-col on mobile, sm:flex-row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-03-02 13:35:25 +01:00
parent c85ca355df
commit 679e4e81f4
8 changed files with 193 additions and 60 deletions

View file

@ -451,7 +451,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="name">{m["productForm_name"]()}</Label>
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
@ -461,7 +461,7 @@
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="line_name">{m["productForm_lineName"]()}</Label>
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
@ -471,7 +471,7 @@
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sku">{m["productForm_sku"]()}</Label>
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
@ -488,7 +488,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="col-span-2 space-y-2">
<Label>{m["productForm_category"]()}</Label>
<input type="hidden" name="category" value={category} />
@ -564,7 +564,7 @@
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>{m["productForm_recommendedFor"]()}</Label>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each skinTypes as st}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
@ -588,7 +588,7 @@
<div class="space-y-2">
<Label>{m["productForm_targetConcerns"]()}</Label>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each skinConcerns as sc}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
@ -641,7 +641,7 @@
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label>{m["productForm_activeIngredients"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
</div>
@ -650,14 +650,23 @@
{#each actives as active, i}
<div class="rounded-md border border-border p-3 space-y-3">
<div class="grid grid-cols-[1fr_100px_120px_120px_auto] gap-2 items-end">
<div class="space-y-1">
<div class="flex items-end gap-2">
<div class="min-w-0 flex-1 space-y-1">
<Label class="text-xs">{m["productForm_activeName"]()}</Label>
<Input
placeholder="e.g. Niacinamide"
bind:value={active.name}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeActive(i)}
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
>✕</Button>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1">
<Label class="text-xs">{m["productForm_activePercent"]()}</Label>
<Input
@ -687,18 +696,11 @@
<option value="3">{m["productForm_strengthHigh"]()}</option>
</select>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeActive(i)}
class="text-destructive hover:text-destructive"
>✕</Button>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
<div class="grid grid-cols-4 gap-1">
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
{#each ingFunctions as fn}
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
<input
@ -764,7 +766,7 @@
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label>{m["productForm_incompatibleWith"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
{m["productForm_addIncompatibility"]()}
@ -774,7 +776,7 @@
<input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
{#each incompatibleWith as row, i}
<div class="grid grid-cols-[1fr_140px_1fr_auto] gap-2 items-end">
<div class="grid grid-cols-2 gap-2 items-end sm:grid-cols-[1fr_140px_1fr_auto]">
<div class="space-y-1">
<Label class="text-xs">{m["productForm_incompTarget"]()}</Label>
<Input placeholder="e.g. Vitamin C" bind:value={row.target} />
@ -813,7 +815,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label>{m["productForm_ctxAfterShaving"]()}</Label>
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
@ -884,7 +886,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label>{m["productForm_priceTier"]()}</Label>
<input type="hidden" name="price_tier" value={priceTier} />
@ -919,7 +921,7 @@
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
@ -948,7 +950,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label>{m["productForm_fragranceFree"]()}</Label>
<input type="hidden" name="fragrance_free" value={fragranceFree} />
@ -1008,7 +1010,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />

View file

@ -6,6 +6,8 @@
let { children } = $props();
let mobileMenuOpen = $state(false);
const navItems = $derived([
{ href: '/', label: m.nav_dashboard(), icon: '🏠' },
{ href: '/products', label: m.nav_products(), icon: '🧴' },
@ -28,9 +30,68 @@
}
</script>
<div class="flex min-h-screen bg-background">
<!-- Sidebar -->
<nav class="w-56 shrink-0 border-r border-border bg-card px-3 py-6">
<div class="flex min-h-screen flex-col bg-background md:flex-row">
<!-- Mobile header -->
<header class="flex items-center justify-between border-b border-border bg-card px-4 py-3 md:hidden">
<div>
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span>
</div>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Toggle menu"
>
{#if mobileMenuOpen}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
{/if}
</button>
</header>
<!-- Mobile drawer overlay -->
{#if mobileMenuOpen}
<!-- Backdrop: closes drawer on click -->
<button
type="button"
class="fixed inset-0 z-50 bg-black/50 md:hidden"
onclick={() => (mobileMenuOpen = false)}
aria-label={m.common_cancel()}
></button>
<!-- Drawer (same z-50 but later in DOM → on top of backdrop) -->
<nav
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
>
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
</div>
<ul class="space-y-1">
{#each navItems as item}
<li>
<a
href={item.href}
onclick={() => (mobileMenuOpen = false)}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors
{isActive(item.href)
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<span class="text-base">{item.icon}</span>
{item.label}
</a>
</li>
{/each}
</ul>
<div class="mt-6 px-3">
<LanguageSwitcher />
</div>
</nav>
{/if}
<!-- Desktop Sidebar -->
<nav class="hidden w-56 shrink-0 flex-col border-r border-border bg-card px-3 py-6 md:flex">
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
@ -57,7 +118,7 @@
</nav>
<!-- Main content -->
<main class="flex-1 overflow-auto p-8">
<main class="flex-1 overflow-auto p-4 md:p-8">
{@render children()}
</main>
</div>

View file

@ -79,7 +79,7 @@
<Card>
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="collected_at">{m["labResults_date"]()}</Label>
<Input id="collected_at" name="collected_at" type="date" required />
@ -125,7 +125,8 @@
</Card>
{/if}
<div class="rounded-md border border-border">
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<Table>
<TableHeader>
<TableRow>
@ -173,4 +174,34 @@
</TableBody>
</Table>
</div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
{#each data.results as r (r.record_id)}
<div class="rounded-lg border border-border p-4 flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
{#if r.flag}
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
{r.flag}
</span>
{/if}
</div>
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
<div class="flex items-center gap-2 text-sm">
<span class="font-mono text-xs text-muted-foreground">{r.test_code}</span>
{#if r.value_num != null}
<span>{r.value_num} {r.unit_original ?? ''}</span>
{:else if r.value_text}
<span>{r.value_text}</span>
{/if}
</div>
{#if r.lab}
<p class="text-xs text-muted-foreground">{r.lab}</p>
{/if}
</div>
{:else}
<p class="py-8 text-center text-sm text-muted-foreground">{m["labResults_noResults"]()}</p>
{/each}
</div>
</div>

View file

@ -56,7 +56,7 @@
<Card>
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1 col-span-2">
<Label>{m.medications_kind()}</Label>
<input type="hidden" name="kind" value={kind} />

View file

@ -66,7 +66,7 @@
<Button href="/products/new">{m["products_addNew"]()}</Button>
</div>
<div class="flex gap-1">
<div class="flex flex-wrap gap-1">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button
variant={ownershipFilter === f ? 'default' : 'outline'}
@ -78,7 +78,8 @@
{/each}
</div>
<div class="rounded-md border border-border">
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<Table>
<TableHeader>
<TableRow>
@ -128,4 +129,41 @@
</TableBody>
</Table>
</div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
{#if totalCount === 0}
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
{:else}
{#each groupedProducts as [category, products] (category)}
<div class="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{category.replace(/_/g, ' ')}
</div>
{#each products as product (product.id)}
<a
href="/products/{product.id}"
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-medium">{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</p>
</div>
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
</div>
{#if product.targets.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each product.targets.slice(0, 3) as t (t)}
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
{/each}
{#if product.targets.length > 3}
<span class="text-xs text-muted-foreground">+{product.targets.length - 3}</span>
{/if}
</div>
{/if}
</a>
{/each}
{/each}
{/if}
</div>
</div>

View file

@ -20,10 +20,9 @@
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<div>
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{product.name}</h2>
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
</div>
{#if form?.error}

View file

@ -22,12 +22,12 @@
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2>
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<Button href="/routines/suggest" variant="outline">{m["routines_suggestAI"]()}</Button>
<Button href="/routines/new">{m["routines_addNew"]()}</Button>
</div>

View file

@ -224,7 +224,7 @@
<Card>
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="snapshot_date">{m.skin_date()}</Label>
<Input
@ -332,7 +332,7 @@
await update();
if (result.type === 'success') editingId = null;
}}
class="grid grid-cols-2 gap-4"
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
<input type="hidden" name="id" value={snap.id} />
<div class="space-y-1">
@ -422,9 +422,19 @@
</form>
{:else}
<!-- Read view -->
<div class="flex items-center justify-between mb-3">
<div class="mb-3 space-y-1.5">
<div class="flex items-center justify-between">
<span class="font-medium">{snap.snapshot_date}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}>✎</Button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={snap.id} />
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}>×</Button>
</form>
</div>
</div>
{#if snap.overall_state || snap.texture}
<div class="flex flex-wrap items-center gap-1.5">
{#if snap.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
@ -433,16 +443,8 @@
{#if snap.texture}
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
{/if}
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 px-2 text-xs">
{m.common_edit()}
</Button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={snap.id} />
<Button type="submit" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive hover:text-destructive">
{m.common_delete()}
</Button>
</form>
</div>
{/if}
</div>
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
{#if snap.hydration_level != null}