SPB Git

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%
10.7 KB · 295 lines javascript
Raw Blame History
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