// upload.js — drag-drop file upload into the current scope. // // Active only in server mode and only at paths where uploads make // sense (any segment named working / staging / incoming, case- // insensitive). At other scopes the handlers stay armed but ignore // drops silently — there is no visible drop-zone overlay outside an // upload-eligible context. // // Wire model: // - dragenter on the document raises a counter; first-enter shows // the overlay. // - dragleave decrements; reaching zero hides the overlay. // - drop short-circuits: prevent default, PUT each file under the // current state.currentPath, surface per-file toast results, // refetch the listing on completion. // // The PUT uses fetch(``, method: 'PUT'). The // server's authorizeAction enforces write ACL on the parent; a 403 // surfaces as an error toast and the rest of the batch proceeds. // // Per-file size cap (UPLOAD_MAX_BYTES): files larger than the cap // are rejected client-side with a clear toast — the server would // accept them in chunks but browse's v1 PUT is a single body, and // dropping a 4 GB CAD bundle into the browser tab as a Blob is a // poor experience. Operators with larger uploads should use a // dedicated client (zddc-cli or the cache/mirror downstream). (function () { 'use strict'; if (!window.app || !window.app.modules) return; var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file // Path segments where uploads are allowed. Matches the current // hardcoded surface (working / staging / incoming). Will become // configurable when the folders: schema lands. var UPLOAD_SCOPES = /\/(working|staging|incoming)(\/|$)/i; var state = window.app.state; var enterCount = 0; var overlayEl = null; function ensureOverlay() { if (overlayEl) return overlayEl; overlayEl = document.createElement('div'); overlayEl.className = 'upload-overlay'; overlayEl.setAttribute('aria-hidden', 'true'); overlayEl.innerHTML = '
' + '
' + '
Drop to upload
' + '
' + '
'; document.body.appendChild(overlayEl); return overlayEl; } function currentScopeAllows() { if (!state || state.source !== 'server') return false; var p = state.currentPath || ''; return UPLOAD_SCOPES.test(p); } function showOverlay() { var el = ensureOverlay(); var pathEl = el.querySelector('#uploadOverlayPath'); if (pathEl) pathEl.textContent = state.currentPath || '/'; el.classList.add('is-active'); } function hideOverlay() { if (overlayEl) overlayEl.classList.remove('is-active'); } function dragHasFiles(e) { if (!e.dataTransfer || !e.dataTransfer.types) return false; var types = e.dataTransfer.types; for (var i = 0; i < types.length; i++) { if (types[i] === 'Files') return true; } return false; } function uploadUrl(filename) { var base = state.currentPath || '/'; if (!base.endsWith('/')) base += '/'; return base + encodeURIComponent(filename); } async function uploadOne(file) { if (file.size > UPLOAD_MAX_BYTES) { return { file: file, ok: false, status: 0, message: 'too large (max ' + Math.round(UPLOAD_MAX_BYTES / 1024 / 1024) + ' MiB)' }; } try { var resp = await fetch(uploadUrl(file.name), { method: 'PUT', body: file, credentials: 'same-origin', headers: { 'Content-Type': file.type || 'application/octet-stream' } }); return { file: file, ok: resp.ok, status: resp.status, message: resp.ok ? '' : ('HTTP ' + resp.status) }; } catch (e) { return { file: file, ok: false, status: 0, message: (e && e.message) ? e.message : 'network error' }; } } async function handleDrop(e) { e.preventDefault(); e.stopPropagation(); enterCount = 0; hideOverlay(); if (!currentScopeAllows()) return; var dt = e.dataTransfer; if (!dt || !dt.files || dt.files.length === 0) return; var files = Array.from(dt.files); var note = window.zddc && window.zddc.toast; if (note) note('Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…', 'info'); // Sequential — predictable progress + ordering. Can parallelise // later if it matters. var ok = 0, fail = 0; for (var i = 0; i < files.length; i++) { var res = await uploadOne(files[i]); if (res.ok) { ok++; } else { fail++; if (note) { note('Upload failed: ' + res.file.name + ' — ' + res.message, 'error'); } } } if (note) { if (fail === 0) { note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's'), 'success'); } else if (ok === 0) { note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error'); } else { note(ok + ' uploaded, ' + fail + ' failed', 'warning'); } } // Refresh the listing so newly-uploaded files appear. var loader = window.app.modules.loader; var tree = window.app.modules.tree; if (loader && tree && state.currentPath) { try { var es = await loader.fetchServerChildren(state.currentPath); tree.setRoot(es); tree.render(); } catch (_e) { /* swallow; user can hard-reload */ } } } function onEnter(e) { if (!dragHasFiles(e)) return; enterCount++; if (enterCount === 1 && currentScopeAllows()) { showOverlay(); } } function onLeave(e) { if (!dragHasFiles(e)) return; enterCount = Math.max(0, enterCount - 1); if (enterCount === 0) hideOverlay(); } function onOver(e) { if (!dragHasFiles(e)) return; // preventDefault on dragover is required for drop to fire. e.preventDefault(); if (e.dataTransfer && currentScopeAllows()) { e.dataTransfer.dropEffect = 'copy'; } else if (e.dataTransfer) { e.dataTransfer.dropEffect = 'none'; } } function init() { document.addEventListener('dragenter', onEnter, false); document.addEventListener('dragleave', onLeave, false); document.addEventListener('dragover', onOver, false); document.addEventListener('drop', handleDrop, false); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } window.app.modules.upload = { currentScopeAllows: currentScopeAllows, UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES }; })();