diff --git a/landing/css/landing.css b/landing/css/landing.css index 0c3dc2b..968bb73 100644 --- a/landing/css/landing.css +++ b/landing/css/landing.css @@ -342,3 +342,125 @@ body { text-align: center; 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; +} diff --git a/landing/js/landing.js b/landing/js/landing.js index a2e9951..cd22446 100644 --- a/landing/js/landing.js +++ b/landing/js/landing.js @@ -612,9 +612,163 @@ catch (e) { /* private mode / quota */ } } + // ── Project mode ───────────────────────────────────────────────────────── + // + // The same landing tool serves at / as the project-workspace + // page. Mode is determined from location.pathname: + // + // / → 'picker' (existing behavior) + // / → '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 //?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 = '/' + escapeHtml(project) + '/archive/'; + } + + // Fetch party list. Best-effort — failures render the + // no-parties-yet fallback. We try //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 = + '

No party folders yet. The MDL view auto-renders at any ' + + 'archive/<party>/mdl/ URL, even when the folder doesn\'t exist on ' + + 'disk — so you can start editing an MDL before any transmittals have been exchanged.

'; + return; + } + var html = '

Direct links — parties currently in archive/:

' + + '
    '; + for (var j = 0; j < parties.length; j++) { + var name = parties[j].name; + var url = parties[j].url; // server-provided absolute URL + html += '
  • ' + escapeHtml(name) + ' MDL →
  • '; + } + html += '
'; + 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 ──────────────────────────────────────────────────────────── async function init() { + if (detectMode() === 'project') { + await renderProjectMode(); + return; + } + await initPicker(); + } + + async function initPicker() { loadGroups(); urlRestore(); @@ -669,6 +823,9 @@ saveGroup: saveGroup, openSelectedVisible: openSelectedVisible, dismissWarning: dismissWarning, + // Project-mode entry points (also tested directly). + detectMode: detectMode, + renderProjectMode: renderProjectMode, // Test-only: override the navigation function (avoids the messy // browser-locked-down state of window.location). _setNavigate: function(fn) { navigate = fn; } diff --git a/landing/template.html b/landing/template.html index faa09fb..3e7a588 100644 --- a/landing/template.html +++ b/landing/template.html @@ -31,7 +31,9 @@ -
+
+ +

Welcome to the ZDDC Archive

@@ -90,6 +92,67 @@
Loading projects…
+ + + +
diff --git a/tests/landing.spec.js b/tests/landing.spec.js index ac0f5dc..033e9a6 100644 --- a/tests/landing.spec.js +++ b/tests/landing.spec.js @@ -1,4 +1,6 @@ import { test, expect } from '@playwright/test'; +import * as http from 'http'; +import * as fs from 'fs'; import * as path from 'path'; const HTML_PATH = path.resolve('landing/dist/index.html'); @@ -227,6 +229,93 @@ test.describe('Landing page', () => { 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 +// /. 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 //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 /', 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 }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); // Seed legacy presets and clear the new key. diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index f914966..d5a34f1 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -960,16 +960,21 @@ 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. + // Project root (depth-1 dir, no trailing slash) serves the + // landing tool, which detects mode='project' from + // location.pathname and renders the lifecycle-stage cards + + // 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, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && handler.IsProjectRootURL(urlPath) { - project := strings.TrimPrefix(urlPath, "/") - handler.ServeProjectLanding(cfg, w, r, project) - return + if appsSrv != nil { + chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) + appsSrv.Serve(w, r, "landing", chain, absPath) + return + } } if !strings.HasSuffix(urlPath, "/") { http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) diff --git a/zddc/internal/handler/projecthandler.go b/zddc/internal/handler/projecthandler.go index 0eebf8a..82c2768 100644 --- a/zddc/internal/handler/projecthandler.go +++ b/zddc/internal/handler/projecthandler.go @@ -1,30 +1,25 @@ 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 / (with no trailing slash) to ServeProjectLanding -// instead of 301'ing to the slash form. +// to route / (no trailing slash) to the landing tool's +// project-workspace mode rather than the historical 301-to-slash. // // Examples: // // "/Project-1" → true // "/Project-1/" → false (trailing slash → directory listing) // "/Project-1/x" → false (deeper) -// "/" → false (deployment root, served by landing tool) +// "/" → false (deployment root) // "" → 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 { if urlPath == "" || urlPath == "/" { return false @@ -35,263 +30,3 @@ func IsProjectRootURL(urlPath string) bool { trimmed := strings.TrimPrefix(urlPath, "/") return !strings.Contains(trimmed, "/") } - -// projectLandingTmpl is the inline template for / (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(` - - - - -{{.Project}} — ZDDC - - - -
-
- - - - {{.Project}} -
- ZDDC project workspace -
- -
-

{{.Project}} — project workspace

-

Pick a lifecycle stage, or browse all files.

- - - -

Browse all files →

- -

Master Deliverables List (MDL)

-

Each counterparty in the archive has an MDL — an editable table - of expected deliverables. The default columns mirror the ZDDC - tracking-number components (originator, phase, - project, area, discipline, - type, sequence, suffix) plus - title, plannedRevision, - plannedDate, status, and owner.

- -

To edit the MDL for any party:

-
    -
  1. Open the project archive: /{{.ProjectURL}}/archive/
  2. -
  3. Click into a party's folder (e.g. PartyA)
  4. -
  5. Click mdl inside the party folder
  6. -
- - {{if .Parties}} -

Direct links — parties currently in archive/:

- - {{else}} -

No party folders yet. The MDL view auto-renders at - any archive/<party>/mdl/ URL, even when the folder - doesn't exist on disk — so you can start editing an MDL before any - transmittals have been exchanged.

- {{end}} - -

To customize the columns or schema for a specific party, drop a - table.yaml and form.yaml into - archive/<party>/mdl/. Operator-supplied files - override the embedded defaults entirely.

-
- -`)) - -// 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 -// / (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//. 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) - } -} diff --git a/zddc/internal/handler/projecthandler_test.go b/zddc/internal/handler/projecthandler_test.go index 5448797..245061c 100644 --- a/zddc/internal/handler/projecthandler_test.go +++ b/zddc/internal/handler/projecthandler_test.go @@ -1,15 +1,6 @@ package handler -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "codeberg.org/VARASYS/ZDDC/zddc/internal/config" -) +import "testing" func TestIsProjectRootURL(t *testing.T) { 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", - "

Archive

", - "

Working

", - "

Staging

", - "

Reviewing

", - "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. - `