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 groupFormSnapshot = $state<Partial<Group>>({});
|
||||||
let groupFieldError = $state<string | null>(null); // which field has error
|
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 ────────────────────────────────────────────────────────────────
|
// ── Load data ────────────────────────────────────────────────────────────────
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [res, gres] = await Promise.all([
|
const [res, gres] = await Promise.all([
|
||||||
@@ -470,8 +580,17 @@
|
|||||||
<!-- Group tree -->
|
<!-- Group tree -->
|
||||||
{#each unitsData.groups as group (group.id)}
|
{#each unitsData.groups as group (group.id)}
|
||||||
{@const groupUnits = unitsByGroup.get(group.id) ?? []}
|
{@const groupUnits = unitsByGroup.get(group.id) ?? []}
|
||||||
<div class="tree-group">
|
<div
|
||||||
<div class="tree-group-header">
|
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 -->
|
<!-- Expand toggle -->
|
||||||
<button
|
<button
|
||||||
class="plain expand-btn"
|
class="plain expand-btn"
|
||||||
@@ -510,7 +629,20 @@
|
|||||||
<div
|
<div
|
||||||
class="tree-unit-row"
|
class="tree-unit-row"
|
||||||
class:selected={selectedItem?.id === unit.id && selectedItem?.type === 'unit'}
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checkedUnitIds.has(unit.id)}
|
checked={checkedUnitIds.has(unit.id)}
|
||||||
@@ -550,7 +682,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tree-units">
|
<div class="tree-units">
|
||||||
{#each orphaned as unit (unit.id)}
|
{#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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checkedUnitIds.has(unit.id)}
|
checked={checkedUnitIds.has(unit.id)}
|
||||||
@@ -988,4 +1134,31 @@
|
|||||||
.modal-card { width: 100%; max-width: 480px; margin: 0 1rem; }
|
.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; }
|
.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; }
|
.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>
|
</style>
|
||||||
|
|||||||
58
src/routes/admin/api/order/+server.ts
Normal file
58
src/routes/admin/api/order/+server.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { loadData, saveData } from '$lib/server/data';
|
||||||
|
import { authRequest } from '$lib/server/auth';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const PUT: RequestHandler = async ({ request }) => {
|
||||||
|
if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
if (!body) return json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
|
||||||
|
const { groups: groupOrder, units: unitOrder } = body as {
|
||||||
|
groups?: string[];
|
||||||
|
units?: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = loadData();
|
||||||
|
|
||||||
|
// Reorder groups if provided
|
||||||
|
if (Array.isArray(groupOrder)) {
|
||||||
|
const groupMap = new Map(data.groups.map((g) => [g.id, g]));
|
||||||
|
// Put known IDs first in the new order, then append any not in the list
|
||||||
|
const reordered = groupOrder
|
||||||
|
.map((id) => groupMap.get(id))
|
||||||
|
.filter((g): g is NonNullable<typeof g> => g !== undefined);
|
||||||
|
const mentioned = new Set(groupOrder);
|
||||||
|
const rest = data.groups.filter((g) => !mentioned.has(g.id));
|
||||||
|
data.groups = [...reordered, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder units within groups if provided
|
||||||
|
if (unitOrder && typeof unitOrder === 'object') {
|
||||||
|
for (const [groupId, orderedIds] of Object.entries(unitOrder)) {
|
||||||
|
const key = groupId === 'null' ? null : groupId;
|
||||||
|
if (!Array.isArray(orderedIds)) continue;
|
||||||
|
// Build a map of id → unit for units in this group
|
||||||
|
const groupUnits = data.units.filter((u) => (u.group ?? null) === key);
|
||||||
|
const unitMap = new Map(groupUnits.map((u) => [u.id, u]));
|
||||||
|
// Build new ordered list for this group
|
||||||
|
const reordered = orderedIds
|
||||||
|
.map((id) => unitMap.get(id))
|
||||||
|
.filter((u): u is NonNullable<typeof u> => u !== undefined);
|
||||||
|
const mentioned = new Set(orderedIds);
|
||||||
|
const rest = groupUnits.filter((u) => !mentioned.has(u.id));
|
||||||
|
const newGroupUnits = [...reordered, ...rest];
|
||||||
|
// Replace units in data.units for this group, preserving other groups' positions
|
||||||
|
let gi = 0;
|
||||||
|
for (let i = 0; i < data.units.length; i++) {
|
||||||
|
if ((data.units[i].group ?? null) === key) {
|
||||||
|
data.units[i] = newGroupUnits[gi++];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveData(data);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user