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:
@@ -183,10 +183,19 @@ textarea.input { resize: vertical; }
|
||||
.trip-card { display: flex; flex-direction: column; gap: 0.6rem; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; }
|
||||
.trip-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-strong); }
|
||||
.trip-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.6rem; }
|
||||
.trip-card-top-right { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||
.trip-card-name { font-size: 1.1rem; }
|
||||
.trip-card-dates { color: var(--text-muted); font-size: 0.9rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.trip-card-countdown { font-size: 0.72rem; font-weight: 700; color: var(--brand-dark); background: var(--brand-soft); padding: 0.1rem 0.45rem; border-radius: 999px; }
|
||||
.trip-card-meta { display: flex; gap: 1rem; font-size: 0.85rem; color: var(--text-muted); margin-top: auto; }
|
||||
|
||||
/* Drag-to-reorder trip cards: same grip + insertion-indicator convention as
|
||||
the day editor's .entry-drag-handle / .entry-row (see dragdrop.js). */
|
||||
.trip-drag-handle { flex-shrink: 0; cursor: grab; color: var(--text-faint); font-size: 0.95rem; line-height: 1; letter-spacing: -1px; user-select: none; }
|
||||
.trip-drag-handle:active { cursor: grabbing; }
|
||||
.trip-card.dragging { opacity: 0.4; }
|
||||
.trip-card.drop-before { box-shadow: inset 0 3px 0 var(--brand); }
|
||||
.trip-card.drop-after { box-shadow: inset 0 -3px 0 var(--brand); }
|
||||
.role-badge { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; }
|
||||
.role-owner { background: #fef3c7; color: #92400e; }
|
||||
.role-editor { background: var(--brand-soft); color: var(--brand-dark); }
|
||||
|
||||
@@ -64,6 +64,7 @@ export const api = {
|
||||
create: (payload) => post('/api/trips', payload),
|
||||
get: (id) => get(`/api/trips/${id}`),
|
||||
update: (id, patchBody) => patch(`/api/trips/${id}`, patchBody),
|
||||
reorder: (id, sort_order) => patch(`/api/trips/${id}/order`, { sort_order }),
|
||||
remove: (id) => del(`/api/trips/${id}`),
|
||||
join: (code) => post('/api/trips/join', { code }),
|
||||
regenerateJoinCode: (id) => post(`/api/trips/${id}/join-code`, {}),
|
||||
|
||||
Vendored
+50
-19
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { api } from '../api.js';
|
||||
import { el, mount, clear, loading, errorBox, emptyState, toast } from '../dom.js';
|
||||
import { formatRange, ymd, parseYMD, pluralize, normalizeCode } from '../format.js';
|
||||
import { enableTripReorder } from './dragdrop.js';
|
||||
|
||||
export function renderTrips(container, ctx) {
|
||||
mount(container, loading('Loading your trips…'));
|
||||
@@ -57,27 +58,46 @@ export function renderTrips(container, ctx) {
|
||||
);
|
||||
} else {
|
||||
const grid = el('div', { class: 'trip-grid' });
|
||||
for (const trip of trips) grid.appendChild(tripCard(trip));
|
||||
// Reordering only makes sense with 2+ trips (mirrors the day editor's
|
||||
// entry-row reorder — see dragdrop.js).
|
||||
const reorderable = trips.length > 1;
|
||||
const rows = [];
|
||||
for (const trip of trips) {
|
||||
const { node, handle } = tripCard(trip, reorderable);
|
||||
if (reorderable) rows.push({ node, handle, trip });
|
||||
grid.appendChild(node);
|
||||
}
|
||||
page.appendChild(grid);
|
||||
if (reorderable) enableTripReorder(rows, load);
|
||||
}
|
||||
|
||||
mount(container, page);
|
||||
}
|
||||
|
||||
function tripCard(trip) {
|
||||
function tripCard(trip, reorderable) {
|
||||
const today = ymd(new Date());
|
||||
const future = trip.start_date > today;
|
||||
const daysUntil = future
|
||||
? Math.round((parseYMD(trip.start_date) - parseYMD(today)) / 86400000)
|
||||
: 0;
|
||||
return el(
|
||||
const handle = reorderable
|
||||
? el('span', {
|
||||
class: 'trip-drag-handle',
|
||||
title: 'Drag to reorder',
|
||||
'aria-hidden': 'true',
|
||||
onClick: (e) => e.preventDefault(),
|
||||
}, '⋮⋮')
|
||||
: null;
|
||||
const node = el(
|
||||
'a',
|
||||
{ class: 'trip-card card', href: `#/trip/${trip.id}` },
|
||||
{ class: 'trip-card card', href: `#/trip/${trip.id}`, draggable: 'false' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'trip-card-top' },
|
||||
el('h3', { class: 'trip-card-name' }, trip.name),
|
||||
el('span', { class: `role-badge role-${trip.role}` }, trip.role),
|
||||
el('div', { class: 'trip-card-top-right' },
|
||||
handle,
|
||||
el('span', { class: `role-badge role-${trip.role}` }, trip.role)),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
@@ -93,6 +113,7 @@ export function renderTrips(container, ctx) {
|
||||
el('span', { class: 'trip-card-currency' }, trip.currency || 'USD'),
|
||||
),
|
||||
);
|
||||
return { node, handle };
|
||||
}
|
||||
|
||||
// Render `build()` into the slot, or close it if the same panel is open.
|
||||
|
||||
Reference in New Issue
Block a user