feat(browse): drag-drop upload into working/staging/incoming
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) <noreply@anthropic.com>
This commit is contained in:
parent
5debd552ae
commit
4af0d8ca7c
3 changed files with 267 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
215
browse/js/upload.js
Normal file
215
browse/js/upload.js
Normal file
|
|
@ -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(`<currentPath><filename>`, 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 =
|
||||
'<div class="upload-overlay__panel">'
|
||||
+ '<div class="upload-overlay__icon">⤴</div>'
|
||||
+ '<div class="upload-overlay__title">Drop to upload</div>'
|
||||
+ '<div class="upload-overlay__path" id="uploadOverlayPath"></div>'
|
||||
+ '</div>';
|
||||
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
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue