/*
* Gallery bulk uploader.
*
* Per file, one request:
* 1. draw a small JPEG thumbnail on a canvas (browser-side), and when the
* gallery caps its resolution, a downscaled copy of the original too
* 2. POST the original + thumbnail to admin/api.php as multipart/form-data
* 3. the webhost streams both to S3 and registers the image
*
* One file per request keeps each PHP process small, so the size of the whole
* gallery upload never matters — only the largest single image. The browser
* never sees an S3 URL or credential for writing.
*
* Several files are in flight at once (data-concurrency, default 3). Each file
* is store-and-forward — the webhost receives the whole body before it starts
* the S3 PUT — so a single-file queue leaves the uplink idle for the entire
* webhost→S3 leg and for every thumbnail decode. Overlapping requests keeps it
* saturated; the server appends to the gallery JSON under a lock, so parallel
* completions cannot lose entries.
*/
(function () {
'use strict';
var zone = document.getElementById('dropzone');
if (!zone) return;
var input = document.getElementById('file-input');
var list = document.getElementById('upload-list');
var countEl = document.getElementById('img-count');
var api = zone.dataset.api;
var slug = zone.dataset.slug;
var csrf = zone.dataset.csrf;
// Guest upload links pass a per-gallery key; the admin edit page sets none.
var uploadKey = zone.dataset.key || '';
var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
// Per-gallery cap on the longest edge of the stored image; 0 = keep the
// original. Best-effort by nature: it only applies to files this browser
// can decode, so a RAW still goes up untouched.
var maxRes = parseInt(zone.dataset.maxResolution, 10) || 0;
var resizeQuality = parseFloat(zone.dataset.resizeQuality) || 0.9;
// How many uploads may be in flight at once. Small on purpose: each one
// occupies a PHP worker on the webhost for its whole S3 round trip.
var maxParallel = Math.max(1, parseInt(zone.dataset.concurrency, 10) || 3);
// Transient failures (network drop, 5xx, throttling) are retried
// automatically before the row is marked failed.
var maxAttempts = 3;
var queue = [];
var active = 0;
zone.addEventListener('click', function () { input.click(); });
input.addEventListener('change', function () { enqueue(input.files); input.value = ''; });
['dragenter', 'dragover'].forEach(function (ev) {
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('drag'); });
});
['dragleave', 'drop'].forEach(function (ev) {
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('drag'); });
});
zone.addEventListener('drop', function (e) { enqueue(e.dataTransfer.files); });
window.addEventListener('beforeunload', function (e) {
if (active || queue.length) { e.preventDefault(); e.returnValue = ''; }
});
function enqueue(files) {
Array.prototype.forEach.call(files, function (file) {
var row = document.createElement('div');
row.className = 'upload-item';
row.innerHTML = 'queued';
row.querySelector('.name').textContent = file.name;
list.appendChild(row);
queue.push({ file: file, row: row });
});
pump();
}
/* Start jobs until the parallel slots are full; called again as each ends. */
function pump() {
while (active < maxParallel && queue.length) {
run(queue.shift());
}
}
function run(job) {
active++;
uploadOne(job.file, job.row)
.then(function () { setState(job.row, 'done', 'done'); bumpCount(); })
.catch(function (err) {
setState(job.row, 'failed', 'error');
job.row.title = String(err);
var retry = document.createElement('a');
retry.href = '#';
retry.textContent = ' retry';
retry.addEventListener('click', function (e) {
e.preventDefault();
retry.remove();
setState(job.row, 'queued', '');
queue.push(job);
pump();
});
job.row.appendChild(retry);
})
.finally(function () { active--; pump(); });
}
function setState(row, text, cls) {
var el = row.querySelector('.state');
el.textContent = text;
el.className = 'state ' + (cls || '');
}
function bumpCount() {
if (countEl) countEl.textContent = String(parseInt(countEl.textContent, 10) + 1);
}
/* An error worth repeating: the request never landed, or the server said it
was a temporary condition. A 4xx is a real rejection (bad CSRF token,
wrong file type, gone gallery) and must not be retried. */
function failure(message, status) {
var err = new Error(message);
err.transient = status === 0 || status === 408 || status === 429 || status >= 500;
return err;
}
/* POST multipart to the webhost with upload progress (fetch has none → XHR). */
function sendForm(form, onProgress) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('POST', api);
xhr.setRequestHeader('X-CSRF-Token', csrf);
// Let the browser set Content-Type (with the multipart boundary).
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total);
});
xhr.addEventListener('load', function () {
var data = {};
try { data = JSON.parse(xhr.responseText); } catch (err) {}
if (xhr.status >= 200 && xhr.status < 300 && data.ok) resolve(data);
else reject(failure((data && data.error) || ('Upload failed (' + xhr.status + ')'), xhr.status));
});
xhr.addEventListener('error', function () { reject(failure('Network error during upload', 0)); });
xhr.send(form);
});
}
function delay(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
/* Decode a file to a bitmap, downsampled to hintEdge where the browser can
do it during the decode itself (a JPEG decoder scales by DCT factors)
instead of materialising a full-resolution bitmap — much faster and far
less memory on 40MP files. Only the width is given, so the aspect ratio
is preserved; the canvas step below still fixes the exact long edge, and
browsers that ignore resizeWidth simply return the full-size bitmap.
Pass hintEdge 0 to decode at natural size. resizeWidth scales *up* as
readily as down and the bitmap keeps no record of which happened, so a
hinted decode cannot tell "shrunk from 6000px" from "stretched from
900px". Harmless for a thumbnail, which ends up small either way; not
harmless for pixels we are about to store, which is why the resize path
decodes unhinted. Small files skip the hint for the same reason. */
function decodeImage(file, hintEdge) {
var options = { imageOrientation: 'from-image' };
if (hintEdge && file.size > 2 * 1024 * 1024) {
options.resizeWidth = hintEdge;
options.resizeQuality = 'high';
}
if (window.createImageBitmap) return createImageBitmap(file, options);
return new Promise(function (resolve, reject) {
var img = new Image();
img.onload = function () { resolve(img); };
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
/* One JPEG blob, longest edge at most maxEdge. Never upscales. */
function drawScaled(src, maxEdge, quality) {
var scale = Math.min(1, maxEdge / Math.max(src.width, src.height));
var canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(src.width * scale));
canvas.height = Math.max(1, Math.round(src.height * scale));
var ctx = canvas.getContext('2d');
// JPEG has no alpha, so a transparent PNG would encode onto black.
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
return new Promise(function (resolve) {
canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', quality);
});
}
/* Decode/resize runs one file at a time even though uploads overlap: it is
main-thread canvas work, and a capped gallery decodes at full size, so
three at once is three 40MP bitmaps — enough to jank or exhaust the
phones that use the guest link. Parallelism is worth having on the
network leg, not here. */
var cpuChain = Promise.resolve();
function serialize(work) {
var result = cpuChain.then(work);
// Keep the chain alive after a rejection, and unhandled-rejection-free.
cpuChain = result.catch(function () {});
return result;
}
/* Derive what we send: the grid thumbnail, plus a downscaled original when
the gallery caps its resolution and the image exceeds it — both off a
single decode. Returns nulls when the browser cannot decode the file
(e.g. RAW) — the original then uploads untouched, thumbnail-less,
exactly as before.
An uncapped gallery decodes exactly as it did before this option
existed: hinted down to the thumbnail, since nothing else is kept. A
capped one pays for a full-size decode, which is why this stage is
serialised — one large bitmap at a time, not one per upload slot. */
function prepare(file) {
return serialize(function () {
return decodeImage(file, maxRes > 0 ? 0 : thumbSize).then(function (src) {
var oversized = maxRes > 0 && Math.max(src.width, src.height) > maxRes;
return drawScaled(src, thumbSize, thumbQuality).then(function (thumb) {
if (!oversized) return { thumb: thumb, resized: null };
return drawScaled(src, maxRes, resizeQuality).then(function (resized) {
return { thumb: thumb, resized: resized };
});
}).finally(function () { if (src.close) src.close(); });
});
}).catch(function () { return { thumb: null, resized: null }; });
}
/* A re-encoded file must not be stored under its old extension. */
function jpegName(name) {
return name.replace(/\.[^.\/]*$/, '') + '.jpg';
}
function uploadOne(file, row) {
var bar = row.querySelector('.bar i');
setState(row, maxRes ? 'resizing' : 'thumbnail');
return prepare(file).then(function (out) {
// The same FormData is re-sent on retry. An untouched original is
// read from the File on disk each time; a resized blob is held in
// memory for the job (a megabyte or two), which also makes a retry
// cheaper — nothing is decoded or re-encoded twice.
var form = new FormData();
form.append('slug', slug);
if (uploadKey) form.append('key', uploadKey);
if (out.resized) form.append('original', out.resized, jpegName(file.name));
else form.append('original', file, file.name);
if (out.thumb) form.append('thumb', out.thumb, 'thumb.jpg');
function attempt(n) {
setState(row, n === 1 ? 'uploading' : 'retrying ' + n + '/' + maxAttempts);
bar.style.width = '0%';
return sendForm(form, function (f) {
bar.style.width = Math.round(f * 100) + '%';
}).catch(function (err) {
if (!err.transient || n >= maxAttempts) throw err;
// Back off so a briefly overloaded host is not hammered by
// every parallel slot at once.
return delay(1000 * n).then(function () { return attempt(n + 1); });
});
}
return attempt(1);
});
}
})();