feat: drag-to-reorder groups and units with PUT /admin/api/order

This commit is contained in:
Falkan
2026-03-17 19:58:17 -04:00
parent c1279c3757
commit 6d72242aaa
2 changed files with 234 additions and 3 deletions

View File

@@ -27,6 +27,116 @@
let groupFormSnapshot = $state<Partial<Group>>({});
let groupFieldError = $state<string | null>(null); // which field has error
// ── Drag-to-reorder state ─────────────────────────────────────────────────────
let dragGroupId = $state<string | null>(null); // group being dragged
let dragOverGroupId = $state<string | null>(null); // group being hovered over
let dragUnitId = $state<string | null>(null); // unit being dragged
let dragUnitGroupId = $state<string | null>(null); // group of unit being dragged
let dragOverUnitId = $state<string | null>(null); // unit being hovered over
// ── Drag handlers: groups ─────────────────────────────────────────────────────
function onGroupDragStart(e: DragEvent, groupId: string) {
dragGroupId = groupId;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', groupId);
}
function onGroupDragOver(e: DragEvent, groupId: string) {
if (!dragGroupId || dragGroupId === groupId) return;
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
dragOverGroupId = groupId;
}
function onGroupDragLeave(groupId: string) {
if (dragOverGroupId === groupId) dragOverGroupId = null;
}
function onGroupDrop(e: DragEvent, targetGroupId: string) {
e.preventDefault();
if (!dragGroupId || dragGroupId === targetGroupId) {
dragGroupId = null;
dragOverGroupId = null;
return;
}
// Reorder in local state immediately
const groups = [...unitsData.groups];
const fromIdx = groups.findIndex((g) => g.id === dragGroupId);
const toIdx = groups.findIndex((g) => g.id === targetGroupId);
if (fromIdx === -1 || toIdx === -1) { dragGroupId = null; dragOverGroupId = null; return; }
const [moved] = groups.splice(fromIdx, 1);
groups.splice(toIdx, 0, moved);
unitsData = { ...unitsData, groups };
dragGroupId = null;
dragOverGroupId = null;
// Persist
fetch('/admin/api/order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ groups: groups.map((g) => g.id) })
});
}
function onGroupDragEnd() {
dragGroupId = null;
dragOverGroupId = null;
}
// ── Drag handlers: units ──────────────────────────────────────────────────────
function onUnitDragStart(e: DragEvent, unitId: string, groupId: string | null) {
dragUnitId = unitId;
dragUnitGroupId = groupId;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', unitId);
}
function onUnitDragOver(e: DragEvent, unitId: string, groupId: string | null) {
if (!dragUnitId || dragUnitId === unitId || dragUnitGroupId !== groupId) return;
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
dragOverUnitId = unitId;
}
function onUnitDragLeave(unitId: string) {
if (dragOverUnitId === unitId) dragOverUnitId = null;
}
function onUnitDrop(e: DragEvent, targetUnitId: string, groupId: string | null) {
e.preventDefault();
if (!dragUnitId || dragUnitId === targetUnitId || dragUnitGroupId !== groupId) {
dragUnitId = null;
dragUnitGroupId = null;
dragOverUnitId = null;
return;
}
// Reorder in local state
const units = [...unitsData.units];
const fromIdx = units.findIndex((u) => u.id === dragUnitId);
const toIdx = units.findIndex((u) => u.id === targetUnitId);
if (fromIdx === -1 || toIdx === -1) { dragUnitId = null; dragUnitGroupId = null; dragOverUnitId = null; return; }
const [moved] = units.splice(fromIdx, 1);
units.splice(toIdx, 0, moved);
unitsData = { ...unitsData, units };
const gid = dragUnitId;
dragUnitId = null;
dragUnitGroupId = null;
dragOverUnitId = null;
// Build ordered IDs for this group
const groupKey = groupId ?? 'null';
const groupUnitIds = units.filter((u) => (u.group ?? null) === groupId).map((u) => u.id);
fetch('/admin/api/order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ units: { [groupKey]: groupUnitIds } })
});
}
function onUnitDragEnd() {
dragUnitId = null;
dragUnitGroupId = null;
dragOverUnitId = null;
}
// ── Load data ────────────────────────────────────────────────────────────────
async function loadData() {
const [res, gres] = await Promise.all([
@@ -470,8 +580,17 @@
<!-- Group tree -->
{#each unitsData.groups as group (group.id)}
{@const groupUnits = unitsByGroup.get(group.id) ?? []}
<div class="tree-group">
<div class="tree-group-header">
<div
class="tree-group"
class:drag-over-group={dragOverGroupId === group.id}
draggable="true"
ondragstart={(e) => onGroupDragStart(e, group.id)}
ondragover={(e) => onGroupDragOver(e, group.id)}
ondragleave={() => onGroupDragLeave(group.id)}
ondrop={(e) => onGroupDrop(e, group.id)}
ondragend={onGroupDragEnd}
>
<div class="tree-group-header" class:dragging={dragGroupId === group.id}>
<!-- Expand toggle -->
<button
class="plain expand-btn"
@@ -510,7 +629,20 @@
<div
class="tree-unit-row"
class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}
class:drag-over-unit={dragOverUnitId === unit.id}
draggable="false"
ondragover={(e) => onUnitDragOver(e, unit.id, unit.group ?? null)}
ondragleave={() => onUnitDragLeave(unit.id)}
ondrop={(e) => onUnitDrop(e, unit.id, unit.group ?? null)}
>
<!-- Drag handle -->
<span
class="drag-handle"
draggable="true"
ondragstart={(e) => onUnitDragStart(e, unit.id, unit.group ?? null)}
ondragend={onUnitDragEnd}
title="Drag to reorder"
></span>
<input
type="checkbox"
checked={checkedUnitIds.has(unit.id)}
@@ -550,7 +682,21 @@
</div>
<div class="tree-units">
{#each orphaned as unit (unit.id)}
<div class="tree-unit-row" class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}>
<div
class="tree-unit-row"
class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}
class:drag-over-unit={dragOverUnitId === unit.id}
ondragover={(e) => onUnitDragOver(e, unit.id, null)}
ondragleave={() => onUnitDragLeave(unit.id)}
ondrop={(e) => onUnitDrop(e, unit.id, null)}
>
<span
class="drag-handle"
draggable="true"
ondragstart={(e) => onUnitDragStart(e, unit.id, null)}
ondragend={onUnitDragEnd}
title="Drag to reorder"
></span>
<input
type="checkbox"
checked={checkedUnitIds.has(unit.id)}
@@ -988,4 +1134,31 @@
.modal-card { width: 100%; max-width: 480px; margin: 0 1rem; }
.reassign-options { margin: 0.5rem 0 0.5rem 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; }
.radio-stack { display: flex; flex-direction: column; gap: 0.25rem; }
/* ── Drag-to-reorder ───────────────────────────────────────────────────────── */
.tree-group-header {
cursor: grab;
}
.tree-group-header.dragging {
cursor: grabbing;
opacity: 0.5;
}
.drag-over-group > .tree-group-header {
border-top: 2px solid var(--pico-primary);
}
.drag-handle {
cursor: grab;
color: var(--pico-muted-color);
font-size: 0.9rem;
padding: 0 0.1rem;
flex-shrink: 0;
user-select: none;
line-height: 1;
}
.drag-handle:active {
cursor: grabbing;
}
.drag-over-unit {
border-top: 2px solid var(--pico-primary);
}
</style>