spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/web/assets/js/upload.js8 * Purpose : Chunked resumable upload manager + Google-Drive-style panel9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { post, api } from './api.js';14import { h, fmtSize, toast } from './ui.js';15import { UI } from './icons.js';1617const CONCURRENT_FILES = 3;1819/**20 * Walk dropped DataTransfer items (files AND directory trees).21 * @returns {Promise<Array<{file: File, relPath: string}>>}22 */23export async function collectDropped(dataTransfer) {24 const out = [];25 const entries = [...dataTransfer.items]26 .filter((i) => i.kind === 'file')27 .map((i) => i.webkitGetAsEntry?.() ?? null);2829 if (entries.every((e) => e === null)) {30 for (const file of dataTransfer.files) out.push({ file, relPath: file.name });31 return out;32 }3334 const walk = async (entry, prefix) => {35 if (!entry) return;36 if (entry.isFile) {37 const file = await new Promise((res, rej) => entry.file(res, rej));38 out.push({ file, relPath: prefix + entry.name });39 } else if (entry.isDirectory) {40 const reader = entry.createReader();41 // readEntries returns batches of ≤100; loop until empty.42 for (;;) {43 const batch = await new Promise((res, rej) => reader.readEntries(res, rej));44 if (!batch.length) break;45 for (const child of batch) await walk(child, `${prefix}${entry.name}/`);46 }47 }48 };49 for (const entry of entries) await walk(entry, '');50 return out;51}5253export class UploadManager {54 /**55 * @param {{onFinished?: (parentId: number) => void,56 * resolveConflict?: (item, existing) => Promise<'keep-both'|'replace'|'skip'>}} hooks57 */58 constructor(hooks = {}) {59 this.hooks = hooks;60 this.items = [];61 this.active = 0;62 this.panel = document.getElementById('uploadPanel');63 }6465 /** Enqueue files targeted at a folder. items: {file, relPath} */66 add(files, parentId) {67 for (const { file, relPath } of files) {68 this.items.push({69 file,70 relPath: relPath || file.name,71 parentId,72 state: 'queued', // queued | uploading | done | error | cancelled73 sent: 0,74 uploadId: null,75 speed: 0,76 el: null,77 cancelled: false,78 });79 }80 this.renderPanel();81 this.pump();82 }8384 pump() {85 while (this.active < CONCURRENT_FILES) {86 const next = this.items.find((i) => i.state === 'queued');87 if (!next) break;88 this.active += 1;89 this.uploadOne(next).finally(() => {90 this.active -= 1;91 this.pump();92 if (!this.items.some((i) => i.state === 'queued' || i.state === 'uploading')) {93 const target = this.items.at(-1)?.parentId;94 const okCount = this.items.filter((i) => i.state === 'done').length;95 if (okCount) this.hooks.onFinished?.(target);96 }97 });98 }99 }100101 async uploadOne(item) {102 item.state = 'uploading';103 this.updateItem(item);104 try {105 const init = await post('/api/v1/upload/init', {106 parentId: item.parentId,107 name: item.relPath.split('/').pop(),108 path: item.relPath.includes('/') ? item.relPath : undefined,109 size: item.file.size,110 });111 item.uploadId = init.uploadId;112113 // Same-name file already there? Ask: keep both / replace (new version) / skip.114 let conflict = 'keep-both';115 if (init.conflictsWith && this.hooks.resolveConflict) {116 conflict = await this.hooks.resolveConflict(item, init.conflictsWith) ?? 'keep-both';117 if (conflict === 'skip') {118 await api(`/api/v1/upload/${item.uploadId}`, { method: 'DELETE' }).catch(() => {});119 item.state = 'skipped';120 this.updateItem(item);121 this.updateHead();122 return;123 }124 }125126 const { chunkSize, nChunks } = init;127 let have = new Set(init.have);128129 const startedAt = Date.now();130 for (let n = 0; n < nChunks; n += 1) {131 if (item.cancelled) throw new Error('cancelled');132 if (have.has(n)) { item.sent += chunkSize; continue; }133 const blob = item.file.slice(n * chunkSize, Math.min((n + 1) * chunkSize, item.file.size));134 let attempt = 0;135 for (;;) {136 try {137 const res = await fetch(`/api/v1/upload/${item.uploadId}/chunk/${n}`, {138 method: 'PUT',139 headers: { 'content-type': 'application/octet-stream', 'x-spbdrive-csrf': '1' },140 body: blob,141 credentials: 'same-origin',142 });143 if (!res.ok) throw new Error(`chunk ${n}: HTTP ${res.status}`);144 break;145 } catch (err) {146 attempt += 1;147 if (item.cancelled || attempt > 3) throw err;148 await new Promise((r) => setTimeout(r, 1000 * attempt));149 // Ask the server what it already has (resume support).150 const status = await api(`/api/v1/upload/${item.uploadId}`);151 have = new Set(status.have);152 if (have.has(n)) break;153 }154 }155 item.sent = Math.min((n + 1) * chunkSize, item.file.size);156 item.speed = item.sent / Math.max((Date.now() - startedAt) / 1000, 0.1);157 this.updateItem(item);158 }159160 await post(`/api/v1/upload/${item.uploadId}/complete`, {161 conflict,162 mime: item.file.type || undefined,163 });164 item.state = 'done';165 item.sent = item.file.size;166 } catch (err) {167 if (item.cancelled) {168 item.state = 'cancelled';169 if (item.uploadId) api(`/api/v1/upload/${item.uploadId}`, { method: 'DELETE' }).catch(() => {});170 } else {171 item.state = 'error';172 item.error = err.message;173 }174 }175 this.updateItem(item);176 this.updateHead();177 }178179 cancel(item) {180 item.cancelled = true;181 if (item.state === 'queued') { item.state = 'cancelled'; this.updateItem(item); }182 }183184 retry(item) {185 if (item.state !== 'error') return;186 item.state = 'queued';187 item.cancelled = false;188 item.sent = 0;189 this.updateItem(item);190 this.pump();191 }192193 // ── Panel UI ────────────────────────────────────────────────────────194 renderPanel() {195 if (!this.panel) return;196 if (!this.panel.querySelector('.upload-head')) {197 this.panel.innerHTML = '';198 this.head = h('div.upload-head', {},199 h('span', {}, 'Uploads'),200 this.agg = h('span.agg'),201 h('button.btn.icon.ghost', {202 title: 'Minimize',203 onclick: () => this.panel.classList.toggle('min'),204 }, h('span', { html: UI.chevron, style: { display: 'contents' } })),205 h('button.btn.icon.ghost', {206 title: 'Close',207 onclick: () => { this.panel.classList.remove('open'); this.items = this.items.filter((i) => i.state === 'uploading' || i.state === 'queued'); },208 }, h('span', { html: UI.close, style: { display: 'contents' } })),209 );210 this.list = h('div.upload-list');211 this.panel.append(this.head, this.list);212 }213 this.panel.classList.add('open');214 for (const item of this.items) {215 if (!item.el) {216 item.el = h('div.upload-item', {},217 h('div.fname', {},218 h('span.n', {}, item.relPath),219 h('span.s'),220 h('div.prog', {}, h('i')),221 ),222 h('div.act', {},223 h('button.btn.icon.ghost', {224 title: 'Retry', style: { display: 'none' },225 onclick: () => this.retry(item),226 }, h('span', { html: UI.restore, style: { display: 'contents' } })),227 h('button.btn.icon.ghost', {228 title: 'Cancel',229 onclick: () => this.cancel(item),230 }, h('span', { html: UI.close, style: { display: 'contents' } })),231 ),232 );233 this.list.prepend(item.el);234 }235 this.updateItem(item);236 }237 this.updateHead();238 }239240 updateItem(item) {241 if (!item.el) return;242 const pct = item.file.size ? Math.min(100, (item.sent / item.file.size) * 100) : 100;243 item.el.className = `upload-item${item.state === 'done' ? ' done' : ''}${item.state === 'error' ? ' err' : ''}`;244 item.el.querySelector('.prog i').style.width = `${item.state === 'done' ? 100 : pct}%`;245 const status = {246 queued: 'Waiting…',247 uploading: `${fmtSize(item.sent)} / ${fmtSize(item.file.size)} · ${fmtSize(item.speed)}/s`,248 done: `Done · ${fmtSize(item.file.size)}`,249 error: `Failed — ${item.error ?? 'error'}`,250 cancelled: 'Cancelled',251 skipped: 'Skipped (already exists)',252 }[item.state];253 item.el.querySelector('.s').textContent = status;254 item.el.querySelector('[title="Retry"]').style.display = item.state === 'error' ? '' : 'none';255 item.el.querySelector('[title="Cancel"]').style.display =256 item.state === 'uploading' || item.state === 'queued' ? '' : 'none';257 }258259 updateHead() {260 if (!this.agg) return;261 const total = this.items.length;262 const done = this.items.filter((i) => i.state === 'done').length;263 const failed = this.items.filter((i) => i.state === 'error').length;264 const activeBytes = this.items.reduce((s, i) => s + i.sent, 0);265 const allBytes = this.items.reduce((s, i) => s + i.file.size, 0);266 this.agg.textContent = failed267 ? `${done}/${total} · ${failed} failed`268 : done === total269 ? `${total} done`270 : `${done}/${total} · ${allBytes ? Math.round((activeBytes / allBytes) * 100) : 0}%`;271 }272}273274/** Wire paste-to-upload: screenshots land as pasted-YYYYMMDD-HHmmss.png. */275export function bindPasteUpload(manager, getCurrentFolder) {276 document.addEventListener('paste', (e) => {277 if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return;278 const files = [...(e.clipboardData?.items ?? [])]279 .filter((i) => i.kind === 'file')280 .map((i) => i.getAsFile())281 .filter(Boolean);282 if (!files.length) return;283 e.preventDefault();284 const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15).replace(/^(\d{8})/, '$1-');285 const items = files.map((file, i) => ({286 file,287 relPath: file.name && file.name !== 'image.png'288 ? file.name289 : `pasted-${stamp}${files.length > 1 ? `-${i + 1}` : ''}.png`,290 }));291 manager.add(items, getCurrentFolder());292 toast(`Uploading ${items.length} pasted file${items.length > 1 ? 's' : ''}…`);293 });294}295