Add drag & drop reordering of trips on the dashboard

- trip_members.sort_order (per-user, migrated) drives GET /api/trips order
- PATCH /api/trips/:id/order updates only the caller's membership row
- Trip cards get a drag handle reusing the generalized row-reorder helper;
  clicking the card still navigates
This commit is contained in:
2026-07-20 09:41:43 +07:00
parent d393e16ae1
commit b452430415
8 changed files with 163 additions and 27 deletions
+50 -19
View File
@@ -1,8 +1,10 @@
// HTML5 drag-and-drop helpers, no libraries. Two flows:
// HTML5 drag-and-drop helpers, no libraries. Three flows:
// • Calendar — drag a day chip onto another in-range day to move that entry
// (shifts end_date by the same delta so multi-day spans stay intact).
// • Day editor — drag a row's handle to reorder entries within one day.
// Touch devices don't fire HTML5 DnD events, so both flows are desktop-only and
// • Trip dashboard — drag a card's handle to reorder trips (shares the day
// editor's row-reorder machinery, see enableReorder below).
// Touch devices don't fire HTML5 DnD events, so all flows are desktop-only and
// degrade to the existing click/edit behaviour with no polyfill.
import { api } from '../api.js';
import { toast } from '../dom.js';
@@ -62,24 +64,26 @@ export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tc
});
}
// ---------- Day editor: reorder entries within a day ----------
// ---------- Shared reorder machinery ----------
// `rows`: [{ node, handle, entry }] in current display order. Dragging a row's
// `rows`: [{ node, handle, item }] in current display order. Dragging a row's
// handle over another row shows a top/bottom insertion indicator; on drop the
// new order is computed and sort_order PATCHed for ONLY the entries whose index
// changed, then `refresh()` re-renders (the editor stays open on the same day).
export function enableRowReorder(rows, refresh) {
// new order is computed and `commitPatch(id, newSortOrder)` is awaited for ONLY
// the items whose index changed, then `refresh()` re-renders. `getId`/`getSort`
// read the identifying fields off `item` (entries use id/sort_order, trips too,
// but kept generic so callers don't need matching shapes).
function enableReorder(rows, getId, getSort, commitPatch, refresh) {
let draggingId = null;
const clearAll = () => {
for (const r of rows) r.node.classList.remove('drop-before', 'drop-after', 'dragging');
};
for (const { node, handle, entry } of rows) {
for (const { node, handle, item } of rows) {
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (e) => {
draggingId = entry.id;
e.dataTransfer.setData(DND_MIME, String(entry.id));
draggingId = getId(item);
e.dataTransfer.setData(DND_MIME, String(draggingId));
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setDragImage(node, 0, 0);
node.classList.add('dragging');
@@ -87,7 +91,7 @@ export function enableRowReorder(rows, refresh) {
handle.addEventListener('dragend', () => { draggingId = null; clearAll(); });
node.addEventListener('dragover', (e) => {
if (draggingId == null || entry.id === draggingId) return;
if (draggingId == null || getId(item) === draggingId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = node.getBoundingClientRect();
@@ -99,26 +103,26 @@ export function enableRowReorder(rows, refresh) {
if (!node.contains(e.relatedTarget)) node.classList.remove('drop-before', 'drop-after');
});
node.addEventListener('drop', async (e) => {
if (draggingId == null || entry.id === draggingId) return;
if (draggingId == null || getId(item) === draggingId) return;
e.preventDefault();
const after = node.classList.contains('drop-after');
const movedId = draggingId;
clearAll();
draggingId = null;
await commitReorder(rows, movedId, entry.id, after, refresh);
await commitReorder(rows, movedId, getId(item), after, getId, getSort, commitPatch, refresh);
});
}
}
async function commitReorder(rows, movedId, targetId, after, refresh) {
const moved = rows.find((r) => r.entry.id === movedId).entry;
const ordered = rows.map((r) => r.entry).filter((e) => e.id !== movedId);
const targetIndex = ordered.findIndex((e) => e.id === targetId);
async function commitReorder(rows, movedId, targetId, after, getId, getSort, commitPatch, refresh) {
const moved = rows.find((r) => getId(r.item) === movedId).item;
const ordered = rows.map((r) => r.item).filter((it) => getId(it) !== movedId);
const targetIndex = ordered.findIndex((it) => getId(it) === targetId);
ordered.splice(after ? targetIndex + 1 : targetIndex, 0, moved);
try {
for (let i = 0; i < ordered.length; i++) {
if (ordered[i].sort_order !== i) {
await api.entries.update(ordered[i].id, { sort_order: i });
if (getSort(ordered[i]) !== i) {
await commitPatch(getId(ordered[i]), i);
}
}
await refresh();
@@ -126,3 +130,30 @@ async function commitReorder(rows, movedId, targetId, after, refresh) {
toast(err.message);
}
}
// ---------- Day editor: reorder entries within a day ----------
// `rows`: [{ node, handle, entry }] — see enableReorder above for the mechanics.
export function enableRowReorder(rows, refresh) {
enableReorder(
rows.map((r) => ({ node: r.node, handle: r.handle, item: r.entry })),
(entry) => entry.id,
(entry) => entry.sort_order,
(id, sort_order) => api.entries.update(id, { sort_order }),
refresh,
);
}
// ---------- Trip dashboard: reorder trip cards ----------
// `rows`: [{ node, handle, trip }] — same mechanics as enableRowReorder, but
// PATCHes the caller's per-user `trip_members.sort_order` via api.trips.reorder.
export function enableTripReorder(rows, refresh) {
enableReorder(
rows.map((r) => ({ node: r.node, handle: r.handle, item: r.trip })),
(trip) => trip.id,
(trip) => trip.sort_order,
(id, sort_order) => api.trips.reorder(id, sort_order),
refresh,
);
}