Files
trip-plan/public/js/views/trips.js
T
grabowski b452430415 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
2026-07-20 09:41:43 +07:00

243 lines
8.9 KiB
JavaScript

// Trip list dashboard: cards, a "new trip" form, and a "join a trip" form.
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…'));
load();
async function load() {
try {
const data = await api.trips.list();
draw(data.trips || []);
} catch (err) {
mount(container, errorBox(err.message, load));
}
}
function draw(trips) {
const page = el('div', { class: 'page' });
// The form slot is created up front so every button below can capture a
// fully-initialized reference (no reliance on declaration ordering).
const formSlot = el('div', { class: 'form-slot' });
const newTripBtn = el('button', { class: 'btn btn-primary', onClick: () => openNewTrip(formSlot) }, '+ New trip');
const joinBtn = el('button', { class: 'btn', onClick: () => openJoin(formSlot) }, 'Join a trip');
page.appendChild(
el(
'div',
{ class: 'page-head' },
el(
'div',
{},
el('h1', {}, 'Your Trips'),
el('p', { class: 'muted' }, trips.length
? pluralize(trips.length, 'trip', 'trips')
: 'Start planning your next adventure.'),
),
el('div', { class: 'page-head-actions' }, joinBtn, newTripBtn),
),
);
page.appendChild(formSlot);
if (!trips.length) {
page.appendChild(
emptyState(
'No trips yet',
'Create your first trip, or join one with a code a friend shared.',
el('div', { class: 'empty-actions' },
el('button', { class: 'btn btn-primary', onClick: () => openNewTrip(formSlot) }, '+ New trip'),
el('button', { class: 'btn', onClick: () => openJoin(formSlot) }, 'Join a trip'),
),
),
);
} else {
const grid = el('div', { class: 'trip-grid' });
// 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, 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;
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}`, draggable: 'false' },
el(
'div',
{ class: 'trip-card-top' },
el('h3', { class: 'trip-card-name' }, trip.name),
el('div', { class: 'trip-card-top-right' },
handle,
el('span', { class: `role-badge role-${trip.role}` }, trip.role)),
),
el(
'div',
{ class: 'trip-card-dates' },
'📅 ', formatRange(trip.start_date, trip.end_date),
future ? el('span', { class: 'trip-card-countdown' }, `in ${daysUntil} ${daysUntil === 1 ? 'day' : 'days'}`) : null,
),
el(
'div',
{ class: 'trip-card-meta' },
el('span', {}, '👥 ', pluralize(trip.member_count ?? 1, 'member', 'members')),
el('span', {}, '📝 ', pluralize(trip.entry_count ?? 0, 'entry', 'entries')),
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.
function togglePanel(slot, kind, build) {
if (slot.dataset.open === kind) {
clear(slot);
slot.dataset.open = '';
return;
}
mount(slot, build());
slot.dataset.open = kind;
}
function openNewTrip(slot) {
togglePanel(slot, 'new', () => newTripForm(slot));
}
function openJoin(slot) {
togglePanel(slot, 'join', () => joinForm(slot));
}
function closeSlot(slot) {
clear(slot);
slot.dataset.open = '';
}
function newTripForm(slot) {
const today = ymd(new Date());
const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', placeholder: 'e.g. Northern Thailand' });
const startInput = el('input', { class: 'input', type: 'date', value: today });
const endInput = el('input', { class: 'input', type: 'date', value: today });
const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: 'USD', placeholder: 'USD', 'aria-label': 'Currency code' });
const errorEl = el('p', { class: 'form-error' });
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Create trip');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const name = nameInput.value.trim();
const start_date = startInput.value;
const end_date = endInput.value;
const currency = currencyInput.value.trim().toUpperCase() || 'USD';
if (!name) return (errorEl.textContent = 'Please give the trip a name.');
if (!start_date || !end_date) return (errorEl.textContent = 'Pick a start and end date.');
if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.');
if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.');
submitBtn.disabled = true;
submitBtn.textContent = 'Creating…';
try {
const data = await api.trips.create({ name, start_date, end_date, currency });
toast('Trip created', 'success');
ctx.navigate(`#/trip/${data.trip.id}`);
} catch (err) {
errorEl.textContent = err.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Create trip';
}
}
const form = el(
'form',
{ class: 'card new-trip-form', onSubmit },
el('h3', {}, 'New trip'),
el('div', { class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput)),
el('div', { class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput),
el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput)),
errorEl,
el('div', { class: 'form-actions' },
el('button', { class: 'btn btn-ghost', type: 'button', onClick: () => closeSlot(slot) }, 'Cancel'),
submitBtn),
);
setTimeout(() => nameInput.focus(), 0);
return form;
}
function joinForm(slot) {
const codeInput = el('input', {
class: 'input token-input',
type: 'text',
autocomplete: 'off',
autocapitalize: 'characters',
spellcheck: 'false',
placeholder: 'XXXX-XXXX',
'aria-label': 'Join code',
});
const errorEl = el('p', { class: 'form-error' });
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Join trip');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const code = normalizeCode(codeInput.value);
if (code.length < 4) return (errorEl.textContent = 'Enter the join code your friend shared.');
submitBtn.disabled = true;
submitBtn.textContent = 'Joining…';
try {
const data = await api.trips.join(code);
toast('Joined trip', 'success');
ctx.navigate(`#/trip/${data.trip.id}`);
} catch (err) {
errorEl.textContent = err.status === 404 ? 'No trip found for that code.' : err.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Join trip';
}
}
const form = el(
'form',
{ class: 'card join-form', onSubmit },
el('h3', {}, 'Join a trip'),
el('p', { class: 'muted' }, 'Paste the join code from a trip you were invited to.'),
el('div', { class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Join code'), codeInput)),
errorEl,
el('div', { class: 'form-actions' },
el('button', { class: 'btn btn-ghost', type: 'button', onClick: () => closeSlot(slot) }, 'Cancel'),
submitBtn),
);
setTimeout(() => codeInput.focus(), 0);
return form;
}
}