ZDDC/zddc/internal/handler/projecthandler.go
ZDDC 3fa2762c28 fix(zddc-server): project landing logo links to deployment root
The project landing page at /<project> had its own hand-rolled
header with <svg class="logo"> — not the canonical app-header__logo
class, and not loading shared/logo.js. So the logo on that page was
purely decorative while every other tool's logo (in the same beta
build) was wrapped by shared/logo.js into a clickable link to
/<project>. Inconsistent and surprising — clicking the logo from
mdedit/archive/etc. takes you to project landing, but clicking the
logo on project landing did nothing.

Inline the wrap directly in the template (the page is server-
rendered, so it can't lean on shared/logo.js the way bundled tools
do):

  <a class="app-header__logo-link" href="/" title="ZDDC home">
    <svg class="app-header__logo" ...>...</svg>
  </a>

href="/" because "next up" from the project landing is the
deployment root (the project picker / landing tool).

Also rename .logo → .app-header__logo for visual consistency, and
add the matching hover/focus styles inline. The test asserts both
the wrapping anchor and the canonical class name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:46:59 -05:00

297 lines
11 KiB
Go

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 .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)
}
}