admin.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. /*
  2. * Gallery bulk uploader.
  3. *
  4. * Per file:
  5. * 1. ask admin/api.php for presigned PUT URLs (original + thumbnail)
  6. * 2. PUT the original to S3 — byte-for-byte, full resolution, unmodified
  7. * 3. draw a small JPEG thumbnail on a canvas (browser-side) and PUT it too
  8. * 4. register the image in the gallery's flat file
  9. *
  10. * The webhost never receives the image data; only tiny JSON requests.
  11. */
  12. (function () {
  13. 'use strict';
  14. var zone = document.getElementById('dropzone');
  15. if (!zone) return;
  16. var input = document.getElementById('file-input');
  17. var list = document.getElementById('upload-list');
  18. var countEl = document.getElementById('img-count');
  19. var api = zone.dataset.api;
  20. var slug = zone.dataset.slug;
  21. var csrf = zone.dataset.csrf;
  22. var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
  23. var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
  24. var queue = [];
  25. var busy = false;
  26. zone.addEventListener('click', function () { input.click(); });
  27. input.addEventListener('change', function () { enqueue(input.files); input.value = ''; });
  28. ['dragenter', 'dragover'].forEach(function (ev) {
  29. zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('drag'); });
  30. });
  31. ['dragleave', 'drop'].forEach(function (ev) {
  32. zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('drag'); });
  33. });
  34. zone.addEventListener('drop', function (e) { enqueue(e.dataTransfer.files); });
  35. window.addEventListener('beforeunload', function (e) {
  36. if (busy || queue.length) { e.preventDefault(); e.returnValue = ''; }
  37. });
  38. function enqueue(files) {
  39. Array.prototype.forEach.call(files, function (file) {
  40. var row = document.createElement('div');
  41. row.className = 'upload-item';
  42. row.innerHTML = '<span class="name"></span><span class="bar"><i></i></span><span class="state">queued</span>';
  43. row.querySelector('.name').textContent = file.name;
  44. list.appendChild(row);
  45. queue.push({ file: file, row: row });
  46. });
  47. pump();
  48. }
  49. function pump() {
  50. if (busy || !queue.length) return;
  51. busy = true;
  52. var job = queue.shift();
  53. uploadOne(job.file, job.row)
  54. .then(function () { setState(job.row, 'done', 'done'); bumpCount(); })
  55. .catch(function (err) {
  56. setState(job.row, 'failed', 'error');
  57. job.row.title = String(err);
  58. var retry = document.createElement('a');
  59. retry.href = '#';
  60. retry.textContent = ' retry';
  61. retry.addEventListener('click', function (e) {
  62. e.preventDefault();
  63. retry.remove();
  64. setState(job.row, 'queued', '');
  65. queue.push(job);
  66. pump();
  67. });
  68. job.row.appendChild(retry);
  69. })
  70. .finally(function () { busy = false; pump(); });
  71. }
  72. function setState(row, text, cls) {
  73. var el = row.querySelector('.state');
  74. el.textContent = text;
  75. el.className = 'state ' + (cls || '');
  76. }
  77. function bumpCount() {
  78. if (countEl) countEl.textContent = String(parseInt(countEl.textContent, 10) + 1);
  79. }
  80. function apiCall(payload) {
  81. return fetch(api, {
  82. method: 'POST',
  83. headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
  84. body: JSON.stringify(payload)
  85. }).then(function (res) {
  86. if (!res.ok) throw new Error('API error ' + res.status);
  87. return res.json();
  88. });
  89. }
  90. /* PUT with upload progress (fetch has no upload progress → XHR). */
  91. function putToS3(url, data, contentType, onProgress) {
  92. return new Promise(function (resolve, reject) {
  93. var xhr = new XMLHttpRequest();
  94. xhr.open('PUT', url);
  95. if (contentType) xhr.setRequestHeader('Content-Type', contentType);
  96. xhr.upload.addEventListener('progress', function (e) {
  97. if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total);
  98. });
  99. xhr.addEventListener('load', function () {
  100. (xhr.status >= 200 && xhr.status < 300)
  101. ? resolve()
  102. : reject(new Error('S3 upload failed (' + xhr.status + ')'));
  103. });
  104. xhr.addEventListener('error', function () { reject(new Error('S3 upload network error')); });
  105. xhr.send(data);
  106. });
  107. }
  108. /* Thumbnail as JPEG blob; null when the browser cannot decode the file
  109. (e.g. RAW) — the original still uploads untouched. */
  110. function makeThumb(file) {
  111. var decode = window.createImageBitmap
  112. ? createImageBitmap(file, { imageOrientation: 'from-image' })
  113. : new Promise(function (resolve, reject) {
  114. var img = new Image();
  115. img.onload = function () { resolve(img); };
  116. img.onerror = reject;
  117. img.src = URL.createObjectURL(file);
  118. });
  119. return decode.then(function (src) {
  120. var w = src.width, h = src.height;
  121. var scale = Math.min(1, thumbSize / Math.max(w, h));
  122. var canvas = document.createElement('canvas');
  123. canvas.width = Math.max(1, Math.round(w * scale));
  124. canvas.height = Math.max(1, Math.round(h * scale));
  125. canvas.getContext('2d').drawImage(src, 0, 0, canvas.width, canvas.height);
  126. if (src.close) src.close();
  127. return new Promise(function (resolve) {
  128. canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', thumbQuality);
  129. });
  130. }).catch(function () { return null; });
  131. }
  132. function uploadOne(file, row) {
  133. var bar = row.querySelector('.bar i');
  134. setState(row, 'preparing');
  135. return apiCall({ action: 'presign', slug: slug, name: file.name })
  136. .then(function (p) {
  137. setState(row, 'uploading');
  138. return putToS3(p.put_original, file, file.type || 'application/octet-stream', function (f) {
  139. bar.style.width = Math.round(f * 100) + '%';
  140. }).then(function () {
  141. setState(row, 'thumbnail');
  142. return makeThumb(file);
  143. }).then(function (thumbBlob) {
  144. if (!thumbBlob) return { p: p, thumb: '' };
  145. return putToS3(p.put_thumb, thumbBlob, 'image/jpeg')
  146. .then(function () { return { p: p, thumb: p.thumb }; });
  147. }).then(function (r) {
  148. setState(row, 'saving');
  149. return apiCall({
  150. action: 'register',
  151. slug: slug,
  152. key: r.p.key,
  153. thumb: r.thumb,
  154. name: file.name,
  155. size: file.size
  156. });
  157. });
  158. });
  159. }
  160. })();