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
|
// Single shared popup window for file preview (across
|
||||||
// multiple file clicks). Same pattern as archive's preview.
|
// 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' },
|
headers: { 'Accept': 'application/json' },
|
||||||
credentials: 'same-origin'
|
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) {
|
if (resp.status === 404) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
// upload.js — drag-drop file upload into the current scope.
|
// upload.js — drag-drop file upload into the current scope.
|
||||||
//
|
//
|
||||||
// Active only in server mode and only at paths where uploads make
|
// Active only in server mode and only at paths where the cascade
|
||||||
// sense (any segment named working / staging / incoming, case-
|
// declares drop_target: true (see zddc/internal/zddc/lookups.go
|
||||||
// insensitive). At other scopes the handlers stay armed but ignore
|
// DropTargetAt + defaults.zddc.yaml). The loader captures the
|
||||||
// drops silently — there is no visible drop-zone overlay outside an
|
// X-ZDDC-Drop-Target response header on every directory listing
|
||||||
// upload-eligible context.
|
// 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:
|
// Wire model:
|
||||||
// - dragenter on the document raises a counter; first-enter shows
|
// - dragenter on the document raises a counter; first-enter shows
|
||||||
|
|
@ -30,11 +36,6 @@
|
||||||
if (!window.app || !window.app.modules) return;
|
if (!window.app || !window.app.modules) return;
|
||||||
|
|
||||||
var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file
|
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 state = window.app.state;
|
||||||
var enterCount = 0;
|
var enterCount = 0;
|
||||||
var overlayEl = null;
|
var overlayEl = null;
|
||||||
|
|
@ -56,8 +57,12 @@
|
||||||
|
|
||||||
function currentScopeAllows() {
|
function currentScopeAllows() {
|
||||||
if (!state || state.source !== 'server') return false;
|
if (!state || state.source !== 'server') return false;
|
||||||
var p = state.currentPath || '';
|
// state.scopeDropTarget is set by the loader on every listing
|
||||||
return UPLOAD_SCOPES.test(p);
|
// 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() {
|
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).
|
// auto-detect (which fetches the same URL with Accept: JSON).
|
||||||
w.Header().Set("Vary", "Accept")
|
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") {
|
if strings.Contains(accept, "application/json") {
|
||||||
// Content-hash ETag on the listing payload. Re-fetched on every
|
// Content-hash ETag on the listing payload. Re-fetched on every
|
||||||
// request (the cascade is walked, ACL filter applied, JSON
|
// request (the cascade is walked, ACL filter applied, JSON
|
||||||
|
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<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>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@ paths:
|
||||||
# First write into incoming/ auto-creates an owner
|
# First write into incoming/ auto-creates an owner
|
||||||
# grant so the creator can manage their own drops.
|
# grant so the creator can manage their own drops.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
|
# Browse shows a drag-drop overlay here.
|
||||||
|
drop_target: true
|
||||||
received:
|
received:
|
||||||
default_tool: archive
|
default_tool: archive
|
||||||
# received/ is WORM — express as ACL elsewhere; the
|
# received/ is WORM — express as ACL elsewhere; the
|
||||||
|
|
@ -80,6 +82,7 @@ paths:
|
||||||
# working/ auto-owns the first creator + the per-user homes
|
# working/ auto-owns the first creator + the per-user homes
|
||||||
# below.
|
# below.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
|
drop_target: true
|
||||||
paths:
|
paths:
|
||||||
"*": # per-user home dir
|
"*": # per-user home dir
|
||||||
default_tool: mdedit
|
default_tool: mdedit
|
||||||
|
|
@ -90,10 +93,12 @@ paths:
|
||||||
# grants don't reach inside. The user can edit the file
|
# grants don't reach inside. The user can edit the file
|
||||||
# to grant collaborators access.
|
# to grant collaborators access.
|
||||||
auto_own_fenced: true
|
auto_own_fenced: true
|
||||||
|
drop_target: true
|
||||||
staging:
|
staging:
|
||||||
default_tool: transmittal
|
default_tool: transmittal
|
||||||
available_tools: [transmittal, classifier]
|
available_tools: [transmittal, classifier]
|
||||||
auto_own: true
|
auto_own: true
|
||||||
|
drop_target: true
|
||||||
reviewing:
|
reviewing:
|
||||||
default_tool: mdedit
|
default_tool: mdedit
|
||||||
available_tools: [mdedit]
|
available_tools: [mdedit]
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,14 @@ type ZddcFile struct {
|
||||||
// cascade.
|
// cascade.
|
||||||
Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"`
|
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
|
// AvailableTools restricts which tools the server will auto-serve
|
||||||
// at this directory and its descendants. The effective list is the
|
// at this directory and its descendants. The effective list is the
|
||||||
// concat-dedupe union of all AvailableTools across the cascade
|
// concat-dedupe union of all AvailableTools across the cascade
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,24 @@ func AutoOwnFencedAt(fsRoot, dirPath string) bool {
|
||||||
return false
|
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
|
// VirtualAt reports whether THIS specific directory is declared as
|
||||||
// purely virtual (never materialise on disk). Leaf-only: the virtual
|
// purely virtual (never materialise on disk). Leaf-only: the virtual
|
||||||
// property describes a particular path, not a subtree. A child of a
|
// property describes a particular path, not a subtree. A child of a
|
||||||
|
|
@ -201,7 +219,8 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
||||||
if zf.DefaultTool != "" {
|
if zf.DefaultTool != "" {
|
||||||
return false
|
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
|
return false
|
||||||
}
|
}
|
||||||
if len(zf.AvailableTools) > 0 {
|
if len(zf.AvailableTools) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.AutoOwnFenced != nil {
|
if top.AutoOwnFenced != nil {
|
||||||
out.AutoOwnFenced = top.AutoOwnFenced
|
out.AutoOwnFenced = top.AutoOwnFenced
|
||||||
}
|
}
|
||||||
|
if top.DropTarget != nil {
|
||||||
|
out.DropTarget = top.DropTarget
|
||||||
|
}
|
||||||
if top.Virtual != nil {
|
if top.Virtual != nil {
|
||||||
out.Virtual = top.Virtual
|
out.Virtual = top.Virtual
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue