feat(frontend): add drag-and-drop reordering and inline editing for routine steps
- Install svelte-dnd-action v0.9.69 - Use dragHandleZone + dragHandle for per-step ⋮⋮ drag handles - PATCH only steps whose order_index changed after a drop - Inline edit mode (✎ button) expands step in-place: product steps show product/dose/region selects; action steps show action_type/notes - DnD disabled while a step is being edited Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5e2536138b
commit
4b0fedde35
3 changed files with 243 additions and 30 deletions
|
|
@ -31,6 +31,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-svelte": "^0.575.0",
|
"lucide-svelte": "^0.575.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
|
"svelte-dnd-action": "^0.9.69",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
frontend/pnpm-lock.yaml
generated
12
frontend/pnpm-lock.yaml
generated
|
|
@ -23,6 +23,9 @@ importers:
|
||||||
mode-watcher:
|
mode-watcher:
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0(svelte@5.53.5)
|
version: 1.1.0(svelte@5.53.5)
|
||||||
|
svelte-dnd-action:
|
||||||
|
specifier: ^0.9.69
|
||||||
|
version: 0.9.69(svelte@5.53.5)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
|
|
@ -968,6 +971,11 @@ packages:
|
||||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||||
typescript: '>=5.0.0'
|
typescript: '>=5.0.0'
|
||||||
|
|
||||||
|
svelte-dnd-action@0.9.69:
|
||||||
|
resolution: {integrity: sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: '>=3.23.0 || ^5.0.0-next.0'
|
||||||
|
|
||||||
svelte-toolbelt@0.10.6:
|
svelte-toolbelt@0.10.6:
|
||||||
resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==}
|
resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==}
|
||||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||||
|
|
@ -1828,6 +1836,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- picomatch
|
- picomatch
|
||||||
|
|
||||||
|
svelte-dnd-action@0.9.69(svelte@5.53.5):
|
||||||
|
dependencies:
|
||||||
|
svelte: 5.53.5
|
||||||
|
|
||||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5):
|
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
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 type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
|
@ -13,12 +16,93 @@
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let { routine, products } = $derived(data);
|
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 showStepForm = $state(false);
|
||||||
let selectedProductId = $state('');
|
let selectedProductId = $state('');
|
||||||
|
|
||||||
const nextOrderIndex = $derived(
|
const GROOMING_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
|
||||||
routine.steps.length ? Math.max(...routine.steps.map((s) => s.order_index)) + 1 : 0
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
||||||
|
|
@ -43,7 +127,7 @@
|
||||||
<!-- Steps -->
|
<!-- Steps -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold">{m.routines_steps({ count: routine.steps.length })}</h3>
|
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
|
||||||
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
|
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
|
||||||
{showStepForm ? m.common_cancel() : m["routines_addStep"]()}
|
{showStepForm ? m.common_cancel() : m["routines_addStep"]()}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -89,17 +173,116 @@
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if routine.steps.length}
|
{#if steps.length}
|
||||||
<div class="space-y-2">
|
<div
|
||||||
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step (step.id)}
|
use:dragHandleZone={{ items: steps, flipDurationMs: 200, dragDisabled: !!editingStepId || dndSaving }}
|
||||||
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3">
|
onconsider={handleConsider}
|
||||||
<div class="flex items-center gap-3">
|
onfinalize={handleFinalize}
|
||||||
<span class="text-xs text-muted-foreground w-4">{step.order_index}</span>
|
class="space-y-2"
|
||||||
<div>
|
>
|
||||||
|
{#each steps as step (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 products as p (p.id)}
|
||||||
|
<SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem>
|
||||||
|
{/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">{step.order_index + 1}.</span>
|
||||||
|
<div class="flex-1 min-w-0 px-1">
|
||||||
{#if step.product_id}
|
{#if step.product_id}
|
||||||
{@const product = products.find((p) => p.id === step.product_id)}
|
{@const product = products.find((p) => p.id === step.product_id)}
|
||||||
<p class="text-sm font-medium">{product?.name ?? step.product_id}</p>
|
<p class="text-sm font-medium truncate">{product?.name ?? step.product_id}</p>
|
||||||
{#if product?.brand}<p class="text-xs text-muted-foreground">{product.brand}</p>{/if}
|
{#if product?.brand}<p class="text-xs text-muted-foreground truncate">{product.brand}</p>{/if}
|
||||||
{:else if step.action_type}
|
{:else if step.action_type}
|
||||||
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
|
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -107,16 +290,27 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if step.dose}
|
{#if step.dose}
|
||||||
<span class="text-xs text-muted-foreground">{step.dose}</span>
|
<span class="shrink-0 text-xs text-muted-foreground">{step.dose}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<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>
|
<form method="POST" action="?/removeStep" use:enhance>
|
||||||
<input type="hidden" name="step_id" value={step.id} />
|
<input type="hidden" name="step_id" value={step.id} />
|
||||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
<Button
|
||||||
×
|
type="submit"
|
||||||
</Button>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||||
|
>×</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -126,8 +320,14 @@
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<form method="POST" action="?/delete" use:enhance
|
<form
|
||||||
onsubmit={(e) => { if (!confirm(m["routines_confirmDelete"]())) e.preventDefault(); }}>
|
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>
|
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue