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>
|
||||
|
||||
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