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); }
|
||||
|
||||
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
|
||||
/* CSS-Grid shell. Two columns (editor | sidebar) and two rows (toolbar
|
||||
| body). The grid gives every cell a definite size, which Toast UI
|
||||
needs to compute its scroll regions correctly. A 4-px resizer sits
|
||||
between the editor and sidebar; JS updates grid-template-columns on
|
||||
drag. */
|
||||
/* CSS-Grid shell mirroring mdedit's layout: sidebar on the LEFT
|
||||
(front matter top + TOC bottom), content on the RIGHT (informational
|
||||
header above the Toast UI editor). The grid gives every cell a
|
||||
definite size, which Toast UI needs to compute its scroll regions
|
||||
correctly. */
|
||||
.md-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr 260px; /* JS overrides on resize */
|
||||
grid-template-areas:
|
||||
"toolbar toolbar"
|
||||
"editor sidebar";
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 280px 1fr; /* JS overrides on resize */
|
||||
grid-template-areas: "sidebar content";
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toolbar spans both columns; subtle row above the editor. */
|
||||
.md-shell__toolbar {
|
||||
grid-area: toolbar;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
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;
|
||||
/* Sidebar (col 1): two stacked sections — Front matter (top, fixed
|
||||
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
|
||||
.md-shell__sidebar {
|
||||
grid-area: sidebar;
|
||||
display: grid;
|
||||
grid-template-rows: 180px 1fr; /* JS overrides on resize */
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
/* Toast UI mounts a .toastui-editor-defaultUI element here; give
|
||||
it a definite height via height:100% in the JS. */
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Resizer sits on the grid border between editor (col 1) and sidebar
|
||||
(col 2). Positioned absolutely over the boundary so it doesn't take
|
||||
up a grid track itself. */
|
||||
/* Vertical sidebar/content resizer. Sits absolutely on the column
|
||||
boundary so it doesn't occupy a grid track. */
|
||||
.md-shell__resizer {
|
||||
grid-area: editor;
|
||||
grid-area: sidebar;
|
||||
align-self: stretch;
|
||||
justify-self: end;
|
||||
width: 6px;
|
||||
margin-right: -3px; /* center on the column boundary */
|
||||
margin-right: -3px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 2;
|
||||
|
|
@ -446,17 +409,91 @@ html, body {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
/* Sidebar (right column): grid of two stacked sections — Outline
|
||||
(1fr) takes the bulk of the height, Front matter (auto, capped) is
|
||||
below. */
|
||||
.md-shell__sidebar {
|
||||
grid-area: sidebar;
|
||||
/* Horizontal resizer between front-matter and TOC inside the sidebar.
|
||||
Spans both rows by placement, then absolutely positioned to overlay
|
||||
the grid-row boundary. */
|
||||
.md-shell__fmresizer {
|
||||
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;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
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);
|
||||
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 {
|
||||
|
|
@ -465,10 +502,8 @@ html, body {
|
|||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.md-side--fm {
|
||||
.md-side--toc {
|
||||
border-top: 1px solid var(--border);
|
||||
/* Front matter doesn't dominate — cap it so the outline keeps room. */
|
||||
max-height: 40%;
|
||||
}
|
||||
.md-side__header {
|
||||
padding: 0.35rem 0.75rem;
|
||||
|
|
|
|||
|
|
@ -28,9 +28,10 @@
|
|||
|
||||
if (!window.app || !window.app.modules) return;
|
||||
|
||||
var TOC_MIN_WIDTH = 180;
|
||||
var TOC_MAX_WIDTH = 480;
|
||||
var TOC_DEFAULT_WIDTH = 260;
|
||||
var SIDEBAR_MIN_WIDTH = 180;
|
||||
var SIDEBAR_MAX_WIDTH = 480;
|
||||
var SIDEBAR_DEFAULT_WIDTH = 280;
|
||||
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
|
|
@ -38,7 +39,8 @@
|
|||
}
|
||||
|
||||
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) {
|
||||
if (!window.crypto || !window.crypto.subtle) return null;
|
||||
|
|
@ -302,20 +304,78 @@
|
|||
}
|
||||
|
||||
// Wipe the container and install a single shell child. The
|
||||
// shell is a CSS Grid with two rows (toolbar | body) and two
|
||||
// columns (editor | sidebar). Setting these on a dedicated
|
||||
// child — rather than touching previewBody's class — keeps
|
||||
// the outer flex layout intact (previewBody itself is the
|
||||
// flex item that fills the preview pane).
|
||||
// shell mirrors mdedit's layout: sidebar on the LEFT (front
|
||||
// matter top, TOC bottom), content on the RIGHT (informational
|
||||
// header above the Toast UI editor). CSS Grid keeps every
|
||||
// cell sized definitely so Toast UI's scroll regions resolve
|
||||
// correctly.
|
||||
container.innerHTML = '';
|
||||
var shell = document.createElement('div');
|
||||
shell.className = 'md-shell';
|
||||
shell.style.gridTemplateColumns = '1fr ' + lastTocWidth + 'px';
|
||||
shell.style.gridTemplateColumns = lastSidebarWidth + 'px 1fr';
|
||||
container.appendChild(shell);
|
||||
|
||||
// Toolbar (row 1, spans both columns).
|
||||
var toolbar = document.createElement('div');
|
||||
toolbar.className = 'md-shell__toolbar';
|
||||
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
|
||||
var sidebar = document.createElement('div');
|
||||
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');
|
||||
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
|
||||
|
|
@ -339,52 +399,17 @@
|
|||
sourceEl.textContent = 'server';
|
||||
}
|
||||
|
||||
toolbar.appendChild(saveBtn);
|
||||
toolbar.appendChild(dirtyEl);
|
||||
toolbar.appendChild(statusEl);
|
||||
toolbar.appendChild(sourceEl);
|
||||
shell.appendChild(toolbar);
|
||||
infohdr.appendChild(titleEl);
|
||||
infohdr.appendChild(dirtyEl);
|
||||
infohdr.appendChild(statusEl);
|
||||
infohdr.appendChild(sourceEl);
|
||||
infohdr.appendChild(saveBtn);
|
||||
content.appendChild(infohdr);
|
||||
|
||||
// Editor host (row 2, col 1).
|
||||
// Editor host.
|
||||
var editorHost = document.createElement('div');
|
||||
editorHost.className = 'md-shell__editor';
|
||||
shell.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);
|
||||
content.appendChild(editorHost);
|
||||
|
||||
// Construct the editor. height: 100% works because editorHost
|
||||
// is a grid cell with a definite size.
|
||||
|
|
@ -424,10 +449,9 @@
|
|||
renderToc(tocBody, text, editor);
|
||||
renderFrontMatter(fmBody, text);
|
||||
|
||||
// ── Resizer ────────────────────────────────────────────────────────
|
||||
// Drag the resizer to grow/shrink the sidebar. Updates the
|
||||
// container's grid-template-columns so the editor + sidebar
|
||||
// both reflow cleanly.
|
||||
// ── Sidebar/content resizer ─────────────────────────────────────────
|
||||
// Sidebar is on the LEFT now. Dragging right grows the
|
||||
// sidebar; left shrinks it.
|
||||
(function () {
|
||||
var dragging = false;
|
||||
var startX = 0;
|
||||
|
|
@ -435,12 +459,10 @@
|
|||
function onMove(e) {
|
||||
if (!dragging) return;
|
||||
var dx = e.clientX - startX;
|
||||
// Dragging right shrinks the sidebar; left grows it.
|
||||
// (The sidebar is on the right; user expectation matches.)
|
||||
var w = startW - dx;
|
||||
w = Math.max(TOC_MIN_WIDTH, Math.min(TOC_MAX_WIDTH, w));
|
||||
lastTocWidth = w;
|
||||
shell.style.gridTemplateColumns = '1fr ' + w + 'px';
|
||||
var w = startW + dx;
|
||||
w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, w));
|
||||
lastSidebarWidth = w;
|
||||
shell.style.gridTemplateColumns = w + 'px 1fr';
|
||||
e.preventDefault();
|
||||
}
|
||||
function onUp() {
|
||||
|
|
@ -458,15 +480,58 @@
|
|||
document.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
});
|
||||
// Keyboard: ← / → adjust by 24px.
|
||||
resizer.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
e.preventDefault();
|
||||
var step = e.key === 'ArrowLeft' ? 24 : -24;
|
||||
var w = Math.max(TOC_MIN_WIDTH,
|
||||
Math.min(TOC_MAX_WIDTH, lastTocWidth + step));
|
||||
lastTocWidth = w;
|
||||
shell.style.gridTemplateColumns = '1fr ' + w + 'px';
|
||||
var step = e.key === 'ArrowLeft' ? -24 : 24;
|
||||
var w = Math.max(SIDEBAR_MIN_WIDTH,
|
||||
Math.min(SIDEBAR_MAX_WIDTH, lastSidebarWidth + step));
|
||||
lastSidebarWidth = w;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.landing-hero {
|
||||
margin: 0 0 24px;
|
||||
|
|
|
|||
|
|
@ -34,48 +34,6 @@
|
|||
<main id="landingMain" class="landing-main">
|
||||
<!-- Picker mode (deployment root /). Project picker + groups. -->
|
||||
<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 -->
|
||||
<section class="landing-hero">
|
||||
<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.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__toolbar')).toBeVisible();
|
||||
await expect(page.locator('.md-shell__editor')).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.
|
||||
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-
|
||||
// folder block below where DefaultAppAt routes to mdedit.
|
||||
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 tracking != "" {
|
||||
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)
|
||||
return
|
||||
}
|
||||
handler.ServeReviewing(cfg, w, r, proj, tracking)
|
||||
handler.ServeReviewing(cfg, w, r, proj, tracking, sidePath)
|
||||
return
|
||||
}
|
||||
// HTML trailing-slash falls through to canonical-folder
|
||||
// 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,
|
||||
// working,staging,reviewing}[/] should land on a usable view
|
||||
// (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
|
||||
// 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/<tracking>/ → depth 1: list received/ + staged/
|
||||
// <project>/reviewing/ → depth 0: pending submittals
|
||||
// <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
|
||||
// emits canonical URLs (under archive/ and staging/) so navigation past
|
||||
// that point goes through the regular file-tree handlers, not back into
|
||||
// the virtual reviewing/ subtree.
|
||||
// sidePath at depth 1 is "" (no side selected yet). At depth ≥ 2 it's
|
||||
// "received[/rest...]" or "staged[/rest...]" — the slash-separated
|
||||
// remainder after the tracking segment.
|
||||
//
|
||||
// Trailing slash on either depth is required and tolerated. Match on
|
||||
// "reviewing" is case-insensitive.
|
||||
func IsReviewingPath(urlPath string) (project, tracking string, ok bool) {
|
||||
// Match on "reviewing" is case-insensitive.
|
||||
func IsReviewingPath(urlPath string) (project, tracking, sidePath string, ok bool) {
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") {
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
return parts[0], "", true
|
||||
return parts[0], "", "", true
|
||||
case 3:
|
||||
return parts[0], parts[2], true
|
||||
return parts[0], parts[2], "", true
|
||||
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
|
||||
}
|
||||
|
||||
// ServeReviewing emits the aggregator JSON listing for either depth 0
|
||||
// (project's full pending list) or depth 1 (one submittal's
|
||||
// received/ + staged/ pair). The HTML branch is handled separately by
|
||||
// the apps subsystem (mdedit served at the URL); only requests that
|
||||
// accept JSON reach here.
|
||||
// ServeReviewing emits the aggregator JSON listing for any depth under
|
||||
// <project>/reviewing/. The HTML branch is handled separately by the
|
||||
// apps subsystem (mdedit served at the URL); only requests that 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,
|
||||
project, tracking string) {
|
||||
project, tracking, sidePath string) {
|
||||
|
||||
pending, err := computePending(r.Context(), DeciderFromContext(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
|
||||
switch tracking {
|
||||
case "":
|
||||
switch {
|
||||
case tracking == "":
|
||||
// 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/"
|
||||
for _, s := range pending {
|
||||
entries = append(entries, listing.FileInfo{
|
||||
|
|
@ -245,10 +275,7 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
|||
})
|
||||
}
|
||||
default:
|
||||
// Depth 1: find the matching pending entry; emit received/ +
|
||||
// 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.
|
||||
// Depth ≥1: find the pending entry for this tracking number.
|
||||
var match *pendingSubmittal
|
||||
for i := range pending {
|
||||
if pending[i].tracking == tracking {
|
||||
|
|
@ -260,21 +287,130 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
|||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
entries = append(entries, listing.FileInfo{
|
||||
Name: "received/",
|
||||
URL: match.receivedURL,
|
||||
ModTime: match.lastModified,
|
||||
IsDir: true,
|
||||
Virtual: true,
|
||||
})
|
||||
if match.stagedURL != "" {
|
||||
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{
|
||||
Name: "staged/",
|
||||
URL: match.stagedURL,
|
||||
Name: "received/",
|
||||
URL: urlPrefix + "received/",
|
||||
ModTime: match.lastModified,
|
||||
IsDir: true,
|
||||
Virtual: true,
|
||||
})
|
||||
if match.stagedURL != "" {
|
||||
entries = append(entries, listing.FileInfo{
|
||||
Name: "staged/",
|
||||
URL: urlPrefix + "staged/",
|
||||
ModTime: match.lastModified,
|
||||
IsDir: 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,26 +18,32 @@ func TestIsReviewingPath(t *testing.T) {
|
|||
wantOK bool
|
||||
wantProj string
|
||||
wantTracking string
|
||||
wantSide string
|
||||
}{
|
||||
{"/Project/reviewing/", true, "Project", ""},
|
||||
{"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001"},
|
||||
{"/Project/reviewing/", true, "Project", "", ""},
|
||||
{"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001", ""},
|
||||
// Case-insensitive on the literal "reviewing" segment.
|
||||
{"/Project/Reviewing/", true, "Project", ""},
|
||||
{"/Project/REVIEWING/x/", true, "Project", "x"},
|
||||
{"/Project/Reviewing/", true, "Project", "", ""},
|
||||
{"/Project/REVIEWING/x/", true, "Project", "x", ""},
|
||||
// No trailing slash: still classified (caller decides redirect).
|
||||
{"/Project/reviewing", true, "Project", ""},
|
||||
{"/Project/reviewing/123/", true, "Project", "123"},
|
||||
{"/Project/reviewing", true, "Project", "", ""},
|
||||
{"/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.
|
||||
{"/Project/", false, "", ""},
|
||||
{"/", false, "", ""},
|
||||
{"/Project/working/", false, "", ""},
|
||||
{"/Project/reviewing/x/y/", false, "", ""}, // depth >3 not supported
|
||||
{"/Project/", false, "", "", ""},
|
||||
{"/", false, "", "", ""},
|
||||
{"/Project/working/", false, "", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
gotProj, gotTracking, gotOK := IsReviewingPath(tc.path)
|
||||
if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking {
|
||||
t.Errorf("IsReviewingPath(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
||||
tc.path, gotProj, gotTracking, gotOK, tc.wantProj, tc.wantTracking, tc.wantOK)
|
||||
gotProj, gotTracking, gotSide, gotOK := IsReviewingPath(tc.path)
|
||||
if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking || gotSide != tc.wantSide {
|
||||
t.Errorf("IsReviewingPath(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)",
|
||||
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 = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||
rec := httptest.NewRecorder()
|
||||
ServeReviewing(cfg, rec, req, "Project", "")
|
||||
ServeReviewing(cfg, rec, req, "Project", "", "")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
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 = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||
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 {
|
||||
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/" {
|
||||
t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "received/")
|
||||
}
|
||||
// Canonical URL — outside reviewing/ subtree.
|
||||
if want := "/Project/archive/Beta/received/"; !startsWith(got[0].URL, want) {
|
||||
t.Errorf("received URL=%q, want prefix %q", got[0].URL, want)
|
||||
// Virtual URL — stays under reviewing/ so depth-2 navigation
|
||||
// returns to the aggregator (which lists the real folder's
|
||||
// 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/" {
|
||||
t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "staged/")
|
||||
}
|
||||
if want := "/Project/staging/"; !startsWith(got[1].URL, want) {
|
||||
t.Errorf("staged URL=%q, want prefix %q", got[1].URL, want)
|
||||
if want := "/Project/reviewing/002-AB-SUB-0007/staged/"; 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 = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||
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 {
|
||||
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 = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||
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 {
|
||||
t.Errorf("status=%d, want 404", rec.Code)
|
||||
}
|
||||
|
|
@ -193,7 +201,7 @@ func TestServeReviewing(t *testing.T) {
|
|||
req.Header.Set("Accept", "application/json")
|
||||
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
|
||||
rec := httptest.NewRecorder()
|
||||
ServeReviewing(bareCfg, rec, req, "Fresh", "")
|
||||
ServeReviewing(bareCfg, rec, req, "Fresh", "", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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 class="header-right">
|
||||
|
|
|
|||
Loading…
Reference in a new issue