feat: drag-to-reorder groups and units with PUT /admin/api/order
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user