innercontext/frontend/src/routes/skin/+page.svelte
Piotr Oleszczyk 679e4e81f4 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>
2026-03-02 13:35:25 +01:00

496 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { analyzeSkinPhotos } from '$lib/api';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
let { data, form }: { data: PageData; form: ActionData } = $props();
const states = ['excellent', 'good', 'fair', 'poor'];
const skinTextures = ['smooth', 'rough', 'flaky', 'bumpy'];
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
const stateColors: Record<string, string> = {
excellent: 'bg-green-100 text-green-800',
good: 'bg-blue-100 text-blue-800',
fair: 'bg-yellow-100 text-yellow-800',
poor: 'bg-red-100 text-red-800'
};
const stateLabels: Record<string, () => string> = {
excellent: m["skin_stateExcellent"],
good: m["skin_stateGood"],
fair: m["skin_stateFair"],
poor: m["skin_statePoor"]
};
const textureLabels: Record<string, () => string> = {
smooth: m["skin_textureSmooth"],
rough: m["skin_textureRough"],
flaky: m["skin_textureFlaky"],
bumpy: m["skin_textureBumpy"]
};
const barrierLabels: Record<string, () => string> = {
intact: m["skin_barrierIntact"],
mildly_compromised: m["skin_barrierMildly"],
compromised: m["skin_barrierCompromised"]
};
const skinTypeLabels: Record<string, () => string> = {
dry: m["skin_typeDry"],
oily: m["skin_typeOily"],
combination: m["skin_typeCombination"],
sensitive: m["skin_typeSensitive"],
normal: m["skin_typeNormal"],
acne_prone: m["skin_typeAcneProne"]
};
let showForm = $state(false);
// Create form state (bound to inputs so AI can pre-fill)
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
let overallState = $state('');
let texture = $state('');
let barrierState = $state('');
let skinType = $state('');
let hydrationLevel = $state('');
let sensitivityLevel = $state('');
let sebumTzone = $state('');
let sebumCheeks = $state('');
let activeConcernsRaw = $state('');
let prioritiesRaw = $state('');
let notes = $state('');
// Edit state
let editingId = $state<string | null>(null);
let editSnapshotDate = $state('');
let editOverallState = $state('');
let editTexture = $state('');
let editBarrierState = $state('');
let editSkinType = $state('');
let editHydrationLevel = $state('');
let editSensitivityLevel = $state('');
let editSebumTzone = $state('');
let editSebumCheeks = $state('');
let editActiveConcernsRaw = $state('');
let editPrioritiesRaw = $state('');
let editNotes = $state('');
function startEdit(snap: (typeof data.snapshots)[number]) {
editingId = snap.id;
editSnapshotDate = snap.snapshot_date;
editOverallState = snap.overall_state ?? '';
editTexture = snap.texture ?? '';
editBarrierState = snap.barrier_state ?? '';
editSkinType = snap.skin_type ?? '';
editHydrationLevel = snap.hydration_level != null ? String(snap.hydration_level) : '';
editSensitivityLevel = snap.sensitivity_level != null ? String(snap.sensitivity_level) : '';
editSebumTzone = snap.sebum_tzone != null ? String(snap.sebum_tzone) : '';
editSebumCheeks = snap.sebum_cheeks != null ? String(snap.sebum_cheeks) : '';
editActiveConcernsRaw = snap.active_concerns?.join(', ') ?? '';
editPrioritiesRaw = snap.priorities?.join(', ') ?? '';
editNotes = snap.notes ?? '';
showForm = false;
}
// AI photo analysis state
let aiPanelOpen = $state(false);
let selectedFiles = $state<File[]>([]);
let previewUrls = $state<string[]>([]);
let aiLoading = $state(false);
let aiError = $state('');
const sortedSnapshots = $derived(
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
);
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []).slice(0, 3);
selectedFiles = files;
previewUrls.forEach(URL.revokeObjectURL);
previewUrls = files.map((f) => URL.createObjectURL(f));
}
async function analyzePhotos() {
if (!selectedFiles.length) return;
aiLoading = true;
aiError = '';
try {
const r = await analyzeSkinPhotos(selectedFiles);
if (r.overall_state) overallState = r.overall_state;
if (r.texture) texture = r.texture;
if (r.skin_type) skinType = r.skin_type;
if (r.barrier_state) barrierState = r.barrier_state;
if (r.hydration_level != null) hydrationLevel = String(r.hydration_level);
if (r.sensitivity_level != null) sensitivityLevel = String(r.sensitivity_level);
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
if (r.notes) notes = r.notes;
aiPanelOpen = false;
} catch (e) {
aiError = (e as Error).message;
} finally {
aiLoading = false;
}
}
</script>
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.skin_title()}</h2>
<p class="text-muted-foreground">{m.skin_count({ count: data.snapshots.length })}</p>
</div>
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["skin_addNew"]()}
</Button>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotAdded"]()}</div>
{/if}
{#if form?.updated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotUpdated"]()}</div>
{/if}
{#if form?.deleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotDeleted"]()}</div>
{/if}
{#if showForm}
<!-- AI photo analysis card -->
<Card>
<CardHeader>
<button
type="button"
class="flex w-full items-center justify-between text-left"
onclick={() => (aiPanelOpen = !aiPanelOpen)}
>
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
</button>
</CardHeader>
{#if aiPanelOpen}
<CardContent class="space-y-3">
<p class="text-sm text-muted-foreground">
{m["skin_aiUploadText"]()}
</p>
<input
type="file"
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
multiple
onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground
file:mr-4 file:rounded-md file:border-0 file:bg-primary
file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
{#if previewUrls.length}
<div class="flex flex-wrap gap-2">
{#each previewUrls as url (url)}
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md object-cover border" />
{/each}
</div>
{/if}
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<Button
type="button"
onclick={analyzePhotos}
disabled={aiLoading || !selectedFiles.length}
>
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
</Button>
</CardContent>
{/if}
</Card>
<!-- New snapshot form -->
<Card>
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
<CardContent>
<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
id="snapshot_date"
name="snapshot_date"
type="date"
bind:value={snapshotDate}
required
/>
</div>
<div class="space-y-1">
<Label>{m["skin_overallState"]()}</Label>
<input type="hidden" name="overall_state" value={overallState} />
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
<SelectTrigger>{overallState ? stateLabels[overallState]?.() ?? overallState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each states as s (s)}
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m.skin_texture()}</Label>
<input type="hidden" name="texture" value={texture} />
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
<SelectTrigger>{texture ? textureLabels[texture]?.() ?? texture : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTextures as t (t)}
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_skinType"]()}</Label>
<input type="hidden" name="skin_type" value={skinType} />
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
<SelectTrigger>{skinType ? skinTypeLabels[skinType]?.() ?? skinType : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTypes as st (st)}
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_barrierState"]()}</Label>
<input type="hidden" name="barrier_state" value={barrierState} />
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
<SelectTrigger>{barrierState ? barrierLabels[barrierState]?.() ?? barrierState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each barrierStates as b (b)}
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="hydration_level">{m.skin_hydration()}</Label>
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={hydrationLevel} />
</div>
<div class="space-y-1">
<Label for="sensitivity_level">{m.skin_sensitivity()}</Label>
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={sensitivityLevel} />
</div>
<div class="space-y-1">
<Label for="sebum_tzone">{m["skin_sebumTzone"]()}</Label>
<Input id="sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={sebumTzone} />
</div>
<div class="space-y-1">
<Label for="sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
<Input id="sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={sebumCheeks} />
</div>
<div class="space-y-1 col-span-2">
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="priorities">{m["skin_priorities"]()}</Label>
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="notes">{m.skin_notes()}</Label>
<Input id="notes" name="notes" bind:value={notes} />
</div>
<div class="col-span-2">
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
<div class="space-y-4">
{#each sortedSnapshots as snap (snap.id)}
<Card>
<CardContent class="pt-4">
{#if editingId === snap.id}
<!-- Inline edit form -->
<form
method="POST"
action="?/update"
use:enhance={() => async ({ result, update }) => {
await update();
if (result.type === 'success') editingId = null;
}}
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
<input type="hidden" name="id" value={snap.id} />
<div class="space-y-1">
<Label for="edit_snapshot_date">{m.skin_date()}</Label>
<Input id="edit_snapshot_date" name="snapshot_date" type="date" bind:value={editSnapshotDate} required />
</div>
<div class="space-y-1">
<Label>{m["skin_overallState"]()}</Label>
<input type="hidden" name="overall_state" value={editOverallState} />
<Select type="single" value={editOverallState} onValueChange={(v) => (editOverallState = v)}>
<SelectTrigger>{editOverallState ? stateLabels[editOverallState]?.() ?? editOverallState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each states as s (s)}
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m.skin_texture()}</Label>
<input type="hidden" name="texture" value={editTexture} />
<Select type="single" value={editTexture} onValueChange={(v) => (editTexture = v)}>
<SelectTrigger>{editTexture ? textureLabels[editTexture]?.() ?? editTexture : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTextures as t (t)}
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_skinType"]()}</Label>
<input type="hidden" name="skin_type" value={editSkinType} />
<Select type="single" value={editSkinType} onValueChange={(v) => (editSkinType = v)}>
<SelectTrigger>{editSkinType ? skinTypeLabels[editSkinType]?.() ?? editSkinType : m.common_select()}</SelectTrigger>
<SelectContent>
{#each skinTypes as st (st)}
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>{m["skin_barrierState"]()}</Label>
<input type="hidden" name="barrier_state" value={editBarrierState} />
<Select type="single" value={editBarrierState} onValueChange={(v) => (editBarrierState = v)}>
<SelectTrigger>{editBarrierState ? barrierLabels[editBarrierState]?.() ?? editBarrierState : m.common_select()}</SelectTrigger>
<SelectContent>
{#each barrierStates as b (b)}
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label for="edit_hydration_level">{m.skin_hydration()}</Label>
<Input id="edit_hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={editHydrationLevel} />
</div>
<div class="space-y-1">
<Label for="edit_sensitivity_level">{m.skin_sensitivity()}</Label>
<Input id="edit_sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={editSensitivityLevel} />
</div>
<div class="space-y-1">
<Label for="edit_sebum_tzone">{m["skin_sebumTzone"]()}</Label>
<Input id="edit_sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={editSebumTzone} />
</div>
<div class="space-y-1">
<Label for="edit_sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
<Input id="edit_sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={editSebumCheeks} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_priorities">{m["skin_priorities"]()}</Label>
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_notes">{m.skin_notes()}</Label>
<Input id="edit_notes" name="notes" bind:value={editNotes} />
</div>
<div class="col-span-2 flex gap-2">
<Button type="submit">{m.common_save()}</Button>
<Button type="button" variant="outline" onclick={() => (editingId = null)}>{m.common_cancel()}</Button>
</div>
</form>
{:else}
<!-- Read view -->
<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-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}
</span>
{/if}
{#if snap.texture}
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
{/if}
</div>
{/if}
</div>
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
{#if snap.hydration_level != null}
<div>
<p class="text-xs text-muted-foreground">{m["skin_hydrationLabel"]()}</p>
<p class="font-medium">{snap.hydration_level}/5</p>
</div>
{/if}
{#if snap.sensitivity_level != null}
<div>
<p class="text-xs text-muted-foreground">{m["skin_sensitivityLabel"]()}</p>
<p class="font-medium">{snap.sensitivity_level}/5</p>
</div>
{/if}
{#if snap.barrier_state}
<div>
<p class="text-xs text-muted-foreground">{m["skin_barrierLabel"]()}</p>
<p class="font-medium">{barrierLabels[snap.barrier_state]?.() ?? snap.barrier_state.replace(/_/g, ' ')}</p>
</div>
{/if}
</div>
{#if snap.active_concerns.length}
<div class="flex flex-wrap gap-1">
{#each snap.active_concerns as c (c)}
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
{#if snap.priorities?.length}
<div class="mt-2">
<p class="text-xs text-muted-foreground mb-1">{m["skin_prioritiesLabel"]()}</p>
<div class="flex flex-wrap gap-1">
{#each snap.priorities as p (p)}
<Badge variant="outline" class="text-xs">{p}</Badge>
{/each}
</div>
</div>
{/if}
{#if snap.notes}
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
{/if}
{/if}
</CardContent>
</Card>
{:else}
<p class="text-sm text-muted-foreground">{m["skin_noSnapshots"]()}</p>
{/each}
</div>
</div>