/*
* Gallery bulk uploader.
*
* Per file:
* 1. ask admin/api.php for presigned PUT URLs (original + thumbnail)
* 2. PUT the original to S3 — byte-for-byte, full resolution, unmodified
* 3. draw a small JPEG thumbnail on a canvas (browser-side) and PUT it too
* 4. register the image in the gallery's flat file
*
* The webhost never receives the image data; only tiny JSON requests.
*/
(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;
var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
var queue = [];
var busy = false;
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 (busy || 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();
}
function pump() {
if (busy || !queue.length) return;
busy = true;
var job = queue.shift();
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 () { busy = false; 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);
}
function apiCall(payload) {
return fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify(payload)
}).then(function (res) {
if (!res.ok) throw new Error('API error ' + res.status);
return res.json();
});
}
/* PUT with upload progress (fetch has no upload progress → XHR). */
function putToS3(url, data, contentType, onProgress) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('PUT', url);
if (contentType) xhr.setRequestHeader('Content-Type', contentType);
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total);
});
xhr.addEventListener('load', function () {
(xhr.status >= 200 && xhr.status < 300)
? resolve()
: reject(new Error('S3 upload failed (' + xhr.status + ')'));
});
xhr.addEventListener('error', function () { reject(new Error('S3 upload network error')); });
xhr.send(data);
});
}
/* Thumbnail as JPEG blob; null when the browser cannot decode the file
(e.g. RAW) — the original still uploads untouched. */
function makeThumb(file) {
var decode = window.createImageBitmap
? createImageBitmap(file, { imageOrientation: 'from-image' })
: 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, 'preparing');
return apiCall({ action: 'presign', slug: slug, name: file.name })
.then(function (p) {
setState(row, 'uploading');
return putToS3(p.put_original, file, file.type || 'application/octet-stream', function (f) {
bar.style.width = Math.round(f * 100) + '%';
}).then(function () {
setState(row, 'thumbnail');
return makeThumb(file);
}).then(function (thumbBlob) {
if (!thumbBlob) return { p: p, thumb: '' };
return putToS3(p.put_thumb, thumbBlob, 'image/jpeg')
.then(function () { return { p: p, thumb: p.thumb }; });
}).then(function (r) {
setState(row, 'saving');
return apiCall({
action: 'register',
slug: slug,
key: r.p.key,
thumb: r.thumb,
name: file.name,
size: file.size
});
});
});
}
})();