refactor(landing): project landing is now a single-file SPA, not server-rendered

The /<project> landing page was server-rendered via
internal/handler/projecthandler.go's html/template — an inconsistency
against the project's "every tool is a single-file HTML" convention.
Convert it to a mode of the existing landing/ tool: same bundle now
serves both / (project picker) and /<project> (project workspace).

Mechanics:

  - landing/template.html: pickerView (existing markup) + projectView
    (new: stage cards, browse-all, MDL section, party-list slot).
    Mode toggles by adding/removing .hidden on the two containers.
  - landing/js/landing.js: detectMode() reads location.pathname;
    renderProjectMode() populates stage hrefs from the project segment
    and fetches /<project>/archive/?json=1 for the party list. init()
    forks based on mode; picker init was extracted to initPicker().
    Existing public API + behaviour unchanged for picker mode.
  - landing/css/landing.css: appended ~115 lines for the project view
    (.stages grid, .stage-card hover, .party-list, MDL formatting).
  - cmd/zddc-server/main.go: dispatcher's IsProjectRootURL fork now
    calls appsSrv.Serve(w, r, "landing", chain, absPath) rather than
    the deleted ServeProjectLanding handler.
  - internal/handler/projecthandler.go: trimmed to just the
    IsProjectRootURL predicate (the dispatcher still needs it for
    routing). Template + render code (~220 lines) deleted.

Net effect: same UI as before — same logo wrapping (now via
shared/logo.js, no longer a hand-rolled inline anchor), same stage
cards, same MDL instructions with party links — but the page is now a
single-file SPA that themes like the rest, follows the same logo and
stage-strip conventions, and could in principle be downloaded and
served standalone.

Tests:
  - 3 new tests/landing.spec.js cases: detectMode exposure, project
    workspace renders at /<project> with correct stage hrefs + title,
    party listing populates from JSON fetch and filters dot-prefixed
    entries.
  - The dispatcher test for /Project no-slash still asserts 200 +
    no-redirect; the served body is now landing.html instead of the
    server-rendered template, but both pass the assertion.

LOC: roughly net-neutral. -220 in projecthandler.go, +115 in
landing.css, +130 in landing.js, +60 in template.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-10 07:57:30 -05:00
parent bc5fcf6c73
commit 315d039880
7 changed files with 452 additions and 387 deletions

View file

@ -342,3 +342,125 @@ body {
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
} }
/* ── Project mode ──────────────────────────────────────────────────────── */
/* Activated when location.pathname is a single project segment (e.g.
/Project-1). Picker UI is hidden; this block surfaces the four
lifecycle-stage cards and MDL editing instructions. */
.project-title {
font-size: 1.6rem;
margin: 0 0 0.25rem;
font-weight: 600;
}
.project-title__subtle {
color: var(--text-muted);
font-weight: normal;
font-size: 0.9rem;
}
.lead {
color: var(--text-muted);
margin: 0.25rem 0 1.5rem;
}
.stages {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.85rem;
margin: 1rem 0 1.5rem;
}
.stage-card {
display: block;
padding: 1rem 1.1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
text-decoration: none;
color: var(--text);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
cursor: pointer;
}
.stage-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stage-card:active {
transform: translateY(1px);
}
.stage-card h3 {
margin: 0 0 0.3rem;
font-size: 1rem;
color: var(--primary);
font-weight: 600;
}
.stage-card p {
margin: 0;
color: var(--text-muted);
font-size: 0.875rem;
}
.browse-link {
display: inline-block;
margin-top: 0.25rem;
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.browse-link:hover {
text-decoration: underline;
}
#projectView ol {
padding-left: 1.5rem;
}
#projectView ol li {
margin-bottom: 0.4rem;
}
#projectView code {
font-family: var(--font-mono);
background: var(--bg-secondary);
padding: 0.1em 0.35em;
border-radius: 3px;
font-size: 0.86em;
}
#projectView h2 {
font-size: 1.1rem;
margin: 2.25rem 0 0.5rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.party-list {
padding-left: 1.5rem;
margin: 0.4rem 0 1rem;
}
.party-list li {
margin-bottom: 0.25rem;
}
.party-list a {
color: var(--primary);
text-decoration: none;
}
.party-list a:hover {
text-decoration: underline;
}
.party-list-none-yet {
color: var(--text-muted);
font-style: italic;
}

