// HTML5 drag-and-drop helpers, no libraries. Three flows: // • Calendar — drag a day chip (or a stay area band) onto another in-range // day to move that entry (shifts end_date by the same delta so multi-day // spans / stay lengths stay intact). // • Day editor — drag a row's handle to reorder entries within one day. // • 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'; import { parseYMD, ymd, addDays } from '../format.js'; const DND_MIME = 'text/plain'; function dayDelta(fromYmd, toYmd) { return Math.round((parseYMD(toYmd) - parseYMD(fromYmd)) / 86400000); } // ---------- Calendar: move an entry to another day ---------- // Mark a calendar chip as a drag source carrying its entry id. export function makeChipDraggable(node, entry) { node.setAttribute('draggable', 'true'); node.classList.add('cal-chip-draggable'); node.addEventListener('dragstart', (e) => { e.dataTransfer.setData(DND_MIME, String(entry.id)); e.dataTransfer.effectAllowed = 'move'; node.classList.add('dragging'); }); node.addEventListener('dragend', () => node.classList.remove('dragging')); } // Mark a stay area band segment as a drag source carrying its entry id. Same // mechanics as makeChipDraggable — dropping on an in-range day moves the // whole stay to start there, preserving its length (end_date shifts by the // same delta in makeDayDropTarget below). export function makeBandDraggable(node, entry) { node.setAttribute('draggable', 'true'); node.classList.add('cal-band-draggable'); node.addEventListener('dragstart', (e) => { e.dataTransfer.setData(DND_MIME, String(entry.id)); e.dataTransfer.effectAllowed = 'move'; node.classList.add('dragging'); }); node.addEventListener('dragend', () => node.classList.remove('dragging')); } // Shared move-commit: PATCHes the dragged entry to `targetDate` with the given // `sortOrder`, shifting `end_date` by the same day-delta in the SAME patch (the // backend rejects date > stored end_date otherwise). No-ops when dropped back // on its own day. Used by both the day-cell and week-bands drop targets below. async function commitEntryMove(id, targetDate, sortOrder, entriesById, tctx) { const entry = entriesById.get(id); if (!entry || entry.date === targetDate) return; // dropped on its own day const patch = { date: targetDate, sort_order: sortOrder }; if (entry.end_date) { const delta = dayDelta(entry.date, targetDate); patch.end_date = ymd(addDays(parseYMD(entry.end_date), delta)); } try { await api.entries.update(id, patch); await tctx.refreshTrip(); } catch (err) { toast(err.message); } } // Wire an in-range day cell as a drop target. On drop the dragged entry moves // to `targetDate` with sort_order = `targetCount` (appended after that day's // entries). export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tctx) { cell.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; cell.classList.add('cal-day-drop'); }); cell.addEventListener('dragleave', (e) => { if (!cell.contains(e.relatedTarget)) cell.classList.remove('cal-day-drop'); }); cell.addEventListener('drop', async (e) => { e.preventDefault(); cell.classList.remove('cal-day-drop'); const id = Number(e.dataTransfer.getData(DND_MIME)); if (!id) return; await commitEntryMove(id, targetDate, targetCount, entriesById, tctx); }); } // Wire a week's stay-bands strip as a drop target, so dragging a chip/band // onto a spot already covered by another stay's band still moves the entry // (day cells sit below the bands strip and are otherwise unreachable there). // The target day is derived from the pointer's x-position across the 7-column // strip; the moved entry is appended (sort_order 0 — bands don't track a // day's chip order). export function makeWeekBandsDropTarget(strip, weekYmd, entriesById, tctx) { strip.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; strip.classList.add('cal-bands-drop'); }); strip.addEventListener('dragleave', (e) => { if (!strip.contains(e.relatedTarget)) strip.classList.remove('cal-bands-drop'); }); strip.addEventListener('drop', async (e) => { e.preventDefault(); strip.classList.remove('cal-bands-drop'); const id = Number(e.dataTransfer.getData(DND_MIME)); if (!id) return; const rect = strip.getBoundingClientRect(); const idx = Math.min(6, Math.max(0, Math.floor((e.clientX - rect.left) / (rect.width / 7)))); await commitEntryMove(id, weekYmd[idx], 0, entriesById, tctx); }); } // ---------- Shared reorder machinery ---------- // `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 `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, item } of rows) { handle.setAttribute('draggable', 'true'); handle.addEventListener('dragstart', (e) => { draggingId = getId(item); e.dataTransfer.setData(DND_MIME, String(draggingId)); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setDragImage(node, 0, 0); node.classList.add('dragging'); }); handle.addEventListener('dragend', () => { draggingId = null; clearAll(); }); node.addEventListener('dragover', (e) => { if (draggingId == null || getId(item) === draggingId) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = node.getBoundingClientRect(); const after = e.clientY - rect.top > rect.height / 2; node.classList.toggle('drop-after', after); node.classList.toggle('drop-before', !after); }); node.addEventListener('dragleave', (e) => { if (!node.contains(e.relatedTarget)) node.classList.remove('drop-before', 'drop-after'); }); node.addEventListener('drop', async (e) => { 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, getId(item), after, getId, getSort, commitPatch, refresh); }); } } 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 (getSort(ordered[i]) !== i) { await commitPatch(getId(ordered[i]), i); } } await refresh(); } catch (err) { 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, ); } // ---------- Checklist: reorder items within a category ---------- // `rows`: [{ node, handle, item }] — same mechanics as enableRowReorder, // scoped to one category's rows (checklist.js calls this once per category, // so the resulting sort_order values only need to rank correctly within that // category — category is always the primary sort key server-side). export function enableChecklistReorder(rows, refresh) { enableReorder( rows, (item) => item.id, (item) => item.sort_order, (id, sort_order) => api.checklist.update(id, { sort_order }), refresh, ); }