Round of UX fixes: tool strip removed, MDL routing, browse markdown layout, reviewing depth-2
Four user-reported items:
1. landing: remove the standalone-tool strip from the site picker.
Per user, it was awkward — links pointing at zddc.varasys.io
releases from inside a deployment is a layering confusion. The
nav.tool-strip block in landing/template.html and its CSS are
gone.
2. zddc-server: route /Project/archive/<party>/mdl[/] to the tables
app for the virtual-MDL case where the on-disk folder doesn't
exist yet. Previously fell through to 404 because the dispatcher
only routed virtual mdl/ via the IsDir branch — the IsNotExist
branch was missing the equivalent check. Now both shapes (with
and without trailing slash) hit RecognizeTableRequest's default-
MDL fallback and ServeTable serves the embedded tables.html.
3. browse: re-layout the markdown editor to mirror mdedit's layout.
Was: sidebar on right with TOC top + front-matter bottom.
Now: sidebar on LEFT with YAML front matter top + Outline bottom,
content on RIGHT with an informational header (file title +
save controls + status + source) above the Toast UI editor.
New horizontal resizer between the front-matter and outline
sections inside the sidebar (drag the row boundary; arrow keys
step by 24 px). Browse test selectors updated.
4. zddc-server reviewing aggregator: extend to depth ≥ 2 so the
user can preview files inside virtual reviewing/<tracking>/
received/ and staged/ folders. IsReviewingPath now returns a
sidePath ("received[/rest]" or "staged[/rest]"); ServeReviewing's
depth-2 branch proxies the underlying real folder's listing,
emitting folder entries with virtual reviewing/ URLs (so
navigation stays in the aggregator) and file entries with
canonical archive/ or staging/ URLs (so byte fetches resolve
directly). ACL is enforced against the real path; depth-1
received/ + staged/ URLs are now virtual too (was canonical),
so the user smoothly descends into the depth-2 listing.
Tests updated for the new IsReviewingPath signature and the depth-1
URL shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1479c5104
commit
d052e9fed3
9 changed files with 474 additions and 295 deletions
|
|
@ -360,80 +360,43 @@ html, body {
|
||||||
.status-bar.is-info { color: var(--text); }
|
.status-bar.is-info { color: var(--text); }
|
||||||
|
|
||||||
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
|
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
|
||||||
/* CSS-Grid shell. Two columns (editor | sidebar) and two rows (toolbar
|
/* CSS-Grid shell mirroring mdedit's layout: sidebar on the LEFT
|
||||||
| body). The grid gives every cell a definite size, which Toast UI
|
(front matter top + TOC bottom), content on the RIGHT (informational
|
||||||
needs to compute its scroll regions correctly. A 4-px resizer sits
|
header above the Toast UI editor). The grid gives every cell a
|
||||||
between the editor and sidebar; JS updates grid-template-columns on
|
definite size, which Toast UI needs to compute its scroll regions
|
||||||
drag. */
|
correctly. */
|
||||||
.md-shell {
|
.md-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: 1fr;
|
||||||
grid-template-columns: 1fr 260px; /* JS overrides on resize */
|
grid-template-columns: 280px 1fr; /* JS overrides on resize */
|
||||||
grid-template-areas:
|
grid-template-areas: "sidebar content";
|
||||||
"toolbar toolbar"
|
|
||||||
"editor sidebar";
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar spans both columns; subtle row above the editor. */
|
/* Sidebar (col 1): two stacked sections — Front matter (top, fixed
|
||||||
.md-shell__toolbar {
|
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
|
||||||
grid-area: toolbar;
|
.md-shell__sidebar {
|
||||||
display: flex;
|
grid-area: sidebar;
|
||||||
align-items: center;
|
display: grid;
|
||||||
gap: 0.5rem;
|
grid-template-rows: 180px 1fr; /* JS overrides on resize */
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.md-shell__dirty {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
min-width: 5.5rem;
|
|
||||||
}
|
|
||||||
.md-shell__status {
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.md-shell__source {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-style: italic;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
padding: 0.15rem 0.4rem;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editor host: a single grid cell with overflow:hidden so Toast UI's
|
|
||||||
internal scrollers handle the content. */
|
|
||||||
.md-shell__editor {
|
|
||||||
grid-area: editor;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Toast UI mounts a .toastui-editor-defaultUI element here; give
|
border-right: 1px solid var(--border);
|
||||||
it a definite height via height:100% in the JS. */
|
background: var(--bg);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resizer sits on the grid border between editor (col 1) and sidebar
|
/* Vertical sidebar/content resizer. Sits absolutely on the column
|
||||||
(col 2). Positioned absolutely over the boundary so it doesn't take
|
boundary so it doesn't occupy a grid track. */
|
||||||
up a grid track itself. */
|
|
||||||
.md-shell__resizer {
|
.md-shell__resizer {
|
||||||
grid-area: editor;
|
grid-area: sidebar;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
margin-right: -3px; /* center on the column boundary */
|
margin-right: -3px;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
@ -446,17 +409,91 @@ html, body {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar (right column): grid of two stacked sections — Outline
|
/* Horizontal resizer between front-matter and TOC inside the sidebar.
|
||||||
(1fr) takes the bulk of the height, Front matter (auto, capped) is
|
Spans both rows by placement, then absolutely positioned to overlay
|
||||||
below. */
|
the grid-row boundary. */
|
||||||
.md-shell__sidebar {
|
.md-shell__fmresizer {
|
||||||
grid-area: sidebar;
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
align-self: end;
|
||||||
|
justify-self: stretch;
|
||||||
|
height: 6px;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
cursor: row-resize;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 2;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.md-shell__fmresizer:hover,
|
||||||
|
.md-shell__fmresizer.is-dragging,
|
||||||
|
.md-shell__fmresizer:focus-visible {
|
||||||
|
background: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content (col 2): informational header above the Toast UI editor. */
|
||||||
|
.md-shell__content {
|
||||||
|
grid-area: content;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: auto 1fr;
|
||||||
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-left: 1px solid var(--border);
|
}
|
||||||
|
|
||||||
|
/* Informational header above the editor: file name on the left, then
|
||||||
|
dirty marker, status, source hint, save button. Reads as a header
|
||||||
|
for the content panel — file metadata at a glance. */
|
||||||
|
.md-shell__infohdr {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.md-shell__title {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.md-shell__dirty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.md-shell__status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
.md-shell__source {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor host: a single grid cell with overflow:hidden so Toast UI's
|
||||||
|
internal scrollers handle the content. */
|
||||||
|
.md-shell__editor {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-side {
|
.md-side {
|
||||||
|
|
@ -465,10 +502,8 @@ html, body {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.md-side--fm {
|
.md-side--toc {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
/* Front matter doesn't dominate — cap it so the outline keeps room. */
|
|
||||||
max-height: 40%;
|
|
||||||
}
|
}
|
||||||
.md-side__header {
|
.md-side__header {
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,10 @@
|
||||||
|
|
||||||
if (!window.app || !window.app.modules) return;
|
if (!window.app || !window.app.modules) return;
|
||||||
|
|
||||||
var TOC_MIN_WIDTH = 180;
|
var SIDEBAR_MIN_WIDTH = 180;
|
||||||
var TOC_MAX_WIDTH = 480;
|
var SIDEBAR_MAX_WIDTH = 480;
|
||||||
var TOC_DEFAULT_WIDTH = 260;
|
var SIDEBAR_DEFAULT_WIDTH = 280;
|
||||||
|
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
|
@ -38,7 +39,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
|
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
|
||||||
var lastTocWidth = TOC_DEFAULT_WIDTH; // remember across mounts
|
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
|
||||||
|
var lastFmHeight = FM_DEFAULT_HEIGHT;
|
||||||
|
|
||||||
async function hashContent(text) {
|
async function hashContent(text) {
|
||||||
if (!window.crypto || !window.crypto.subtle) return null;
|
if (!window.crypto || !window.crypto.subtle) return null;
|
||||||
|
|
@ -302,20 +304,78 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wipe the container and install a single shell child. The
|
// Wipe the container and install a single shell child. The
|
||||||
// shell is a CSS Grid with two rows (toolbar | body) and two
|
// shell mirrors mdedit's layout: sidebar on the LEFT (front
|
||||||
// columns (editor | sidebar). Setting these on a dedicated
|
// matter top, TOC bottom), content on the RIGHT (informational
|
||||||
// child — rather than touching previewBody's class — keeps
|
// header above the Toast UI editor). CSS Grid keeps every
|
||||||
// the outer flex layout intact (previewBody itself is the
|
// cell sized definitely so Toast UI's scroll regions resolve
|
||||||
// flex item that fills the preview pane).
|
// correctly.
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
var shell = document.createElement('div');
|
var shell = document.createElement('div');
|
||||||
shell.className = 'md-shell';
|
shell.className = 'md-shell';
|
||||||
shell.style.gridTemplateColumns = '1fr ' + lastTocWidth + 'px';
|
shell.style.gridTemplateColumns = lastSidebarWidth + 'px 1fr';
|
||||||
container.appendChild(shell);
|
container.appendChild(shell);
|
||||||
|
|
||||||
// Toolbar (row 1, spans both columns).
|
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
|
||||||
var toolbar = document.createElement('div');
|
var sidebar = document.createElement('div');
|
||||||
toolbar.className = 'md-shell__toolbar';
|
sidebar.className = 'md-shell__sidebar';
|
||||||
|
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
|
||||||
|
shell.appendChild(sidebar);
|
||||||
|
|
||||||
|
var fmSection = document.createElement('section');
|
||||||
|
fmSection.className = 'md-side md-side--fm';
|
||||||
|
var fmHeader = document.createElement('div');
|
||||||
|
fmHeader.className = 'md-side__header';
|
||||||
|
fmHeader.textContent = 'YAML front matter';
|
||||||
|
var fmBody = document.createElement('div');
|
||||||
|
fmBody.className = 'md-side__body md-fm__body';
|
||||||
|
fmSection.appendChild(fmHeader);
|
||||||
|
fmSection.appendChild(fmBody);
|
||||||
|
sidebar.appendChild(fmSection);
|
||||||
|
|
||||||
|
// Horizontal resizer between front-matter and TOC.
|
||||||
|
var fmResizer = document.createElement('div');
|
||||||
|
fmResizer.className = 'md-shell__fmresizer';
|
||||||
|
fmResizer.setAttribute('role', 'separator');
|
||||||
|
fmResizer.setAttribute('aria-orientation', 'horizontal');
|
||||||
|
fmResizer.setAttribute('aria-label', 'Resize front-matter pane');
|
||||||
|
fmResizer.tabIndex = 0;
|
||||||
|
sidebar.appendChild(fmResizer);
|
||||||
|
|
||||||
|
var tocSection = document.createElement('section');
|
||||||
|
tocSection.className = 'md-side md-side--toc';
|
||||||
|
var tocHeader = document.createElement('div');
|
||||||
|
tocHeader.className = 'md-side__header';
|
||||||
|
tocHeader.textContent = 'Outline';
|
||||||
|
var tocBody = document.createElement('div');
|
||||||
|
tocBody.className = 'md-side__body md-toc__body';
|
||||||
|
tocSection.appendChild(tocHeader);
|
||||||
|
tocSection.appendChild(tocBody);
|
||||||
|
sidebar.appendChild(tocSection);
|
||||||
|
|
||||||
|
// Vertical resizer between sidebar and content.
|
||||||
|
var resizer = document.createElement('div');
|
||||||
|
resizer.className = 'md-shell__resizer';
|
||||||
|
resizer.setAttribute('role', 'separator');
|
||||||
|
resizer.setAttribute('aria-orientation', 'vertical');
|
||||||
|
resizer.setAttribute('aria-label', 'Resize sidebar');
|
||||||
|
resizer.tabIndex = 0;
|
||||||
|
shell.appendChild(resizer);
|
||||||
|
|
||||||
|
// ── Content (col 2): informational header + editor ──────────────────
|
||||||
|
var content = document.createElement('div');
|
||||||
|
content.className = 'md-shell__content';
|
||||||
|
shell.appendChild(content);
|
||||||
|
|
||||||
|
// Informational header above the editor: file name + save +
|
||||||
|
// dirty indicator + status + source hint. Renamed from
|
||||||
|
// "toolbar" to read as a header, since it titles the content.
|
||||||
|
var infohdr = document.createElement('div');
|
||||||
|
infohdr.className = 'md-shell__infohdr';
|
||||||
|
|
||||||
|
var titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'md-shell__title';
|
||||||
|
titleEl.textContent = node.name;
|
||||||
|
titleEl.title = node.name;
|
||||||
|
|
||||||
var saveBtn = document.createElement('button');
|
var saveBtn = document.createElement('button');
|
||||||
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
|
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
|
||||||
|
|
@ -339,52 +399,17 @@
|
||||||
sourceEl.textContent = 'server';
|
sourceEl.textContent = 'server';
|
||||||
}
|
}
|
||||||
|
|
||||||
toolbar.appendChild(saveBtn);
|
infohdr.appendChild(titleEl);
|
||||||
toolbar.appendChild(dirtyEl);
|
infohdr.appendChild(dirtyEl);
|
||||||
toolbar.appendChild(statusEl);
|
infohdr.appendChild(statusEl);
|
||||||
toolbar.appendChild(sourceEl);
|
infohdr.appendChild(sourceEl);
|
||||||
shell.appendChild(toolbar);
|
infohdr.appendChild(saveBtn);
|
||||||
|
content.appendChild(infohdr);
|
||||||
|
|
||||||
// Editor host (row 2, col 1).
|
// Editor host.
|
||||||
var editorHost = document.createElement('div');
|
var editorHost = document.createElement('div');
|
||||||
editorHost.className = 'md-shell__editor';
|
editorHost.className = 'md-shell__editor';
|
||||||
shell.appendChild(editorHost);
|
content.appendChild(editorHost);
|
||||||
|
|
||||||
// Resizer between editor and sidebar (row 2, between cols).
|
|
||||||
var resizer = document.createElement('div');
|
|
||||||
resizer.className = 'md-shell__resizer';
|
|
||||||
resizer.setAttribute('role', 'separator');
|
|
||||||
resizer.setAttribute('aria-orientation', 'vertical');
|
|
||||||
resizer.setAttribute('aria-label', 'Resize outline pane');
|
|
||||||
resizer.tabIndex = 0;
|
|
||||||
shell.appendChild(resizer);
|
|
||||||
|
|
||||||
// Sidebar (row 2, col 2). Its own grid: outline (1fr) + front-matter (auto).
|
|
||||||
var sidebar = document.createElement('div');
|
|
||||||
sidebar.className = 'md-shell__sidebar';
|
|
||||||
shell.appendChild(sidebar);
|
|
||||||
|
|
||||||
var tocSection = document.createElement('section');
|
|
||||||
tocSection.className = 'md-side md-side--toc';
|
|
||||||
var tocHeader = document.createElement('div');
|
|
||||||
tocHeader.className = 'md-side__header';
|
|
||||||
tocHeader.textContent = 'Outline';
|
|
||||||
var tocBody = document.createElement('div');
|
|
||||||
tocBody.className = 'md-side__body md-toc__body';
|
|
||||||
tocSection.appendChild(tocHeader);
|
|
||||||
tocSection.appendChild(tocBody);
|
|
||||||
sidebar.appendChild(tocSection);
|
|
||||||
|
|
||||||
var fmSection = document.createElement('section');
|
|
||||||
fmSection.className = 'md-side md-side--fm';
|
|
||||||
var fmHeader = document.createElement('div');
|
|
||||||
fmHeader.className = 'md-side__header';
|
|
||||||
fmHeader.textContent = 'Front matter';
|
|
||||||
var fmBody = document.createElement('div');
|
|
||||||
fmBody.className = 'md-side__body md-fm__body';
|
|
||||||
fmSection.appendChild(fmHeader);
|
|
||||||
fmSection.appendChild(fmBody);
|
|
||||||
sidebar.appendChild(fmSection);
|
|
||||||
|
|
||||||
// Construct the editor. height: 100% works because editorHost
|
// Construct the editor. height: 100% works because editorHost
|
||||||
// is a grid cell with a definite size.
|
// is a grid cell with a definite size.
|
||||||
|
|
@ -424,10 +449,9 @@
|
||||||
renderToc(tocBody, text, editor);
|
renderToc(tocBody, text, editor);
|
||||||
renderFrontMatter(fmBody, text);
|
renderFrontMatter(fmBody, text);
|
||||||
|
|
||||||
// ── Resizer ────────────────────────────────────────────────────────
|
// ── Sidebar/content resizer ─────────────────────────────────────────
|
||||||
// Drag the resizer to grow/shrink the sidebar. Updates the
|
// Sidebar is on the LEFT now. Dragging right grows the
|
||||||
// container's grid-template-columns so the editor + sidebar
|
// sidebar; left shrinks it.
|
||||||
// both reflow cleanly.
|
|
||||||
(function () {
|
(function () {
|
||||||
var dragging = false;
|
var dragging = false;
|
||||||
var startX = 0;
|
var startX = 0;
|
||||||
|
|
@ -435,12 +459,10 @@
|
||||||
function onMove(e) {
|
function onMove(e) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
var dx = e.clientX - startX;
|
var dx = e.clientX - startX;
|
||||||
// Dragging right shrinks the sidebar; left grows it.
|
var w = startW + dx;
|
||||||
// (The sidebar is on the right; user expectation matches.)
|
w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, w));
|
||||||
var w = startW - dx;
|
lastSidebarWidth = w;
|
||||||
w = Math.max(TOC_MIN_WIDTH, Math.min(TOC_MAX_WIDTH, w));
|
shell.style.gridTemplateColumns = w + 'px 1fr';
|
||||||
lastTocWidth = w;
|
|
||||||
shell.style.gridTemplateColumns = '1fr ' + w + 'px';
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
function onUp() {
|
function onUp() {
|
||||||
|
|
@ -458,15 +480,58 @@
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
// Keyboard: ← / → adjust by 24px.
|
|
||||||
resizer.addEventListener('keydown', function (e) {
|
resizer.addEventListener('keydown', function (e) {
|
||||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var step = e.key === 'ArrowLeft' ? 24 : -24;
|
var step = e.key === 'ArrowLeft' ? -24 : 24;
|
||||||
var w = Math.max(TOC_MIN_WIDTH,
|
var w = Math.max(SIDEBAR_MIN_WIDTH,
|
||||||
Math.min(TOC_MAX_WIDTH, lastTocWidth + step));
|
Math.min(SIDEBAR_MAX_WIDTH, lastSidebarWidth + step));
|
||||||
lastTocWidth = w;
|
lastSidebarWidth = w;
|
||||||
shell.style.gridTemplateColumns = '1fr ' + w + 'px';
|
shell.style.gridTemplateColumns = w + 'px 1fr';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Front-matter / TOC vertical resizer ─────────────────────────────
|
||||||
|
(function () {
|
||||||
|
var FM_MIN = 60;
|
||||||
|
var dragging = false;
|
||||||
|
var startY = 0;
|
||||||
|
var startH = 0;
|
||||||
|
function maxFmHeight() {
|
||||||
|
var sidebarRect = sidebar.getBoundingClientRect();
|
||||||
|
// Leave at least 120 px for the TOC body + headers.
|
||||||
|
return Math.max(FM_MIN, sidebarRect.height - 160);
|
||||||
|
}
|
||||||
|
function onMove(e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
var dy = e.clientY - startY;
|
||||||
|
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
|
||||||
|
lastFmHeight = h;
|
||||||
|
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
dragging = false;
|
||||||
|
fmResizer.classList.remove('is-dragging');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
fmResizer.addEventListener('mousedown', function (e) {
|
||||||
|
dragging = true;
|
||||||
|
fmResizer.classList.add('is-dragging');
|
||||||
|
startY = e.clientY;
|
||||||
|
startH = fmSection.getBoundingClientRect().height;
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
fmResizer.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
e.preventDefault();
|
||||||
|
var step = e.key === 'ArrowUp' ? -24 : 24;
|
||||||
|
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
|
||||||
|
lastFmHeight = h;
|
||||||
|
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,51 +13,6 @@ body {
|
||||||
padding: 0 16px 64px;
|
padding: 0 16px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Standalone-tool strip. Sits above the hero on the picker view. Each
|
|
||||||
link is a small card with the tool name (display serif) and a short
|
|
||||||
one-line hint (sans, muted). Hover lifts the card slightly to signal
|
|
||||||
it's clickable; the strip wraps onto multiple lines on narrow widths
|
|
||||||
rather than scrolling horizontally. */
|
|
||||||
.tool-strip {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0 0 24px;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
.tool-strip__link {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 0.5rem 0.85rem;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
|
|
||||||
min-width: 110px;
|
|
||||||
}
|
|
||||||
.tool-strip__link:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.tool-strip__name {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.15;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.tool-strip__hint {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Welcome / hero */
|
/* Welcome / hero */
|
||||||
.landing-hero {
|
.landing-hero {
|
||||||
margin: 0 0 24px;
|
margin: 0 0 24px;
|
||||||
|
|
|
||||||
|
|
@ -34,48 +34,6 @@
|
||||||
<main id="landingMain" class="landing-main">
|
<main id="landingMain" class="landing-main">
|
||||||
<!-- Picker mode (deployment root /). Project picker + groups. -->
|
<!-- Picker mode (deployment root /). Project picker + groups. -->
|
||||||
<div id="pickerView">
|
<div id="pickerView">
|
||||||
<!-- Standalone-tool strip. Each link opens the latest stable
|
|
||||||
single-file build of one ZDDC tool from the canonical
|
|
||||||
release host (zddc.varasys.io/releases/). Useful for
|
|
||||||
"try this tool" / offline use without a project context. -->
|
|
||||||
<nav class="tool-strip" aria-label="ZDDC tools">
|
|
||||||
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/archive_stable.html"
|
|
||||||
title="Browse a project archive — filter by party, status, revision">
|
|
||||||
<span class="tool-strip__name">Archive</span>
|
|
||||||
<span class="tool-strip__hint">Browse & filter</span>
|
|
||||||
</a>
|
|
||||||
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/transmittal_stable.html"
|
|
||||||
title="Compose, validate, and publish transmittals">
|
|
||||||
<span class="tool-strip__name">Transmittal</span>
|
|
||||||
<span class="tool-strip__hint">Issue & receive</span>
|
|
||||||
</a>
|
|
||||||
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/classifier_stable.html"
|
|
||||||
title="Rename incoming files into ZDDC tracking numbers">
|
|
||||||
<span class="tool-strip__name">Classifier</span>
|
|
||||||
<span class="tool-strip__hint">Rename incoming</span>
|
|
||||||
</a>
|
|
||||||
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/mdedit_stable.html"
|
|
||||||
title="Markdown editor with multi-file FS-Access workflow">
|
|
||||||
<span class="tool-strip__name">Markdown</span>
|
|
||||||
<span class="tool-strip__hint">Edit prose</span>
|
|
||||||
</a>
|
|
||||||
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/browse_stable.html"
|
|
||||||
title="Unified file tree + per-file-type preview">
|
|
||||||
<span class="tool-strip__name">Browse</span>
|
|
||||||
<span class="tool-strip__hint">Files & preview</span>
|
|
||||||
</a>
|
|
||||||
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/form_stable.html"
|
|
||||||
title="Schema-driven form renderer for *.form.yaml files">
|
|
||||||
<span class="tool-strip__name">Form</span>
|
|
||||||
<span class="tool-strip__hint">Structured input</span>
|
|
||||||
</a>
|
|
||||||
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/tables_stable.html"
|
|
||||||
title="Aggregate a directory of YAML rows into a sortable table">
|
|
||||||
<span class="tool-strip__name">Tables</span>
|
|
||||||
<span class="tool-strip__hint">YAML rollup</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Welcome / hero -->
|
<!-- Welcome / hero -->
|
||||||
<section class="landing-hero">
|
<section class="landing-hero">
|
||||||
<h1>Welcome to the ZDDC Archive</h1>
|
<h1>Welcome to the ZDDC Archive</h1>
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,12 @@ test.describe('Browse', () => {
|
||||||
await page.waitForSelector('#treeBody .tree-row[data-isdir="false"]', { timeout: 10000 });
|
await page.waitForSelector('#treeBody .tree-row[data-isdir="false"]', { timeout: 10000 });
|
||||||
await page.locator('#treeBody .tree-row[data-isdir="false"]').first().click();
|
await page.locator('#treeBody .tree-row[data-isdir="false"]').first().click();
|
||||||
|
|
||||||
// Markdown plugin DOM mounts: shell, toolbar, editor host, sidebar.
|
// Markdown plugin DOM mounts: shell, sidebar (front matter +
|
||||||
|
// TOC), content (info header + editor).
|
||||||
await expect(page.locator('.md-shell')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('.md-shell')).toBeVisible({ timeout: 15000 });
|
||||||
await expect(page.locator('.md-shell__toolbar')).toBeVisible();
|
|
||||||
await expect(page.locator('.md-shell__editor')).toBeVisible();
|
|
||||||
await expect(page.locator('.md-shell__sidebar')).toBeVisible();
|
await expect(page.locator('.md-shell__sidebar')).toBeVisible();
|
||||||
|
await expect(page.locator('.md-shell__infohdr')).toBeVisible();
|
||||||
|
await expect(page.locator('.md-shell__editor')).toBeVisible();
|
||||||
|
|
||||||
// Outline lists the three headings.
|
// Outline lists the three headings.
|
||||||
await page.waitForSelector('.md-toc__list .md-toc__item', { timeout: 10000 });
|
await page.waitForSelector('.md-toc__list .md-toc__item', { timeout: 10000 });
|
||||||
|
|
|
||||||
|
|
@ -853,7 +853,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// Depth-2 no-slash (reviewing) falls through to the canonical-
|
// Depth-2 no-slash (reviewing) falls through to the canonical-
|
||||||
// folder block below where DefaultAppAt routes to mdedit.
|
// folder block below where DefaultAppAt routes to mdedit.
|
||||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
if proj, tracking, ok := handler.IsReviewingPath(urlPath); ok {
|
if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
if tracking != "" {
|
if tracking != "" {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||||
|
|
@ -866,13 +866,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeReviewing(cfg, w, r, proj, tracking)
|
handler.ServeReviewing(cfg, w, r, proj, tracking, sidePath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// HTML trailing-slash falls through to canonical-folder
|
// HTML trailing-slash falls through to canonical-folder
|
||||||
// block → ServeDirectory → embedded browse.html.
|
// block → ServeDirectory → embedded browse.html.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Default-MDL virtual directory at archive/<party>/mdl[/].
|
||||||
|
// The rows-dir doesn't have to exist on disk —
|
||||||
|
// RecognizeTableRequest's default-MDL fallback handles a
|
||||||
|
// fully-missing path so a fresh party with no entries yet
|
||||||
|
// still lands on a usable table view (rather than 404).
|
||||||
|
// Both slash and no-slash forms serve the tables app
|
||||||
|
// directly; the slash form is the canonical URL the MDL
|
||||||
|
// card on the project landing page links to.
|
||||||
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
|
base := strings.TrimSuffix(urlPath, "/")
|
||||||
|
synth := base + "/table.html"
|
||||||
|
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
|
||||||
|
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
||||||
|
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.ServeTable(cfg, tr, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// Canonical project-root folder fallback. <project>/{archive,
|
// Canonical project-root folder fallback. <project>/{archive,
|
||||||
// working,staging,reviewing}[/] should land on a usable view
|
// working,staging,reviewing}[/] should land on a usable view
|
||||||
// (default tool or empty listing) rather than 404, so the
|
// (default tool or empty listing) rather than 404, so the
|
||||||
|
|
|
||||||
|
|
@ -18,30 +18,48 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsReviewingPath classifies a URL as a reviewing-aggregator path and
|
// IsReviewingPath classifies a URL as a reviewing-aggregator path and
|
||||||
// extracts (project, tracking). The aggregator is a virtual view at:
|
// extracts (project, tracking, sidePath). The aggregator is a virtual
|
||||||
|
// view at:
|
||||||
//
|
//
|
||||||
// <project>/reviewing/ → depth 0: list pending submittals
|
// <project>/reviewing/ → depth 0: pending submittals
|
||||||
// <project>/reviewing/<tracking>/ → depth 1: list received/ + staged/
|
// <project>/reviewing/<tracking>/ → depth 1: received/ + staged/
|
||||||
|
// <project>/reviewing/<tracking>/<side>/[...] → depth ≥ 2: real folder
|
||||||
|
// contents (received or
|
||||||
|
// staged), proxied from
|
||||||
|
// the canonical archive
|
||||||
|
// or staging path so the
|
||||||
|
// user can preview files
|
||||||
|
// in the browse pane
|
||||||
|
// without leaving the
|
||||||
|
// reviewing view.
|
||||||
//
|
//
|
||||||
// Anything deeper than depth 1 returns ok=false; the depth-1 listing
|
// sidePath at depth 1 is "" (no side selected yet). At depth ≥ 2 it's
|
||||||
// emits canonical URLs (under archive/ and staging/) so navigation past
|
// "received[/rest...]" or "staged[/rest...]" — the slash-separated
|
||||||
// that point goes through the regular file-tree handlers, not back into
|
// remainder after the tracking segment.
|
||||||
// the virtual reviewing/ subtree.
|
|
||||||
//
|
//
|
||||||
// Trailing slash on either depth is required and tolerated. Match on
|
// Match on "reviewing" is case-insensitive.
|
||||||
// "reviewing" is case-insensitive.
|
func IsReviewingPath(urlPath string) (project, tracking, sidePath string, ok bool) {
|
||||||
func IsReviewingPath(urlPath string) (project, tracking string, ok bool) {
|
|
||||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||||
if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") {
|
if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") {
|
||||||
return "", "", false
|
return "", "", "", false
|
||||||
}
|
}
|
||||||
switch len(parts) {
|
switch len(parts) {
|
||||||
case 2:
|
case 2:
|
||||||
return parts[0], "", true
|
return parts[0], "", "", true
|
||||||
case 3:
|
case 3:
|
||||||
return parts[0], parts[2], true
|
return parts[0], parts[2], "", true
|
||||||
default:
|
default:
|
||||||
return "", "", false
|
// parts[3] is the side; remainder joins back as the sub-path
|
||||||
|
// within the real folder.
|
||||||
|
side := strings.ToLower(parts[3])
|
||||||
|
if side != "received" && side != "staged" {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
rest := strings.Join(parts[4:], "/")
|
||||||
|
if rest == "" {
|
||||||
|
return parts[0], parts[2], side, true
|
||||||
|
}
|
||||||
|
return parts[0], parts[2], side + "/" + rest, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,13 +231,27 @@ func computePending(ctx context.Context, decider policy.Decider,
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeReviewing emits the aggregator JSON listing for either depth 0
|
// ServeReviewing emits the aggregator JSON listing for any depth under
|
||||||
// (project's full pending list) or depth 1 (one submittal's
|
// <project>/reviewing/. The HTML branch is handled separately by the
|
||||||
// received/ + staged/ pair). The HTML branch is handled separately by
|
// apps subsystem (mdedit served at the URL); only requests that accept
|
||||||
// the apps subsystem (mdedit served at the URL); only requests that
|
// JSON reach here.
|
||||||
// accept JSON reach here.
|
//
|
||||||
|
// Depths:
|
||||||
|
//
|
||||||
|
// 0 (tracking="") → list pending submittals as virtual
|
||||||
|
// <tracking>/ folders.
|
||||||
|
// 1 (tracking, side="") → list received/ + staged/ virtual folders.
|
||||||
|
// ≥2 (tracking, sidePath) → proxy the listing of the real folder
|
||||||
|
// under archive/<party>/received/<folder>/...
|
||||||
|
// or staging/<folder>/... so the user can
|
||||||
|
// preview files without leaving the
|
||||||
|
// reviewing view. Folder entries keep
|
||||||
|
// virtual reviewing/ URLs (navigation
|
||||||
|
// stays in the aggregator). File entries
|
||||||
|
// use canonical URLs so byte fetches
|
||||||
|
// resolve directly against the real path.
|
||||||
func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
||||||
project, tracking string) {
|
project, tracking, sidePath string) {
|
||||||
|
|
||||||
pending, err := computePending(r.Context(), DeciderFromContext(r),
|
pending, err := computePending(r.Context(), DeciderFromContext(r),
|
||||||
cfg.Root, project, EmailFromContext(r))
|
cfg.Root, project, EmailFromContext(r))
|
||||||
|
|
@ -229,11 +261,9 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
||||||
}
|
}
|
||||||
|
|
||||||
var entries []listing.FileInfo
|
var entries []listing.FileInfo
|
||||||
switch tracking {
|
switch {
|
||||||
case "":
|
case tracking == "":
|
||||||
// Depth 0: list pending submittals as virtual <tracking>/ folders.
|
// Depth 0: list pending submittals as virtual <tracking>/ folders.
|
||||||
// The URLs stay under reviewing/ so the user can drill into a
|
|
||||||
// per-submittal view.
|
|
||||||
urlPrefix := "/" + project + "/reviewing/"
|
urlPrefix := "/" + project + "/reviewing/"
|
||||||
for _, s := range pending {
|
for _, s := range pending {
|
||||||
entries = append(entries, listing.FileInfo{
|
entries = append(entries, listing.FileInfo{
|
||||||
|
|
@ -245,10 +275,7 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Depth 1: find the matching pending entry; emit received/ +
|
// Depth ≥1: find the pending entry for this tracking number.
|
||||||
// staged/ pointing at canonical archive/staging URLs. Clients
|
|
||||||
// using the polyfill follow these URLs out of the virtual
|
|
||||||
// subtree into the real file paths underneath.
|
|
||||||
var match *pendingSubmittal
|
var match *pendingSubmittal
|
||||||
for i := range pending {
|
for i := range pending {
|
||||||
if pending[i].tracking == tracking {
|
if pending[i].tracking == tracking {
|
||||||
|
|
@ -260,9 +287,14 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if sidePath == "" {
|
||||||
|
// Depth 1: emit received/ + staged/ virtual folder pointers.
|
||||||
|
// URLs stay under reviewing/ so navigation into them remains
|
||||||
|
// in the aggregator (handled by the depth ≥2 branch).
|
||||||
|
urlPrefix := "/" + project + "/reviewing/" + url.PathEscape(tracking) + "/"
|
||||||
entries = append(entries, listing.FileInfo{
|
entries = append(entries, listing.FileInfo{
|
||||||
Name: "received/",
|
Name: "received/",
|
||||||
URL: match.receivedURL,
|
URL: urlPrefix + "received/",
|
||||||
ModTime: match.lastModified,
|
ModTime: match.lastModified,
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
Virtual: true,
|
Virtual: true,
|
||||||
|
|
@ -270,12 +302,116 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
||||||
if match.stagedURL != "" {
|
if match.stagedURL != "" {
|
||||||
entries = append(entries, listing.FileInfo{
|
entries = append(entries, listing.FileInfo{
|
||||||
Name: "staged/",
|
Name: "staged/",
|
||||||
URL: match.stagedURL,
|
URL: urlPrefix + "staged/",
|
||||||
ModTime: match.lastModified,
|
ModTime: match.lastModified,
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
Virtual: true,
|
Virtual: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Depth ≥2: proxy the real folder's listing. sidePath is
|
||||||
|
// "received[/rest]" or "staged[/rest]" — split off the
|
||||||
|
// leading side, append remainder to the canonical base.
|
||||||
|
side := sidePath
|
||||||
|
rest := ""
|
||||||
|
if i := strings.IndexByte(sidePath, '/'); i >= 0 {
|
||||||
|
side, rest = sidePath[:i], sidePath[i+1:]
|
||||||
|
}
|
||||||
|
var realURL string
|
||||||
|
switch side {
|
||||||
|
case "received":
|
||||||
|
realURL = match.receivedURL
|
||||||
|
case "staged":
|
||||||
|
if match.stagedURL == "" {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
realURL = match.stagedURL
|
||||||
|
default:
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rest != "" {
|
||||||
|
realURL = strings.TrimSuffix(realURL, "/") + "/" + rest + "/"
|
||||||
|
}
|
||||||
|
// Translate the real URL back to a filesystem path so we
|
||||||
|
// can list it. The URL still encodes percent-escapes;
|
||||||
|
// PathUnescape them before joining.
|
||||||
|
realRel := strings.TrimPrefix(realURL, "/")
|
||||||
|
realRel = strings.TrimSuffix(realRel, "/")
|
||||||
|
realRelDecoded, decodeErr := url.PathUnescape(realRel)
|
||||||
|
if decodeErr != nil {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
realAbs := filepath.Join(cfg.Root, filepath.FromSlash(realRelDecoded))
|
||||||
|
if !strings.HasPrefix(realAbs, cfg.Root+string(filepath.Separator)) {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// ACL on the underlying real path; do not proxy what the
|
||||||
|
// caller can't read directly.
|
||||||
|
chain, err := zddc.EffectivePolicy(cfg.Root, realAbs)
|
||||||
|
if err == nil {
|
||||||
|
if allowed, _ := policy.AllowFromChain(r.Context(),
|
||||||
|
DeciderFromContext(r), chain,
|
||||||
|
EmailFromContext(r), realURL); !allowed {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diskEntries, err := os.ReadDir(realAbs)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Build the virtual URL prefix (for folder entries) and
|
||||||
|
// the canonical URL prefix (for file entries).
|
||||||
|
virtualPrefix := "/" + project + "/reviewing/" +
|
||||||
|
url.PathEscape(tracking) + "/" + side + "/"
|
||||||
|
if rest != "" {
|
||||||
|
virtualPrefix += rest + "/"
|
||||||
|
}
|
||||||
|
canonicalPrefix := realURL // already ends with "/"
|
||||||
|
for _, e := range diskEntries {
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi := listing.FileInfo{
|
||||||
|
Name: name,
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
}
|
||||||
|
if e.IsDir() {
|
||||||
|
fi.Name += "/"
|
||||||
|
fi.IsDir = true
|
||||||
|
fi.URL = virtualPrefix + url.PathEscape(name) + "/"
|
||||||
|
fi.Virtual = true
|
||||||
|
} else {
|
||||||
|
fi.Size = info.Size()
|
||||||
|
// File URL points at the canonical real path so
|
||||||
|
// fetches (preview, download) hit the right bytes
|
||||||
|
// directly — no proxying through the aggregator.
|
||||||
|
fi.URL = canonicalPrefix + url.PathEscape(name)
|
||||||
|
}
|
||||||
|
entries = append(entries, fi)
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
// Folders first, then files; both alphabetical.
|
||||||
|
if entries[i].IsDir != entries[j].IsDir {
|
||||||
|
return entries[i].IsDir
|
||||||
|
}
|
||||||
|
return entries[i].Name < entries[j].Name
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
|
|
|
||||||
|
|
@ -18,26 +18,32 @@ func TestIsReviewingPath(t *testing.T) {
|
||||||
wantOK bool
|
wantOK bool
|
||||||
wantProj string
|
wantProj string
|
||||||
wantTracking string
|
wantTracking string
|
||||||
|
wantSide string
|
||||||
}{
|
}{
|
||||||
{"/Project/reviewing/", true, "Project", ""},
|
{"/Project/reviewing/", true, "Project", "", ""},
|
||||||
{"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001"},
|
{"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001", ""},
|
||||||
// Case-insensitive on the literal "reviewing" segment.
|
// Case-insensitive on the literal "reviewing" segment.
|
||||||
{"/Project/Reviewing/", true, "Project", ""},
|
{"/Project/Reviewing/", true, "Project", "", ""},
|
||||||
{"/Project/REVIEWING/x/", true, "Project", "x"},
|
{"/Project/REVIEWING/x/", true, "Project", "x", ""},
|
||||||
// No trailing slash: still classified (caller decides redirect).
|
// No trailing slash: still classified (caller decides redirect).
|
||||||
{"/Project/reviewing", true, "Project", ""},
|
{"/Project/reviewing", true, "Project", "", ""},
|
||||||
{"/Project/reviewing/123/", true, "Project", "123"},
|
{"/Project/reviewing/123/", true, "Project", "123", ""},
|
||||||
|
// Depth 2+: side present.
|
||||||
|
{"/Project/reviewing/123/received/", true, "Project", "123", "received"},
|
||||||
|
{"/Project/reviewing/123/staged/", true, "Project", "123", "staged"},
|
||||||
|
{"/Project/reviewing/123/received/sub/", true, "Project", "123", "received/sub"},
|
||||||
|
// Unknown side at depth 2 is rejected.
|
||||||
|
{"/Project/reviewing/123/issued/", false, "", "", ""},
|
||||||
// Non-canonical / wrong shape.
|
// Non-canonical / wrong shape.
|
||||||
{"/Project/", false, "", ""},
|
{"/Project/", false, "", "", ""},
|
||||||
{"/", false, "", ""},
|
{"/", false, "", "", ""},
|
||||||
{"/Project/working/", false, "", ""},
|
{"/Project/working/", false, "", "", ""},
|
||||||
{"/Project/reviewing/x/y/", false, "", ""}, // depth >3 not supported
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
gotProj, gotTracking, gotOK := IsReviewingPath(tc.path)
|
gotProj, gotTracking, gotSide, gotOK := IsReviewingPath(tc.path)
|
||||||
if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking {
|
if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking || gotSide != tc.wantSide {
|
||||||
t.Errorf("IsReviewingPath(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
t.Errorf("IsReviewingPath(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)",
|
||||||
tc.path, gotProj, gotTracking, gotOK, tc.wantProj, tc.wantTracking, tc.wantOK)
|
tc.path, gotProj, gotTracking, gotSide, gotOK, tc.wantProj, tc.wantTracking, tc.wantSide, tc.wantOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +91,7 @@ func TestServeReviewing(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeReviewing(cfg, rec, req, "Project", "")
|
ServeReviewing(cfg, rec, req, "Project", "", "")
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -122,7 +128,7 @@ func TestServeReviewing(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007")
|
ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007", "")
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -138,15 +144,17 @@ func TestServeReviewing(t *testing.T) {
|
||||||
if got[0].Name != "received/" {
|
if got[0].Name != "received/" {
|
||||||
t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "received/")
|
t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "received/")
|
||||||
}
|
}
|
||||||
// Canonical URL — outside reviewing/ subtree.
|
// Virtual URL — stays under reviewing/ so depth-2 navigation
|
||||||
if want := "/Project/archive/Beta/received/"; !startsWith(got[0].URL, want) {
|
// returns to the aggregator (which lists the real folder's
|
||||||
t.Errorf("received URL=%q, want prefix %q", got[0].URL, want)
|
// contents with canonical file URLs).
|
||||||
|
if want := "/Project/reviewing/002-AB-SUB-0007/received/"; got[0].URL != want {
|
||||||
|
t.Errorf("received URL=%q, want %q", got[0].URL, want)
|
||||||
}
|
}
|
||||||
if got[1].Name != "staged/" {
|
if got[1].Name != "staged/" {
|
||||||
t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "staged/")
|
t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "staged/")
|
||||||
}
|
}
|
||||||
if want := "/Project/staging/"; !startsWith(got[1].URL, want) {
|
if want := "/Project/reviewing/002-AB-SUB-0007/staged/"; got[1].URL != want {
|
||||||
t.Errorf("staged URL=%q, want prefix %q", got[1].URL, want)
|
t.Errorf("staged URL=%q, want %q", got[1].URL, want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -155,7 +163,7 @@ func TestServeReviewing(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001")
|
ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001", "")
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
||||||
|
|
@ -174,7 +182,7 @@ func TestServeReviewing(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999")
|
ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999", "")
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("status=%d, want 404", rec.Code)
|
t.Errorf("status=%d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +201,7 @@ func TestServeReviewing(t *testing.T) {
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeReviewing(bareCfg, rec, req, "Fresh", "")
|
ServeReviewing(bareCfg, rec, req, "Fresh", "", "")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 17:07:33 · c87fb7f-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 17:29:36 · b1479c5-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue