From 4af0d8ca7c932c21259079e5c000fd9cdcac3c05 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 11 May 2026 13:56:15 -0500 Subject: [PATCH] feat(browse): drag-drop upload into working/staging/incoming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop files anywhere on a browse page; if the current scope is inside a working/, staging/, or incoming/ subtree the files are PUT to the current directory via the existing file API. Per-file ACL is enforced server-side (authorizeAction); a 403 surfaces as a per-file error toast and the rest of the batch proceeds. UX: - dragenter → semi-transparent overlay with a dashed-border panel showing the destination path. Hides immediately on dragleave or drop. - drop → "Uploading N files…" toast, then per-file failure toasts inline, then a summary toast (success / partial / all-failed). - listing auto-refreshes after the batch so new files appear in the tree without a manual reload. Scope: - upload-eligible paths are matched by /\/(working|staging|incoming) (\/|$)/i — same convention as the new grid-mode URL token. - 256 MiB per-file cap (UPLOAD_MAX_BYTES) since browse's single- body PUT loads the file as a Blob in the tab; larger uploads should use a dedicated client. - Outside the upload-eligible set the overlay never appears; drops are silently ignored (drag effect = none). Sequential uploads keep progress predictable; parallel batching can land later if needed. The module hooks document-level dragenter/leave /over/drop so it works regardless of which pane the user drags over. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/build.sh | 1 + browse/css/tree.css | 51 +++++++++++ browse/js/upload.js | 215 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 browse/js/upload.js diff --git a/browse/build.sh b/browse/build.sh index cae2b5e..28e8f50 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -54,6 +54,7 @@ concat_files \ "js/preview.js" \ "js/preview-markdown.js" \ "js/grid.js" \ + "js/upload.js" \ "js/events.js" \ "js/app.js" \ > "$js_raw" diff --git a/browse/css/tree.css b/browse/css/tree.css index b044db0..dfff4fc 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -313,6 +313,57 @@ html, body { font-weight: 500; } +/* ── Drag-drop upload overlay ─────────────────────────────────────────────── */ +/* Shown only while a drag is active over the page AND the current scope + accepts uploads. Pointer-events:none below dragover so the underlying + drop event still reaches the document handlers. */ +.upload-overlay { + position: fixed; + inset: 0; + z-index: 50; + pointer-events: none; + background: rgba(42, 90, 138, 0.18); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.12s ease; +} +.upload-overlay.is-active { + opacity: 1; +} +.upload-overlay__panel { + background: var(--bg); + border: 2px dashed var(--primary); + border-radius: var(--radius); + padding: 1.5rem 2.25rem; + text-align: center; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18); + pointer-events: none; + color: var(--text); + max-width: 80vw; +} +.upload-overlay__icon { + font-size: 2.5rem; + line-height: 1; + color: var(--primary); +} +.upload-overlay__title { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + margin-top: 0.5rem; +} +.upload-overlay__path { + margin-top: 0.35rem; + font-family: var(--font-mono); + font-size: 0.82rem; + color: var(--text-muted); + word-break: break-all; +} + /* Virtual rows: synthesized client-side for folders that aren't on disk yet (canonical project folders). Rendered muted so the user reads them as "available but empty" rather than ordinary entries. diff --git a/browse/js/upload.js b/browse/js/upload.js new file mode 100644 index 0000000..9444733 --- /dev/null +++ b/browse/js/upload.js @@ -0,0 +1,215 @@ +// 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 + }; +})();