View file

@ -612,9 +612,163 @@
catch (e) { /* private mode / quota */ } catch (e) { /* private mode / quota */ }
} }
// ── Project mode ─────────────────────────────────────────────────────────
//
// The same landing tool serves at /<project> as the project-workspace
// page. Mode is determined from location.pathname:
//
// / → 'picker' (existing behavior)
// /<single-segment> → 'project'
// /index.html → 'picker' (file:// + standalone-served root)
// anything else → 'picker' (best-effort fallback)
//
// Project mode shows the four canonical lifecycle-stage cards, a
// "browse all files" link, and a Master Deliverables List section
// with direct links to any parties currently in archive/. The party
// list is fetched from <project>/<archive>/?json=1; failures fall
// back to the static "no parties yet" copy.
function detectMode() {
if (typeof location === 'undefined') return 'picker';
var path = location.pathname || '/';
// Strip any trailing /index.html so the deployment-root case
// matches even on file:// or behind some servers.
var trimmed = path.replace(/\/index\.html$/, '/');
if (trimmed === '' || trimmed === '/') return 'picker';
// Single non-slash, non-dot segment → project root.
var parts = trimmed.split('/').filter(Boolean);
if (parts.length === 1 && parts[0].indexOf('.') === -1) {
return 'project';
}
return 'picker';
}
function projectFromPath() {
var parts = (location.pathname || '/').split('/').filter(Boolean);
return parts[0] || '';
}
// Render the project-workspace view: title, four stage links, MDL
// section. Stage hrefs use the no-trailing-slash form so the server
// routes them to each canonical default tool (mdedit for working/,
// transmittal for staging/, etc.). Browse-all and the archive deep
// link use the slash form to land on the directory listing.
async function renderProjectMode() {
var project = projectFromPath();
if (!project) return;
// Hide picker, show project view.
var picker = document.getElementById('pickerView');
var projectView = document.getElementById('projectView');
if (picker) picker.classList.add('hidden');
if (projectView) projectView.classList.remove('hidden');
document.title = project + ' — ZDDC';
var titleEl = document.getElementById('projectName');
if (titleEl) titleEl.textContent = project;
var p = encodeURIComponent(project);
var stages = [
{ id: 'stageArchive', href: '/' + p + '/archive' },
{ id: 'stageWorking', href: '/' + p + '/working' },
{ id: 'stageStaging', href: '/' + p + '/staging' },
{ id: 'stageReviewing', href: '/' + p + '/reviewing' },
];
for (var i = 0; i < stages.length; i++) {
var a = document.getElementById(stages[i].id);
if (a) a.setAttribute('href', stages[i].href);
}
var browseAll = document.getElementById('browseAllLink');
if (browseAll) {
browseAll.setAttribute('href', '/' + p + '/');
browseAll.textContent = 'Browse all files →';
}
var archiveBrowse = document.getElementById('archiveBrowseLink');
if (archiveBrowse) {
archiveBrowse.setAttribute('href', '/' + p + '/archive/');
archiveBrowse.innerHTML = '<code>/' + escapeHtml(project) + '/archive/</code>';
}
// Fetch party list. Best-effort — failures render the
// no-parties-yet fallback. We try /<project>/archive/ — the
// server returns the listing in either lowercase or PascalCase
// form; either yields the same JSON shape via case-insensitive
// URL canonicalization.
var partySection = document.getElementById('partyListSection');
if (!partySection) return;
var parties = await fetchParties(p);
if (parties == null) {
// Network error or unauthenticated — show neither list nor
// explicit "none" message. The page is still usable.
partySection.innerHTML = '';
return;
}
if (parties.length === 0) {
partySection.innerHTML =
'<p class="party-list-none-yet">No party folders yet. The MDL view auto-renders at any '
+ '<code>archive/&lt;party&gt;/mdl/</code> URL, even when the folder doesn\'t exist on '
+ 'disk — so you can start editing an MDL before any transmittals have been exchanged.</p>';
return;
}
var html = '<p><strong>Direct links — parties currently in <code>archive/</code>:</strong></p>'
+ '<ul class="party-list">';
for (var j = 0; j < parties.length; j++) {
var name = parties[j].name;
var url = parties[j].url; // server-provided absolute URL
html += '<li><a href="' + url + 'mdl/">' + escapeHtml(name) + ' MDL →</a></li>';
}
html += '</ul>';
partySection.innerHTML = html;
}
// Returns an array of {name, url} for each party folder in the
// project's archive/, sorted by name. Returns null if the listing
// can't be fetched (offline, 4xx, or non-JSON response). Returns
// [] if the listing succeeds but archive/ is empty / has no
// visible party folders.
async function fetchParties(projectURL) {
try {
var resp = await fetch('/' + projectURL + '/archive/', {
headers: { 'Accept': 'application/json' },
cache: 'no-cache',
credentials: 'same-origin'
});
if (!resp.ok) return null;
var ctype = resp.headers.get('Content-Type') || '';
if (!ctype.toLowerCase().includes('json')) return null;
var data = await resp.json();
if (!Array.isArray(data)) return null;
// Server emits directories with trailing "/" on the name.
// Filter to dirs only, strip the slash for display.
var out = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e.is_dir) continue;
var nm = String(e.name || '').replace(/\/$/, '');
if (!nm) continue;
if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue;
out.push({ name: nm, url: e.url || ('/' + projectURL + '/archive/' + encodeURIComponent(nm) + '/') });
}
out.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
return out;
} catch (e) {
return null;
}
}
// ── Bootstrap ──────────────────────────────────────────────────────────── // ── Bootstrap ────────────────────────────────────────────────────────────
async function init() { async function init() {
if (detectMode() === 'project') {
await renderProjectMode();
return;
}
await initPicker();
}
async function initPicker() {
loadGroups(); loadGroups();
urlRestore(); urlRestore();
@ -669,6 +823,9 @@
saveGroup: saveGroup, saveGroup: saveGroup,
openSelectedVisible: openSelectedVisible, openSelectedVisible: openSelectedVisible,
dismissWarning: dismissWarning, dismissWarning: dismissWarning,
// Project-mode entry points (also tested directly).
detectMode: detectMode,
renderProjectMode: renderProjectMode,
// Test-only: override the navigation function (avoids the messy // Test-only: override the navigation function (avoids the messy
// browser-locked-down state of window.location). // browser-locked-down state of window.location).
_setNavigate: function(fn) { navigate = fn; } _setNavigate: function(fn) { navigate = fn; }

View file

@ -31,7 +31,9 @@
</div> </div>
</header> </header>
<main class="landing-main"> <main id="landingMain" class="landing-main">
<!-- Picker mode (deployment root /). Project picker + groups. -->
<div id="pickerView">
<!-- 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,6 +92,67 @@
<div class="project-list-loading">Loading projects…</div> <div class="project-list-loading">Loading projects…</div>
</div> </div>
</div> </div>
</div><!-- /pickerView -->
<!-- Project mode (/<project>). Stage cards + MDL section. Shown
by landing.js when location.pathname is a single segment. -->
<div id="projectView" class="hidden">
<h1 id="projectTitle" class="project-title">
<span id="projectName"></span>
<span class="project-title__subtle">— project workspace</span>
</h1>
<p class="lead">Pick a lifecycle stage, or browse all files.</p>
<div class="stages">
<a class="stage-card" id="stageArchive">
<h3>Archive</h3>
<p>Permanent record of issued and received transmittals, organized by counterparty.</p>
</a>
<a class="stage-card" id="stageWorking">
<h3>Working</h3>
<p>Per-user drafting workspace. Your folder is private by default; you can grant access by editing its <code>.zddc</code> file.</p>
</a>
<a class="stage-card" id="stageStaging">
<h3>Staging</h3>
<p>Outbound transmittals being prepared for issue.</p>
</a>
<a class="stage-card" id="stageReviewing">
<h3>Reviewing</h3>
<p>Pending review responses — inbound submittals paired with their in-progress drafts.</p>
</a>
</div>
<p><a id="browseAllLink" class="browse-link">Browse all files →</a></p>
<h2>Master Deliverables List (MDL)</h2>
<p>Each counterparty in the archive has an MDL — an editable
table of expected deliverables. The default columns mirror
the ZDDC tracking-number components (<code>originator</code>,
<code>phase</code>, <code>project</code>, <code>area</code>,
<code>discipline</code>, <code>type</code>,
<code>sequence</code>, <code>suffix</code>) plus
<code>title</code>, <code>plannedRevision</code>,
<code>plannedDate</code>, <code>status</code>, and
<code>owner</code>.</p>
<p><strong>To edit the MDL for any party:</strong></p>
<ol>
<li>Open the project archive: <a id="archiveBrowseLink"></a></li>
<li>Click into a party's folder (e.g. <code>PartyA</code>)</li>
<li>Click <code>mdl</code> inside the party folder</li>
</ol>
<div id="partyListSection">
<!-- Populated by JS when archive/ enumeration succeeds.
Either a "direct links" block with <ul.party-list> or a
"no parties yet" fallback. -->
</div>
<p>To customize the columns or schema for a specific party, drop
a <code>table.yaml</code> and <code>form.yaml</code> into
<code>archive/&lt;party&gt;/mdl/</code>. Operator-supplied
files override the embedded defaults entirely.</p>
</div><!-- /projectView -->
</main> </main>
<!-- Help Panel --> <!-- Help Panel -->

View file

@ -1,4 +1,6 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
const HTML_PATH = path.resolve('landing/dist/index.html'); const HTML_PATH = path.resolve('landing/dist/index.html');
@ -227,6 +229,93 @@ test.describe('Landing page', () => {
expect(navTo).not.toContain('?projects='); expect(navTo).not.toContain('?projects=');
}); });
test('project mode: detectMode classifies URLs correctly', async ({ page }) => {
await loadLandingWithProjects(page, []);
const result = await page.evaluate(() => ({
picker: window.LandingApp.detectMode === undefined ? 'no-fn' : null,
}));
// Sanity: the project-mode entry points are exposed.
expect(result.picker).toBeNull();
});
});
// project-mode tests need a real http(s) origin so location.pathname can be
// /<project>. Spin up a tiny in-process server that serves the same
// landing HTML at any path.
test.describe('Landing project mode', () => {
let server;
let baseUrl;
test.beforeAll(async () => {
const html = fs.readFileSync(HTML_PATH, 'utf8');
server = http.createServer((req, res) => {
// The page itself fetches /<project>/archive/ for the
// party listing. Stub that with a small JSON listing so the
// direct-link section renders. Anything else returns the
// landing HTML.
if (req.url === '/Project-1/archive/' &&
(req.headers.accept || '').includes('json')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([
{ name: 'PartyA/', is_dir: true, size: 0, url: '/Project-1/Archive/PartyA/' },
{ name: 'PartyB/', is_dir: true, size: 0, url: '/Project-1/Archive/PartyB/' },
{ name: '.hidden/', is_dir: true, size: 0, url: '/Project-1/Archive/.hidden/' },
]));
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
test.afterAll(async () => {
if (server) await new Promise(r => server.close(r));
});
test('renders project workspace at /<project>', async ({ page }) => {
await page.goto(`${baseUrl}/Project-1`, { waitUntil: 'load' });
await page.waitForSelector('#projectView:not(.hidden)', { timeout: 5000 });
// Project name surfaces in the H1 + the document title.
await expect(page.locator('#projectName')).toHaveText('Project-1');
expect(await page.title()).toBe('Project-1 — ZDDC');
// Picker view is hidden.
await expect(page.locator('#pickerView')).toBeHidden();
// Four stage cards with the expected hrefs.
const stageHrefs = await page.evaluate(() => ({
archive: document.getElementById('stageArchive').getAttribute('href'),
working: document.getElementById('stageWorking').getAttribute('href'),
staging: document.getElementById('stageStaging').getAttribute('href'),
reviewing: document.getElementById('stageReviewing').getAttribute('href'),
}));
expect(stageHrefs).toEqual({
archive: '/Project-1/archive',
working: '/Project-1/working',
staging: '/Project-1/staging',
reviewing: '/Project-1/reviewing',
});
});
test('lists existing parties as direct MDL links', async ({ page }) => {
await page.goto(`${baseUrl}/Project-1`, { waitUntil: 'load' });
// Wait for the async party fetch to populate.
await page.waitForSelector('.party-list a', { timeout: 5000 });
const links = await page.locator('.party-list a').allTextContents();
expect(links.sort()).toEqual(['PartyA MDL →', 'PartyB MDL →']);
// Hidden dot-prefixed entry was filtered out.
const hrefs = await page.evaluate(() =>
[...document.querySelectorAll('.party-list a')].map(a => a.getAttribute('href'))
);
expect(hrefs).toContain('/Project-1/Archive/PartyA/mdl/');
expect(hrefs).toContain('/Project-1/Archive/PartyB/mdl/');
expect(hrefs.some(h => h.includes('.hidden'))).toBe(false);
});
test('legacy presets are migrated to groups on first load', async ({ page }) => { test('legacy presets are migrated to groups on first load', async ({ page }) => {
await loadLandingWithProjects(page, SAMPLE_PROJECTS); await loadLandingWithProjects(page, SAMPLE_PROJECTS);
// Seed legacy presets and clear the new key. // Seed legacy presets and clear the new key.

View file

@ -960,17 +960,22 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
} }
} }
} }
// Project root (depth-1 dir, no trailing slash) gets a synthetic // Project root (depth-1 dir, no trailing slash) serves the
// landing page with the four lifecycle-stage cards + MDL // landing tool, which detects mode='project' from
// instructions. With trailing slash, the project falls through to // location.pathname and renders the lifecycle-stage cards +
// the regular browse listing. // MDL section. Same single-file SPA as the deployment-root
// project picker — one tool, two URL shapes. With trailing
// slash, the project falls through to the regular browse
// listing.
if !strings.HasSuffix(urlPath, "/") && if !strings.HasSuffix(urlPath, "/") &&
(r.Method == http.MethodGet || r.Method == http.MethodHead) && (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
handler.IsProjectRootURL(urlPath) { handler.IsProjectRootURL(urlPath) {
project := strings.TrimPrefix(urlPath, "/") if appsSrv != nil {
handler.ServeProjectLanding(cfg, w, r, project) chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
appsSrv.Serve(w, r, "landing", chain, absPath)
return return
} }
}
if !strings.HasSuffix(urlPath, "/") { if !strings.HasSuffix(urlPath, "/") {
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
return return

View file

@ -1,30 +1,25 @@
package handler package handler
import ( import (
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings" "strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
// IsProjectRootURL reports whether urlPath names a project root — // IsProjectRootURL reports whether urlPath names a project root —
// exactly one path segment, no trailing slash. Used by the dispatcher // exactly one path segment, no trailing slash. Used by the dispatcher
// to route /<project> (with no trailing slash) to ServeProjectLanding // to route /<project> (no trailing slash) to the landing tool's
// instead of 301'ing to the slash form. // project-workspace mode rather than the historical 301-to-slash.
// //
// Examples: // Examples:
// //
// "/Project-1" → true // "/Project-1" → true
// "/Project-1/" → false (trailing slash → directory listing) // "/Project-1/" → false (trailing slash → directory listing)
// "/Project-1/x" → false (deeper) // "/Project-1/x" → false (deeper)
// "/" → false (deployment root, served by landing tool) // "/" → false (deployment root)
// "" → false // "" → false
//
// The actual page rendering lives client-side in landing/js/landing.js
// (mode='project'); this server-side predicate only decides where to
// route the request.
func IsProjectRootURL(urlPath string) bool { func IsProjectRootURL(urlPath string) bool {
if urlPath == "" || urlPath == "/" { if urlPath == "" || urlPath == "/" {
return false return false
@ -35,263 +30,3 @@ func IsProjectRootURL(urlPath string) bool {
trimmed := strings.TrimPrefix(urlPath, "/") trimmed := strings.TrimPrefix(urlPath, "/")
return !strings.Contains(trimmed, "/") return !strings.Contains(trimmed, "/")
} }
// projectLandingTmpl is the inline template for /<project> (no slash).
// It's a simple navigation page — four canonical-stage cards, a link
// to the full file browser, and instructions for editing the MDL,
// listing any parties already present in archive/.
var projectLandingTmpl = template.Must(template.New("projectLanding").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Project}} ZDDC</title>
<style>
:root {
--primary: #2a5a8a; --primary-hover: #1d4060;
--bg: #ffffff; --bg-secondary: #f8f9fa; --bg-hover: #f0f4f8;
--text: #212529; --text-muted: #6c757d;
--border: #dee2e6; --radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--primary: #5fa8e0; --primary-hover: #74b6e6;
--bg: #1e1e1e; --bg-secondary: #252526; --bg-hover: #2d2d30;
--text: #d4d4d4; --text-muted: #9d9d9d;
--border: #3e3e42;
}
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--text); background: var(--bg-secondary);
line-height: 1.5;
}
header.app-header {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 1rem; background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
header .app-header__logo {
width: 26px; height: 26px; display: inline-block; vertical-align: middle;
margin-right: 0.5rem;
}
header .app-header__logo-link {
display: inline-flex; align-items: center; text-decoration: none;
border-radius: var(--radius);
transition: opacity 0.15s, box-shadow 0.15s;
}
header .app-header__logo-link:hover .app-header__logo,
header .app-header__logo-link:focus-visible .app-header__logo {
opacity: 0.82;
}
header .app-header__logo-link:focus-visible {
outline: 2px solid var(--primary); outline-offset: 2px;
}
header .title-line { font-size: 1.05rem; font-weight: 600; }
header .crumb { color: var(--text-muted); font-size: 0.85rem; }
main {
max-width: 880px; margin: 2rem auto; padding: 0 1.25rem;
}
h1 { font-size: 1.6rem; margin: 0 0 0.25rem; font-weight: 600; }
h1 .subtle { color: var(--text-muted); font-weight: normal; font-size: 0.9rem; }
h2 {
font-size: 1.1rem; margin: 2.25rem 0 0.5rem;
padding-bottom: 0.3rem; border-bottom: 1px solid var(--border);
font-weight: 600;
}
p { margin: 0.5rem 0 1rem; }
.lead { color: var(--text-muted); margin-bottom: 1.5rem; }
.stages {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.85rem;
margin: 1rem 0 1.5rem;
}
.stage-card {
display: block; padding: 1rem 1.1rem;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); text-decoration: none; color: var(--text);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
}
.stage-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stage-card:active { transform: translateY(1px); }
.stage-card h3 {
margin: 0 0 0.3rem; font-size: 1rem; color: var(--primary);
font-weight: 600;
}
.stage-card p { margin: 0; color: var(--text-muted); font-size: 0.875rem; }
.browse-link {
display: inline-block; margin-top: 0.25rem; color: var(--primary);
text-decoration: none;
}
.browse-link:hover { text-decoration: underline; }
ol { padding-left: 1.5rem; }
ol li { margin-bottom: 0.4rem; }
code {
font-family: ui-monospace, "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
background: var(--bg-secondary); padding: 0.1em 0.35em;
border-radius: 3px; font-size: 0.86em;
}
.party-list { padding-left: 1.5rem; margin: 0.4rem 0 1rem; }
.party-list li { margin-bottom: 0.25rem; }
.party-list a { color: var(--primary); text-decoration: none; }
.party-list a:hover { text-decoration: underline; }
.none-yet { color: var(--text-muted); font-style: italic; }
</style>
</head>
<body>
<header class="app-header">
<div>
<a class="app-header__logo-link" href="/" title="ZDDC home" aria-label="ZDDC home">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
</a>
<span class="title-line">{{.Project}}</span>
</div>
<span class="crumb">ZDDC project workspace</span>
</header>
<main>
<h1>{{.Project}} <span class="subtle"> project workspace</span></h1>
<p class="lead">Pick a lifecycle stage, or browse all files.</p>
<div class="stages">
<a class="stage-card" href="/{{.ProjectURL}}/archive">
<h3>Archive</h3>
<p>Permanent record of issued and received transmittals, organized by counterparty.</p>
</a>
<a class="stage-card" href="/{{.ProjectURL}}/working">
<h3>Working</h3>
<p>Per-user drafting workspace. Your folder is private by default; you can grant access by editing its <code>.zddc</code> file.</p>
</a>
<a class="stage-card" href="/{{.ProjectURL}}/staging">
<h3>Staging</h3>
<p>Outbound transmittals being prepared for issue.</p>
</a>
<a class="stage-card" href="/{{.ProjectURL}}/reviewing">
<h3>Reviewing</h3>
<p>Pending review responses inbound submittals paired with their in-progress drafts.</p>
</a>
</div>
<p><a class="browse-link" href="/{{.ProjectURL}}/">Browse all files </a></p>
<h2>Master Deliverables List (MDL)</h2>
<p>Each counterparty in the archive has an MDL an editable table
of expected deliverables. The default columns mirror the ZDDC
tracking-number components (<code>originator</code>, <code>phase</code>,
<code>project</code>, <code>area</code>, <code>discipline</code>,
<code>type</code>, <code>sequence</code>, <code>suffix</code>) plus
<code>title</code>, <code>plannedRevision</code>,
<code>plannedDate</code>, <code>status</code>, and <code>owner</code>.</p>
<p><strong>To edit the MDL for any party:</strong></p>
<ol>
<li>Open the project archive: <a href="/{{.ProjectURL}}/archive/"><code>/{{.ProjectURL}}/archive/</code></a></li>
<li>Click into a party's folder (e.g. <code>PartyA</code>)</li>
<li>Click <code>mdl</code> inside the party folder</li>
</ol>
{{if .Parties}}
<p><strong>Direct links parties currently in <code>archive/</code>:</strong></p>
<ul class="party-list">
{{range .Parties}}
<li><a href="/{{$.ProjectURL}}/{{$.ArchiveSeg}}/{{.URLName}}/mdl/">{{.DisplayName}} MDL </a></li>
{{end}}
</ul>
{{else}}
<p class="none-yet">No party folders yet. The MDL view auto-renders at
any <code>archive/&lt;party&gt;/mdl/</code> URL, even when the folder
doesn't exist on disk so you can start editing an MDL before any
transmittals have been exchanged.</p>
{{end}}
<p>To customize the columns or schema for a specific party, drop a
<code>table.yaml</code> and <code>form.yaml</code> into
<code>archive/&lt;party&gt;/mdl/</code>. Operator-supplied files
override the embedded defaults entirely.</p>
</main>
</body>
</html>`))
// projectLandingData is the template input.
type projectLandingData struct {
Project string
ProjectURL string // url-escaped Project, for use in href values
ArchiveSeg string // on-disk casing of "archive" (Archive vs archive)
Parties []partyEntry
}
type partyEntry struct {
DisplayName string // on-disk name (e.g. "PartyA")
URLName string // url-escaped variant
}
// ServeProjectLanding renders the project root navigation page at
// /<project> (no trailing slash). Lists the four canonical lifecycle
// stages as cards, links into the full browse view, and provides
// instructions for editing the per-party MDL with direct links to any
// parties already present under archive/.
//
// ACL: the dispatcher already gates this entry by the project's
// .zddc cascade before calling here. No additional check needed.
func ServeProjectLanding(cfg config.Config, w http.ResponseWriter, r *http.Request, project string) {
projectAbs := filepath.Join(cfg.Root, project)
// On-disk casing of archive/ — preserve in URL hrefs so links
// don't bounce through the URL-canonicalisation layer.
archiveSeg, _ := zddc.ResolveCanonical(projectAbs, "archive")
if archiveSeg == "" {
archiveSeg = "archive"
}
// Enumerate parties under archive/<party>/. Failures here are
// non-fatal — the page just renders without the direct-link list.
var parties []partyEntry
if archiveSeg != "" {
archiveAbs := filepath.Join(projectAbs, archiveSeg)
if entries, err := os.ReadDir(archiveAbs); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
continue
}
parties = append(parties, partyEntry{
DisplayName: name,
URLName: url.PathEscape(name),
})
}
sort.Slice(parties, func(i, j int) bool {
return parties[i].DisplayName < parties[j].DisplayName
})
}
}
data := projectLandingData{
Project: project,
ProjectURL: url.PathEscape(project),
ArchiveSeg: url.PathEscape(archiveSeg),
Parties: parties,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") // recomputes party list per hit
if err := projectLandingTmpl.Execute(w, data); err != nil {
// Headers already flushed; nothing to do beyond log.
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

View file

@ -1,15 +1,6 @@
package handler package handler
import ( import "testing"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
func TestIsProjectRootURL(t *testing.T) { func TestIsProjectRootURL(t *testing.T) {
cases := map[string]bool{ cases := map[string]bool{
@ -26,100 +17,3 @@ func TestIsProjectRootURL(t *testing.T) {
} }
} }
} }
func TestServeProjectLanding(t *testing.T) {
root := t.TempDir()
// Need .zddc on disk for the resolver to be happy at the root.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*\": rwcda\n"), 0o644); err != nil {
t.Fatal(err)
}
// Project with two parties under archive/ (PascalCase to exercise
// the case-insensitive archive resolver) and one orphaned dot-prefixed
// dir that should be filtered out of the party list.
for _, sub := range []string{
"Project-1/Archive/PartyA",
"Project-1/Archive/PartyB",
"Project-1/Archive/.hidden",
} {
if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil {
t.Fatal(err)
}
}
cfg := config.Config{Root: root}
t.Run("renders project name + stage cards", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project-1", nil)
rec := httptest.NewRecorder()
ServeProjectLanding(cfg, rec, req, "Project-1")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
body := rec.Body.String()
// Page identifies the project and includes the four stages.
for _, want := range []string{
"Project-1",
"<h3>Archive</h3>",
"<h3>Working</h3>",
"<h3>Staging</h3>",
"<h3>Reviewing</h3>",
"Master Deliverables List",
`href="/Project-1/working"`,
`href="/Project-1/archive/"`,
// Logo wraps to the deployment root — same convention as
// shared/logo.js applies in tools (which would route here
// to /Project-1, the project landing). On the project
// landing itself, "next up" is the deployment root.
`<a class="app-header__logo-link" href="/"`,
`class="app-header__logo"`,
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
})
t.Run("lists existing parties as direct MDL links", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project-1", nil)
rec := httptest.NewRecorder()
ServeProjectLanding(cfg, rec, req, "Project-1")
body := rec.Body.String()
// Both parties surfaced; on-disk casing preserved in the URL.
if !strings.Contains(body, `href="/Project-1/Archive/PartyA/mdl/"`) {
t.Errorf("body missing PartyA MDL link")
}
if !strings.Contains(body, `href="/Project-1/Archive/PartyB/mdl/"`) {
t.Errorf("body missing PartyB MDL link")
}
// Dot-prefixed entries filtered out.
if strings.Contains(body, ".hidden") {
t.Errorf("body should not list .hidden directory")
}
})
t.Run("no parties yet → falls back to generic instruction", func(t *testing.T) {
bare := t.TempDir()
if err := os.WriteFile(filepath.Join(bare, ".zddc"),
[]byte("acl:\n permissions:\n \"*\": rwcda\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(bare, "Fresh"), 0o755); err != nil {
t.Fatal(err)
}
bareCfg := config.Config{Root: bare}
req := httptest.NewRequest(http.MethodGet, "/Fresh", nil)
rec := httptest.NewRecorder()
ServeProjectLanding(bareCfg, rec, req, "Fresh")
body := rec.Body.String()
// Falls through to the "no party folders yet" copy.
if !strings.Contains(body, "No party folders yet") {
t.Errorf("body missing fresh-project fallback copy")
}
})
}