feat(frontend): add edit and delete for skin snapshots
Add inline edit form and delete button to each snapshot card on /skin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d62812274b
commit
d938c9999b
2 changed files with 309 additions and 41 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { createSkinSnapshot, getSkinSnapshots } from '$lib/api';
|
import { createSkinSnapshot, deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } from '$lib/api';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
|
@ -50,5 +50,59 @@ export const actions: Actions = {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(500, { error: (e as Error).message });
|
return fail(500, { error: (e as Error).message });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const id = form.get('id') as string;
|
||||||
|
const snapshot_date = form.get('snapshot_date') as string;
|
||||||
|
const overall_state = form.get('overall_state') as string;
|
||||||
|
const texture = form.get('texture') as string;
|
||||||
|
const notes = form.get('notes') as string;
|
||||||
|
const hydration_level = form.get('hydration_level') as string;
|
||||||
|
const sensitivity_level = form.get('sensitivity_level') as string;
|
||||||
|
const barrier_state = form.get('barrier_state') as string;
|
||||||
|
const active_concerns_raw = form.get('active_concerns') as string;
|
||||||
|
const skin_type = form.get('skin_type') as string;
|
||||||
|
const sebum_tzone = form.get('sebum_tzone') as string;
|
||||||
|
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
||||||
|
|
||||||
|
if (!id) return fail(400, { error: 'Missing id' });
|
||||||
|
|
||||||
|
const active_concerns = active_concerns_raw
|
||||||
|
?.split(',')
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { active_concerns };
|
||||||
|
if (snapshot_date) body.snapshot_date = snapshot_date;
|
||||||
|
if (overall_state) body.overall_state = overall_state;
|
||||||
|
if (texture) body.texture = texture;
|
||||||
|
if (notes) body.notes = notes;
|
||||||
|
if (hydration_level) body.hydration_level = Number(hydration_level);
|
||||||
|
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
||||||
|
if (barrier_state) body.barrier_state = barrier_state;
|
||||||
|
if (skin_type) body.skin_type = skin_type;
|
||||||
|
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
|
||||||
|
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSkinSnapshot(id, body);
|
||||||
|
return { updated: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const id = form.get('id') as string;
|
||||||
|
if (!id) return fail(400, { error: 'Missing id' });
|
||||||
|
try {
|
||||||
|
await deleteSkinSnapshot(id);
|
||||||
|
return { deleted: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
|
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
|
|
||||||
// Form state (bound to inputs so AI can pre-fill)
|
// Create form state (bound to inputs so AI can pre-fill)
|
||||||
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
|
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
|
||||||
let overallState = $state('');
|
let overallState = $state('');
|
||||||
let texture = $state('');
|
let texture = $state('');
|
||||||
|
|
@ -38,6 +38,36 @@
|
||||||
let activeConcernsRaw = $state('');
|
let activeConcernsRaw = $state('');
|
||||||
let notes = $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 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(', ') ?? '';
|
||||||
|
editNotes = snap.notes ?? '';
|
||||||
|
showForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
// AI photo analysis state
|
// AI photo analysis state
|
||||||
let aiPanelOpen = $state(false);
|
let aiPanelOpen = $state(false);
|
||||||
let selectedFiles = $state<File[]>([]);
|
let selectedFiles = $state<File[]>([]);
|
||||||
|
|
@ -101,6 +131,12 @@
|
||||||
{#if form?.created}
|
{#if form?.created}
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot added.</div>
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot added.</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if form?.updated}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot updated.</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.deleted}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot deleted.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<!-- AI photo analysis card -->
|
<!-- AI photo analysis card -->
|
||||||
|
|
@ -284,52 +320,230 @@
|
||||||
{#each sortedSnapshots as snap (snap.id)}
|
{#each sortedSnapshots as snap (snap.id)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="pt-4">
|
<CardContent class="pt-4">
|
||||||
<div class="flex items-center justify-between mb-3">
|
{#if editingId === snap.id}
|
||||||
<span class="font-medium">{snap.snapshot_date}</span>
|
<!-- Inline edit form -->
|
||||||
<div class="flex gap-2">
|
<form
|
||||||
{#if snap.overall_state}
|
method="POST"
|
||||||
<span
|
action="?/update"
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[
|
use:enhance={() => async ({ result, update }) => {
|
||||||
snap.overall_state
|
await update();
|
||||||
] ?? ''}"
|
if (result.type === 'success') editingId = null;
|
||||||
|
}}
|
||||||
|
class="grid grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={snap.id} />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_snapshot_date">Date *</Label>
|
||||||
|
<Input
|
||||||
|
id="edit_snapshot_date"
|
||||||
|
name="snapshot_date"
|
||||||
|
type="date"
|
||||||
|
bind:value={editSnapshotDate}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Overall state</Label>
|
||||||
|
<input type="hidden" name="overall_state" value={editOverallState} />
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editOverallState}
|
||||||
|
onValueChange={(v) => (editOverallState = v)}
|
||||||
>
|
>
|
||||||
{snap.overall_state}
|
<SelectTrigger>{editOverallState || 'Select'}</SelectTrigger>
|
||||||
</span>
|
<SelectContent>
|
||||||
|
{#each states as s (s)}
|
||||||
|
<SelectItem value={s}>{s}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Texture</Label>
|
||||||
|
<input type="hidden" name="texture" value={editTexture} />
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editTexture}
|
||||||
|
onValueChange={(v) => (editTexture = v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>{editTexture || 'Select'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each skinTextures as t (t)}
|
||||||
|
<SelectItem value={t}>{t}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Skin type</Label>
|
||||||
|
<input type="hidden" name="skin_type" value={editSkinType} />
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editSkinType}
|
||||||
|
onValueChange={(v) => (editSkinType = v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
>{editSkinType ? editSkinType.replace(/_/g, ' ') : 'Select'}</SelectTrigger
|
||||||
|
>
|
||||||
|
<SelectContent>
|
||||||
|
{#each skinTypes as st (st)}
|
||||||
|
<SelectItem value={st}>{st.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Barrier state</Label>
|
||||||
|
<input type="hidden" name="barrier_state" value={editBarrierState} />
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editBarrierState}
|
||||||
|
onValueChange={(v) => (editBarrierState = v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
>{editBarrierState
|
||||||
|
? editBarrierState.replace(/_/g, ' ')
|
||||||
|
: 'Select'}</SelectTrigger
|
||||||
|
>
|
||||||
|
<SelectContent>
|
||||||
|
{#each barrierStates as b (b)}
|
||||||
|
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_hydration_level">Hydration (1–5)</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">Sensitivity (1–5)</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">Sebum T-zone (1–5)</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">Sebum cheeks (1–5)</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">Active concerns (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit_active_concerns"
|
||||||
|
name="active_concerns"
|
||||||
|
placeholder="acne, redness, dehydration"
|
||||||
|
bind:value={editActiveConcernsRaw}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 col-span-2">
|
||||||
|
<Label for="edit_notes">Notes</Label>
|
||||||
|
<Input id="edit_notes" name="notes" bind:value={editNotes} />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 flex gap-2">
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
<Button type="button" variant="outline" onclick={() => (editingId = null)}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- Read view -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="font-medium">{snap.snapshot_date}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if snap.overall_state}
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[
|
||||||
|
snap.overall_state
|
||||||
|
] ?? ''}"
|
||||||
|
>
|
||||||
|
{snap.overall_state}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if snap.texture}
|
||||||
|
<Badge variant="secondary">{snap.texture}</Badge>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => startEdit(snap)}
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</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">Hydration</p>
|
||||||
|
<p class="font-medium">{snap.hydration_level}/5</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if snap.texture}
|
{#if snap.sensitivity_level != null}
|
||||||
<Badge variant="secondary">{snap.texture}</Badge>
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Sensitivity</p>
|
||||||
|
<p class="font-medium">{snap.sensitivity_level}/5</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if snap.barrier_state}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Barrier</p>
|
||||||
|
<p class="font-medium">{snap.barrier_state.replace(/_/g, ' ')}</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{#if snap.active_concerns.length}
|
||||||
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#if snap.hydration_level != null}
|
{#each snap.active_concerns as c (c)}
|
||||||
<div>
|
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
|
||||||
<p class="text-xs text-muted-foreground">Hydration</p>
|
{/each}
|
||||||
<p class="font-medium">{snap.hydration_level}/5</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if snap.sensitivity_level != null}
|
{#if snap.notes}
|
||||||
<div>
|
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
|
||||||
<p class="text-xs text-muted-foreground">Sensitivity</p>
|
|
||||||
<p class="font-medium">{snap.sensitivity_level}/5</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if snap.barrier_state}
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-muted-foreground">Barrier</p>
|
|
||||||
<p class="font-medium">{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.notes}
|
|
||||||
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue