feat(zddc): Phase 4a — drop_target cascade key, browse upload zone migrated
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>
This commit is contained in:
parent
6310afa922
commit
4b04f61e4b
9 changed files with 77 additions and 15 deletions
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 21:01:13 · 5e393cb-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 21:11:16 · 6310afa-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue