// upload.js — drag-drop file upload into the current scope. // // Active only in server mode and only at paths where the cascade // declares drop_target: true (see zddc/internal/zddc/lookups.go // DropTargetAt + defaults.zddc.yaml). The loader captures the // X-ZDDC-Drop-Target response header on every directory listing // fetch and stamps state.scopeDropTarget; this module just reads it. // // At scopes where drop_target is false (or unset), the handlers // stay armed but ignore drops silently — no visible drop-zone // overlay. An operator can flip working/staging/incoming on or // extend the cascade to mark additional dirs as drop targets via // .zddc; the client follows automatically without code change. // // 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 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; // state.scopeDropTarget is set by the loader on every listing // fetch from the X-ZDDC-Drop-Target response header; it's a // boolean read of the cascade's effective drop_target flag at // the current path. Defaults to false when the header is // absent (older server or non-server response). return !!state.scopeDropTarget; } 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 }; })();