innercontext/frontend/src/routes/routines/[id]/+page.svelte

371 lines
13 KiB
Svelte

<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
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 * as m from '$lib/paraglide/messages.js';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import { SvelteMap } from 'svelte/reactivity';
import { GripVertical, Pencil, X } from 'lucide-svelte';
import { preventIfNotConfirmed } from '$lib/utils/forms';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data);
// ── Steps local state (synced from server data) ───────────────
let steps = $derived([...(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 SvelteMap<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);
});
const groupedProductOptions = $derived(
groupedProducts.map(([cat, items]) => ({
label: formatCategory(cat),
options: items.map((p) => ({ value: p.id, label: `${p.name} · ${p.brand}` }))
}))
);
const groomingActionOptions = GROOMING_ACTIONS.map((action) => ({
value: action,
label: action.replace(/_/g, ' ')
}));
const flashMessages = $derived([
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : [])
]);
</script>
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4">
<PageHeader
title={routine.routine_date}
kicker={m["nav_appSubtitle"]()}
backHref={resolve('/routines')}
backLabel={m["routines_backToList"]()}
titleClassName="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]"
>
{#snippet meta()}
<div class="editorial-meta-strip">
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()}
</Badge>
<span>{m.routines_steps({ count: steps.length })}</span>
{#if routine.notes}
<span></span>
<span class="max-w-[46ch] truncate">{routine.notes}</span>
{/if}
</div>
{/snippet}
<div class="hero-strip" aria-label={routine.routine_date}>
<div>
<p class="hero-strip-label">{m["routines_amOrPm"]()}</p>
<p class="hero-strip-value">{routine.part_of_day.toUpperCase()}</p>
</div>
<div>
<p class="hero-strip-label">{m.routines_steps({ count: steps.length })}</p>
<p class="hero-strip-value">{steps.length}</p>
</div>
</div>
</PageHeader>
<FlashMessages messages={flashMessages} />
{#if routine.notes}
<div class="editorial-panel reveal-2">
<p class="text-sm leading-6 text-muted-foreground">{routine.notes}</p>
</div>
{/if}
<!-- Steps -->
<div class="editorial-panel reveal-2 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}
<FormSectionCard title={m["routines_addStepTitle"]()}>
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
<GroupedSelect
id="new_step_product"
name="product_id"
label={m.routines_product()}
groups={groupedProductOptions}
placeholder={m["routines_selectProduct"]()}
bind:value={selectedProductId}
/>
<input type="hidden" name="order_index" value={nextOrderIndex} />
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<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>
<div class="flex items-center justify-end gap-2">
<Button type="button" size="sm" variant="outline" onclick={() => (showStepForm = false)}>{m.common_cancel()}</Button>
<Button type="submit" size="sm">{m["routines_addStepBtn"]()}</Button>
</div>
</form>
</FormSectionCard>
{/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 -->
<GroupedSelect
id={`edit_step_product_${step.id}`}
label={m.routines_product()}
groups={groupedProductOptions}
placeholder={m["routines_selectProduct"]()}
value={editDraft.product_id ?? ''}
onChange={(value) => (editDraft.product_id = value || undefined)}
/>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<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 -->
<SimpleSelect
id={`edit_step_action_${step.id}`}
label={m["routines_action"]()}
options={groomingActionOptions}
placeholder={m["routines_selectAction"]()}
value={editDraft.action_type ?? ''}
onChange={(value) =>
(editDraft.action_type = (value || undefined) as GroomingAction | undefined)}
/>
<div class="space-y-1">
<Label>{m.routines_notes()}</Label>
<Input
value={editDraft.action_notes ?? ''}
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
placeholder={m["routines_actionNotesPlaceholder"]()}
/>
</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={m.common_dragToReorder()}
><GripVertical class="size-4" /></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={m.common_editStep()}
><Pencil class="size-4" /></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"
><X class="size-4" /></Button>
</form>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["routines_noSteps"]()}</p>
{/if}
</div>
<FormSectionCard title={m.common_edit()} className="reveal-3" contentClassName="space-y-4">
<p class="text-sm text-muted-foreground">{m["routines_confirmDelete"]()}</p>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => preventIfNotConfirmed(e, m["routines_confirmDelete"]())}
>
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
</form>
</FormSectionCard>
</div>