diff --git a/browse/js/init.js b/browse/js/init.js index e58b4b8..0d76df5 100644 --- a/browse/js/init.js +++ b/browse/js/init.js @@ -50,6 +50,11 @@ // Single shared popup window for file preview (across // multiple file clicks). Same pattern as archive's preview. - previewWindow: null + previewWindow: null, + + // Cascade-resolved scope flags, refreshed on each listing + // fetch from response headers. + // scopeDropTarget: cascade's drop_target at currentPath + scopeDropTarget: false }; })(); diff --git a/browse/js/loader.js b/browse/js/loader.js index 5925e09..61d24ef 100644 --- a/browse/js/loader.js +++ b/browse/js/loader.js @@ -85,6 +85,14 @@ headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }); + // Capture cascade-resolved scope flags from response headers + // before bailing on 404. zddc-server emits X-ZDDC-Drop-Target + // for directories the cascade marks as upload destinations + // (see zddc/internal/zddc/lookups.go DropTargetAt). The flag + // is leaf-only — it describes THIS path, not its descendants + // — so a rescope or popstate re-reads it from the new listing. + var dropTargetHdr = (resp.headers.get('X-ZDDC-Drop-Target') || '').toLowerCase(); + window.app.state.scopeDropTarget = dropTargetHdr === 'true'; if (resp.status === 404) { return []; } diff --git a/browse/js/upload.js b/browse/js/upload.js index 9444733..aab51a0 100644 --- a/browse/js/upload.js +++ b/browse/js/upload.js @@ -1,10 +1,16 @@ // 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. +// 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 @@ -30,11 +36,6 @@ 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; @@ -56,8 +57,12 @@ function currentScopeAllows() { if (!state || state.source !== 'server') return false; - var p = state.currentPath || ''; - return UPLOAD_SCOPES.test(p); + // 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() { diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index ecb00e5..7680282 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -128,6 +128,15 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit // auto-detect (which fetches the same URL with Accept: JSON). w.Header().Set("Vary", "Accept") + // Surface cascade-resolved scope flags via response headers so + // the browse SPA can render scope-aware UI (drop-zone overlay, + // future affordances) without re-implementing the cascade + // client-side. Keep the header surface tight — only routing- + // shape booleans go here; ACL details stay server-side. + if zddc.DropTargetAt(cfg.Root, absDir) { + w.Header().Set("X-ZDDC-Drop-Target", "true") + } + if strings.Contains(accept, "application/json") { // Content-hash ETag on the listing payload. Re-fetched on every // request (the cascade is walked, ACL filter applied, JSON diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 3ea1503..acb2549 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-11 21:01:13 · 5e393cb-dirty + v0.0.17-alpha · 2026-05-11 21:11:16 · 6310afa-dirty
diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 77419cd..a6f38c9 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -68,6 +68,8 @@ paths: # First write into incoming/ auto-creates an owner # grant so the creator can manage their own drops. auto_own: true + # Browse shows a drag-drop overlay here. + drop_target: true received: default_tool: archive # received/ is WORM — express as ACL elsewhere; the @@ -80,6 +82,7 @@ paths: # working/ auto-owns the first creator + the per-user homes # below. auto_own: true + drop_target: true paths: "*": # per-user home dir default_tool: mdedit @@ -90,10 +93,12 @@ paths: # grants don't reach inside. The user can edit the file # to grant collaborators access. auto_own_fenced: true + drop_target: true staging: default_tool: transmittal available_tools: [transmittal, classifier] auto_own: true + drop_target: true reviewing: default_tool: mdedit available_tools: [mdedit] diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index a4203db..ae27849 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -201,6 +201,14 @@ type ZddcFile struct { // cascade. Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"` + // DropTarget marks this directory as a destination for drag-drop + // uploads in the browse client. The directory listing's response + // header (X-ZDDC-Drop-Target) surfaces this to the SPA, which + // shows the drop-zone overlay only at scopes where the cascade + // permits uploads. Leaf-only — the property describes THIS dir, + // not its descendants. Defaults (nil): no drop zone. + DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` + // AvailableTools restricts which tools the server will auto-serve // at this directory and its descendants. The effective list is the // concat-dedupe union of all AvailableTools across the cascade diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index b5d60f4..5b491cd 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -68,6 +68,24 @@ func AutoOwnFencedAt(fsRoot, dirPath string) bool { return false } +// DropTargetAt reports whether THIS specific directory accepts +// drag-drop uploads from a client (e.g. browse's drop-zone overlay). +// Leaf-only — the property describes a specific path, not a subtree. +func DropTargetAt(fsRoot, dirPath string) bool { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return false + } + leaf := leafLevel(chain) + if leaf.DropTarget != nil { + return *leaf.DropTarget + } + if v := chain.Embedded.DropTarget; v != nil { + return *v + } + return false +} + // VirtualAt reports whether THIS specific directory is declared as // purely virtual (never materialise on disk). Leaf-only: the virtual // property describes a particular path, not a subtree. A child of a @@ -201,7 +219,8 @@ func isZeroZddcFile(zf ZddcFile) bool { if zf.DefaultTool != "" { return false } - if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil || zf.Inherit != nil { + if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil || + zf.DropTarget != nil || zf.Inherit != nil { return false } if len(zf.AvailableTools) > 0 { diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index d9f445d..120ea86 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -75,6 +75,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.AutoOwnFenced != nil { out.AutoOwnFenced = top.AutoOwnFenced } + if top.DropTarget != nil { + out.DropTarget = top.DropTarget + } if top.Virtual != nil { out.Virtual = top.Virtual }