feat(zddc-server): synthetic project landing page at /<project>

GET /<project> (no trailing slash) used to 301 to /<project>/ which
served the browse listing. Now it serves a small server-rendered
landing page with:

  - Four lifecycle-stage cards (archive/working/staging/reviewing)
    linking to the no-slash form of each canonical folder, so each
    card opens its default tool (archive view, mdedit sandboxed to
    working/, transmittal at staging/, mdedit at reviewing/).
  - A "Browse all files" link to the slash form for the generic
    file tree.
  - A "Master Deliverables List" section with step-by-step
    instructions for editing any party's MDL plus direct links to
    the MDL of each party already present under archive/ (sorted,
    case-preserved). Falls back to a friendly "no parties yet"
    message when the archive is fresh.

Trailing-slash form (/<project>/) is unchanged — still 200 +
embedded browse.html. The slash-vs-no-slash convention now extends
all the way up the URL tree:

  /                       → landing tool (project picker)
  /<project>              → project landing (this commit)
  /<project>/             → browse
  /<project>/working      → mdedit
  /<project>/working/     → browse
  ... etc.

Implementation:
  - new internal/handler/projecthandler.go — IsProjectRootURL
    predicate + ServeProjectLanding rendering an inlined html/template.
    Page styles are inline; tokens mirror shared/base.css and
    auto-flip on prefers-color-scheme: dark.
  - dispatcher in cmd/zddc-server/main.go: at the IsDir branch's
    no-slash fork, intercept depth-1 single-segment URLs before
    the historical 301. Other depths still 301 unchanged.

Tests:
  - internal/handler/projecthandler_test.go (4 cases): predicate
    coverage; landing page renders project name + four stage cards;
    on-disk parties surface as MDL links with case preserved; fresh
    project falls back to the no-parties-yet copy.
  - cmd/zddc-server/main_test.go TestDispatchSlashRouting: the
    "project root no-slash → 301" case becomes "→ landing (200)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-10 07:26:21 -05:00
parent e51d9fe908
commit 6145bb0c87
4 changed files with 415 additions and 1 deletions

View file

@ -960,6 +960,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
} }
} }
} }
// Project root (depth-1 dir, no trailing slash) gets a synthetic
// landing page with the four lifecycle-stage cards + MDL
// instructions. With trailing slash, the project falls through to
// the regular browse listing.
if !strings.HasSuffix(urlPath, "/") &&
(r.Method == http.MethodGet || r.Method == http.MethodHead) &&
handler.IsProjectRootURL(urlPath) {
project := strings.TrimPrefix(urlPath, "/")
handler.ServeProjectLanding(cfg, w, r, project)
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

@ -441,7 +441,8 @@ func TestDispatchSlashRouting(t *testing.T) {
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""}, {"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""}, {"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""},
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""}, {"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
{"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false, ""}, // Project root no-slash → synthetic landing page (handler.ServeProjectLanding).
{"project root no-slash → landing", "/Project", http.StatusOK, true, ""},
} }
for _, tc := range cases { for _, tc := range cases {

View file

@ -0,0 +1,283 @@
package handler
import (
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// IsProjectRootURL reports whether urlPath names a project root —
// exactly one path segment, no trailing slash. Used by the dispatcher
// to route /<project> (with no trailing slash) to ServeProjectLanding
// instead of 301'ing to the slash form.
//
// Examples:
//
// "/Project-1" → true
// "/Project-1/" → false (trailing slash → directory listing)
// "/Project-1/x" → false (deeper)
// "/" → false (deployment root, served by landing tool)
// "" → false
func IsProjectRootURL(urlPath string) bool {
if urlPath == "" || urlPath == "/" {
return false
}
if strings.HasSuffix(urlPath, "/") {
return false
}
trimmed := strings.TrimPrefix(urlPath, "/")
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 .logo {
width: 26px; height: 26px; display: inline-block; vertical-align: middle;
margin-right: 0.5rem;
}
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>
<svg class="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>
<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

@ -0,0 +1,119 @@
package handler
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
func TestIsProjectRootURL(t *testing.T) {
cases := map[string]bool{
"/Project-1": true,
"/Project_2": true,
"/Project-1/": false, // trailing slash
"/Project-1/x": false, // deeper
"/": false, // deployment root
"": false,
}
for path, want := range cases {
if got := IsProjectRootURL(path); got != want {
t.Errorf("IsProjectRootURL(%q) = %v, want %v", path, got, want)
}
}
}
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/"`,
} {
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")
}
})
}