The last hardcoded client-side knowledge of the canonical convention
was the upload-zone regex in browse:
var UPLOAD_SCOPES = /\/(working|staging|incoming)(\/|$)/i;
Now declared in the cascade:
Schema:
drop_target: true|false leaf-only; describes THIS dir
(not propagated to descendants)
Lookup:
zddc.DropTargetAt(root, dir) bool
Surfaced to clients:
Directory listings carry an X-ZDDC-Drop-Target: true response
header when the cascade declares this leaf as an upload zone.
No header = no drop target.
Defaults populated:
working / working/* / staging / archive/<party>/incoming
all carry drop_target: true. Operators can extend (e.g. drop
files on archive/<party>/received via override) or disable
(e.g. drop_target: false at a specific staging subtree) without
touching code.
Browse migration:
loader.fetchServerChildren reads the response header and stamps
state.scopeDropTarget on every listing fetch. upload.js's
currentScopeAllows now reads that flag instead of regex-
matching the URL. Initial value is false in init.js so a
listing failure (offline / server doesn't emit the header)
safely defaults to "no drop zone".
Phase 4a closes the most visible asymmetry between server-side and
client-side cascade knowledge. The remaining client hardcodes
(browse grid-mode regex, archive source heuristics, shared/nav
stage strip) follow the same pattern when needed — Phase 4b/c/d.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
7.8 KiB
JavaScript
220 lines
7.8 KiB
JavaScript
// 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(`<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
|
|
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;
|
|
// 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
|
|
};
|
|
})();
|