| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- /*
- * Gallery bulk uploader.
- *
- * Per file, one request:
- * 1. draw a small JPEG thumbnail on a canvas (browser-side)
- * 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;
- // 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 = '<span class="name"></span><span class="bar"><i></i></span><span class="state">queued</span>';
- 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); });
- }
- /* Thumbnail as JPEG blob; null when the browser cannot decode the file
- (e.g. RAW) — the original still uploads untouched.
- createImageBitmap gets a resize hint so it can downsample while decoding
- (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. The hint is skipped for small
- files, where it could upscale before we downscale again, and browsers
- that ignore resizeWidth simply return the full-size bitmap. */
- function makeThumb(file) {
- var options = { imageOrientation: 'from-image' };
- if (file.size > 2 * 1024 * 1024) {
- options.resizeWidth = thumbSize;
- options.resizeQuality = 'high';
- }
- var decode = window.createImageBitmap
- ? createImageBitmap(file, options)
- : new Promise(function (resolve, reject) {
- var img = new Image();
- img.onload = function () { resolve(img); };
- img.onerror = reject;
- img.src = URL.createObjectURL(file);
- });
- return decode.then(function (src) {
- var w = src.width, h = src.height;
- var scale = Math.min(1, thumbSize / Math.max(w, h));
- var canvas = document.createElement('canvas');
- canvas.width = Math.max(1, Math.round(w * scale));
- canvas.height = Math.max(1, Math.round(h * scale));
- canvas.getContext('2d').drawImage(src, 0, 0, canvas.width, canvas.height);
- if (src.close) src.close();
- return new Promise(function (resolve) {
- canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', thumbQuality);
- });
- }).catch(function () { return null; });
- }
- function uploadOne(file, row) {
- var bar = row.querySelector('.bar i');
- setState(row, 'thumbnail');
- return makeThumb(file).then(function (thumbBlob) {
- // The same FormData is re-sent on retry: it reads from the File on
- // disk each time, so nothing is buffered between attempts.
- var form = new FormData();
- form.append('slug', slug);
- if (uploadKey) form.append('key', uploadKey);
- form.append('original', file, file.name);
- if (thumbBlob) form.append('thumb', thumbBlob, '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);
- });
- }
- })();
|