Products in the Add/Edit step dropdowns are now grouped by category (Cleanser → Serum → Moisturizer → …) using SelectGroup/SelectGroupHeading, and sorted alphabetically by brand then name within each group. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
378 lines
13 KiB
Svelte
378 lines
13 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||
import { updateRoutineStep } from '$lib/api';
|
||
import type { GroomingAction, RoutineStep } from '$lib/types';
|
||
import type { ActionData, PageData } from './$types';
|
||
import { m } from '$lib/paraglide/messages.js';
|
||
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,
|
||
SelectGroup,
|
||
SelectGroupHeading,
|
||
SelectItem,
|
||
SelectTrigger
|
||
} from '$lib/components/ui/select';
|
||
import { Separator } from '$lib/components/ui/separator';
|
||
|
||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||
let { routine, products } = $derived(data);
|
||
|
||
// ── Steps local state (synced from server data) ───────────────
|
||
let steps = $state<RoutineStep[]>([]);
|
||
$effect(() => {
|
||
steps = [...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index);
|
||
});
|
||
|
||
const nextOrderIndex = $derived(
|
||
steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0
|
||
);
|
||
|
||
// ── Drag & drop reordering ────────────────────────────────────
|
||
let dndSaving = $state(false);
|
||
|
||
function handleConsider(e: CustomEvent<DndEvent<RoutineStep>>) {
|
||
steps = e.detail.items;
|
||
}
|
||
|
||
async function handleFinalize(e: CustomEvent<DndEvent<RoutineStep>>) {
|
||
const newItems = e.detail.items;
|
||
// Assign new order_index = position in array, detect which changed
|
||
const updated = newItems.map((s, i) => ({ ...s, order_index: i }));
|
||
const changed = updated.filter((s, i) => s.order_index !== newItems[i].order_index);
|
||
steps = updated;
|
||
if (changed.length) {
|
||
dndSaving = true;
|
||
try {
|
||
await Promise.all(
|
||
changed.map((s) => updateRoutineStep(s.id, { order_index: s.order_index }))
|
||
);
|
||
} finally {
|
||
dndSaving = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Inline editing ────────────────────────────────────────────
|
||
let editingStepId = $state<string | null>(null);
|
||
let editDraft = $state<Partial<RoutineStep>>({});
|
||
let editSaving = $state(false);
|
||
let editError = $state('');
|
||
|
||
function startEdit(step: RoutineStep) {
|
||
editingStepId = step.id;
|
||
editDraft = {
|
||
dose: step.dose ?? '',
|
||
region: step.region ?? '',
|
||
product_id: step.product_id,
|
||
action_type: step.action_type,
|
||
action_notes: step.action_notes ?? ''
|
||
};
|
||
editError = '';
|
||
}
|
||
|
||
async function saveEdit(step: RoutineStep) {
|
||
editSaving = true;
|
||
editError = '';
|
||
try {
|
||
const payload: Record<string, unknown> = {};
|
||
if (step.product_id !== undefined) {
|
||
payload.product_id = editDraft.product_id;
|
||
payload.dose = editDraft.dose || null;
|
||
payload.region = editDraft.region || null;
|
||
} else {
|
||
payload.action_type = editDraft.action_type;
|
||
payload.action_notes = editDraft.action_notes || null;
|
||
}
|
||
const updatedStep = await updateRoutineStep(step.id, payload);
|
||
steps = steps.map((s) => (s.id === step.id ? { ...s, ...updatedStep } : s));
|
||
editingStepId = null;
|
||
} catch (err) {
|
||
editError = (err as Error).message;
|
||
} finally {
|
||
editSaving = false;
|
||
}
|
||
}
|
||
|
||
function cancelEdit() {
|
||
editingStepId = null;
|
||
editDraft = {};
|
||
editError = '';
|
||
}
|
||
|
||
// ── Add step form ─────────────────────────────────────────────
|
||
let showStepForm = $state(false);
|
||
let selectedProductId = $state('');
|
||
|
||
const GROOMING_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
|
||
|
||
const CATEGORY_ORDER = [
|
||
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
|
||
'spf', 'mask', 'exfoliant', 'spot_treatment', 'oil',
|
||
'hair_treatment', 'tool', 'other'
|
||
];
|
||
|
||
function formatCategory(cat: string): string {
|
||
return cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
|
||
}
|
||
|
||
const groupedProducts = $derived.by(() => {
|
||
const groups = new Map<string, typeof products>();
|
||
for (const p of products) {
|
||
const key = p.category ?? 'other';
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key)!.push(p);
|
||
}
|
||
for (const list of groups.values()) {
|
||
list.sort((a, b) => {
|
||
const b_cmp = (a.brand ?? '').localeCompare(b.brand ?? '');
|
||
return b_cmp !== 0 ? b_cmp : a.name.localeCompare(b.name);
|
||
});
|
||
}
|
||
return CATEGORY_ORDER
|
||
.filter((c) => groups.has(c))
|
||
.map((c) => [c, groups.get(c)!] as const);
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
||
|
||
<div class="max-w-2xl space-y-6">
|
||
<div class="flex items-center gap-4">
|
||
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["routines_backToList"]()}</a>
|
||
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
|
||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||
{routine.part_of_day.toUpperCase()}
|
||
</Badge>
|
||
</div>
|
||
|
||
{#if form?.error}
|
||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||
{/if}
|
||
|
||
{#if routine.notes}
|
||
<p class="text-sm text-muted-foreground">{routine.notes}</p>
|
||
{/if}
|
||
|
||
<!-- Steps -->
|
||
<div class="space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
|
||
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
|
||
{showStepForm ? m.common_cancel() : m["routines_addStep"]()}
|
||
</Button>
|
||
</div>
|
||
|
||
{#if showStepForm}
|
||
<Card>
|
||
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
|
||
<CardContent>
|
||
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
|
||
<div class="space-y-1">
|
||
<Label>{m.routines_product()}</Label>
|
||
<input type="hidden" name="product_id" value={selectedProductId} />
|
||
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}>
|
||
<SelectTrigger>
|
||
{#if selectedProductId}
|
||
{products.find((p) => p.id === selectedProductId)?.name ?? m["routines_selectProduct"]()}
|
||
{:else}
|
||
{m["routines_selectProduct"]()}
|
||
{/if}
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{#each groupedProducts as [cat, items]}
|
||
<SelectGroup>
|
||
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
|
||
{#each items as p (p.id)}
|
||
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
|
||
{/each}
|
||
</SelectGroup>
|
||
{/each}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<input type="hidden" name="order_index" value={nextOrderIndex} />
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="space-y-1">
|
||
<Label for="dose">{m.routines_dose()}</Label>
|
||
<Input id="dose" name="dose" placeholder={m["routines_dosePlaceholder"]()} />
|
||
</div>
|
||
<div class="space-y-1">
|
||
<Label for="region">{m.routines_region()}</Label>
|
||
<Input id="region" name="region" placeholder={m["routines_regionPlaceholder"]()} />
|
||
</div>
|
||
</div>
|
||
<Button type="submit" size="sm">{m["routines_addStepBtn"]()}</Button>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
{/if}
|
||
|
||
{#if steps.length}
|
||
<div
|
||
use:dragHandleZone={{ items: steps, flipDurationMs: 200, dragDisabled: !!editingStepId || dndSaving }}
|
||
onconsider={handleConsider}
|
||
onfinalize={handleFinalize}
|
||
class="space-y-2"
|
||
>
|
||
{#each steps as step, i (step.id)}
|
||
<div class="rounded-md border border-border bg-background">
|
||
{#if editingStepId === step.id}
|
||
<!-- ── Edit mode ── -->
|
||
<div class="px-4 py-3 space-y-3">
|
||
{#if step.product_id !== undefined}
|
||
<!-- Product step: change product / dose / region -->
|
||
<div class="space-y-1">
|
||
<Label>{m.routines_product()}</Label>
|
||
<Select
|
||
type="single"
|
||
value={editDraft.product_id ?? ''}
|
||
onValueChange={(v) => (editDraft.product_id = v || undefined)}
|
||
>
|
||
<SelectTrigger>
|
||
{#if editDraft.product_id}
|
||
{products.find((p) => p.id === editDraft.product_id)?.name ?? m["routines_selectProduct"]()}
|
||
{:else}
|
||
{m["routines_selectProduct"]()}
|
||
{/if}
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{#each groupedProducts as [cat, items]}
|
||
<SelectGroup>
|
||
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
|
||
{#each items as p (p.id)}
|
||
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
|
||
{/each}
|
||
</SelectGroup>
|
||
{/each}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="space-y-1">
|
||
<Label>{m.routines_dose()}</Label>
|
||
<Input
|
||
value={editDraft.dose ?? ''}
|
||
oninput={(e) => (editDraft.dose = e.currentTarget.value)}
|
||
placeholder={m["routines_dosePlaceholder"]()}
|
||
/>
|
||
</div>
|
||
<div class="space-y-1">
|
||
<Label>{m.routines_region()}</Label>
|
||
<Input
|
||
value={editDraft.region ?? ''}
|
||
oninput={(e) => (editDraft.region = e.currentTarget.value)}
|
||
placeholder={m["routines_regionPlaceholder"]()}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<!-- Action step: change action_type / notes -->
|
||
<div class="space-y-1">
|
||
<Label>Action</Label>
|
||
<Select
|
||
type="single"
|
||
value={editDraft.action_type ?? ''}
|
||
onValueChange={(v) =>
|
||
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
|
||
>
|
||
<SelectTrigger>
|
||
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{#each GROOMING_ACTIONS as action (action)}
|
||
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
|
||
{/each}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div class="space-y-1">
|
||
<Label>Notes</Label>
|
||
<Input
|
||
value={editDraft.action_notes ?? ''}
|
||
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
|
||
placeholder="optional notes"
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if editError}
|
||
<p class="text-sm text-destructive">{editError}</p>
|
||
{/if}
|
||
|
||
<div class="flex gap-2">
|
||
<Button size="sm" onclick={() => saveEdit(step)} disabled={editSaving}>
|
||
{m.common_save()}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onclick={cancelEdit} disabled={editSaving}>
|
||
{m.common_cancel()}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<!-- ── View mode ── -->
|
||
<div class="flex items-center gap-1 px-2 py-3">
|
||
<span
|
||
use:dragHandle
|
||
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
|
||
aria-label="drag to reorder"
|
||
>⋮⋮</span>
|
||
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
|
||
<div class="flex-1 min-w-0 px-1">
|
||
{#if step.product_id}
|
||
{@const product = products.find((p) => p.id === step.product_id)}
|
||
<p class="text-sm font-medium truncate">{product?.name ?? step.product_id}</p>
|
||
{#if product?.brand}<p class="text-xs text-muted-foreground truncate">{product.brand}</p>{/if}
|
||
{:else if step.action_type}
|
||
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
|
||
{:else}
|
||
<p class="text-sm text-muted-foreground">{m["routines_unknownStep"]()}</p>
|
||
{/if}
|
||
</div>
|
||
{#if step.dose}
|
||
<span class="shrink-0 text-xs text-muted-foreground">{step.dose}</span>
|
||
{/if}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||
onclick={() => startEdit(step)}
|
||
aria-label="edit step"
|
||
>✎</Button>
|
||
<form method="POST" action="?/removeStep" use:enhance>
|
||
<input type="hidden" name="step_id" value={step.id} />
|
||
<Button
|
||
type="submit"
|
||
variant="ghost"
|
||
size="sm"
|
||
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||
>×</Button>
|
||
</form>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<p class="text-sm text-muted-foreground">{m["routines_noSteps"]()}</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<form
|
||
method="POST"
|
||
action="?/delete"
|
||
use:enhance
|
||
onsubmit={(e) => {
|
||
if (!confirm(m["routines_confirmDelete"]())) e.preventDefault();
|
||
}}
|
||
>
|
||
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
|
||
</form>
|
||
</div>
|