|
@@ -0,0 +1,177 @@
|
|
|
|
|
+/*
|
|
|
|
|
+ * 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 = '<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();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+})();
|