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:
parent
e51d9fe908
commit
6145bb0c87
4 changed files with 415 additions and 1 deletions
|
|
@ -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, "/") {
|
||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -441,7 +441,8 @@ func TestDispatchSlashRouting(t *testing.T) {
|
|||
{"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 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 {
|
||||
|
|
|
|||
283
zddc/internal/handler/projecthandler.go
Normal file
283
zddc/internal/handler/projecthandler.go
Normal 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/<party>/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/<party>/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)
|
||||
}
|
||||
}
|
||||
119
zddc/internal/handler/projecthandler_test.go
Normal file
119
zddc/internal/handler/projecthandler_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue