/* * 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 }); }); }); } })();