/* * Gallery archive builder — the admin's "Rebuild now" button. * * The archive normally rebuilds itself in the background, but the photographer * often wants it finished before sending the link out. Building a multi-gigabyte * ZIP cannot happen in one request on a 60 s execution limit, so this loops: * POST step, get back {done, total, finished}, repeat until finished. * * Every piece of state lives on the server, so this loop is disposable — closing * the tab pauses the build, "Resume" picks it up at the photo it stopped on, and * the background worker would eventually finish it regardless. */ (function () { 'use strict'; var card = document.getElementById('archive-card'); if (!card) return; var api = card.dataset.api; var slug = card.dataset.slug; var csrf = card.dataset.csrf; var buildBtn = document.getElementById('archive-build'); var cancelBtn = document.getElementById('archive-cancel'); var bar = document.getElementById('archive-bar'); var fill = bar.querySelector('span'); var status = document.getElementById('archive-status'); var running = false; var stopped = false; var startedAt = 0; var startedFrom = 0; function post(action) { var body = new FormData(); body.append('slug', slug); body.append('action', action); return fetch(api, { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: body }).then(function (response) { return response.json().catch(function () { return { error: 'Server returned a non-JSON response (HTTP ' + response.status + ')' }; }); }); } /* Rough finish time from the rate this run has actually achieved. */ function eta(done, total) { var elapsed = (Date.now() - startedAt) / 1000; var processed = done - startedFrom; if (processed < 1 || elapsed < 1) return ''; var remaining = Math.round((total - done) * (elapsed / processed)); if (remaining < 60) return ' · about a minute left'; return ' · about ' + Math.round(remaining / 60) + ' min left'; } function show(done, total, suffix) { bar.hidden = false; fill.style.width = (total ? Math.round((done / total) * 100) : 0) + '%'; status.textContent = done + ' of ' + total + ' photos' + (suffix || ''); } function fail(message, done, total) { running = false; bar.hidden = total > 0 ? false : true; status.innerHTML = ''; status.appendChild(document.createTextNode(message + ' ')); var resume = document.createElement('button'); resume.className = 'btn-ghost'; resume.style.margin = '0'; resume.textContent = 'Resume'; resume.addEventListener('click', function () { run('step'); }); status.appendChild(resume); buildBtn.disabled = false; cancelBtn.hidden = true; } function run(action) { running = true; stopped = false; buildBtn.disabled = true; cancelBtn.hidden = false; startedAt = Date.now(); startedFrom = -1; var retried = false; function step(which) { post(which).then(function (result) { if (stopped) return; if (result.error && !result.total) { // A transient S3 hiccup is worth one silent retry; anything // else needs the photographer to see it. if (!retried) { retried = true; step('step'); return; } fail(result.error, 0, 0); return; } retried = false; if (startedFrom < 0) startedFrom = result.done; if (result.finished) { running = false; show(result.total, result.total, ' · done'); // Reload so the status line, size and share-side button all // reflect the archive that now exists. window.location.reload(); return; } if (result.error) { show(result.done, result.total, ''); fail(result.error, result.done, result.total); return; } show(result.done, result.total, eta(result.done, result.total)); step('step'); }).catch(function (err) { if (!stopped) fail('Network error: ' + err.message, 0, 0); }); } step(action); } buildBtn.addEventListener('click', function () { if (running) return; // "start" discards any half-finished attempt and opens a fresh upload. run(buildBtn.dataset.resume ? 'step' : 'start'); }); cancelBtn.addEventListener('click', function () { stopped = true; running = false; post('cancel').then(function () { window.location.reload(); }); }); })();