ZDDC/landing/js/landing.js
ZDDC 315d039880 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>
2026-05-10 07:57:30 -05:00

834 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function() {
'use strict';
// ZDDC landing page — project picker.
//
// Two stacked sections:
// 1. Groups (saved bundles of projects) — click row to open, edit, or delete
// 2. Projects (live list from the server) — click row to open one project directly
//
// "Select-mode" (entered via "+ New group" or a group's edit ✏ button) shows
// checkboxes on each project row, a name input, and an action bar with
// Save / Open visible-checked / Cancel. In default (rest) mode there are no
// checkboxes; clicking anything just opens the archive.
//
// Storage: groups persist in localStorage under `zddc_landing_groups` as
// an array of { name: string, projects: string[] }. Old `zddc_landing_presets`
// entries are migrated once on init (project list only — filter/sort state
// from the old preset model is dropped).
// ── State ────────────────────────────────────────────────────────────────
var allProjects = []; // [{name, title, url}] from server
var groups = []; // [{name, projects: [name,...]}] from localStorage
var selected = new Set(); // checked project names (only used in select-mode)
var columnFilters = { pn: '', pt: '' };
var columnFilterASTs = { pn: null, pt: null };
var sortField = 'name'; // 'name' | 'title'
var sortDirection = 'asc';
var loadError = null; // user-facing error string
var loadErrorKind = null; // 'static' | 'auth' | 'non-json' | 'network'
// selectMode === null → default mode (click to open)
// selectMode === { kind: 'create' }
// selectMode === { kind: 'edit', originalName: '...' }
var selectMode = null;
var GROUPS_KEY = 'zddc_landing_groups';
var LEGACY_PRESETS_KEY = 'zddc_landing_presets';
var DEFAULT_SORT_FIELD = 'name';
var DEFAULT_SORT_DIRECTION = 'asc';
// ── URL state ────────────────────────────────────────────────────────────
// Only filters and sort persist in the URL. Selection (`?projects=`) used
// to live here for save-as-preset workflows; with click-to-open + named
// groups it adds noise and isn't shareable in any useful way (groups are
// localStorage-only per user).
function urlSerialize() {
var p = new URLSearchParams();
if (columnFilters.pn) p.set('pn', columnFilters.pn);
if (columnFilters.pt) p.set('pt', columnFilters.pt);
if (sortField !== DEFAULT_SORT_FIELD) p.set('sort', sortField);
if (sortDirection !== DEFAULT_SORT_DIRECTION) p.set('dir', sortDirection);
// Preserve channel selector from existing URL if present.
var v = new URLSearchParams(location.search).get('v');
if (v) p.set('v', v);
var qs = p.toString();
return qs ? '?' + qs : '';
}
function urlPush() {
var qs = urlSerialize();
if (qs === location.search) return;
try {
history.replaceState(null, '', location.pathname + qs);
} catch (e) { /* file:// protocol restrictions */ }
}
function urlRestore() {
var p = new URLSearchParams(location.search);
if (p.has('pn')) {
columnFilters.pn = p.get('pn');
columnFilterASTs.pn = parseFilterAST(columnFilters.pn);
}
if (p.has('pt')) {
columnFilters.pt = p.get('pt');
columnFilterASTs.pt = parseFilterAST(columnFilters.pt);
}
if (p.has('sort')) {
var s = p.get('sort');
if (s === 'name' || s === 'title') sortField = s;
}
if (p.has('dir')) {
var d = p.get('dir');
if (d === 'asc' || d === 'desc') sortDirection = d;
}
}
function parseFilterAST(text) {
if (!text) return null;
try { return zddc.filter.parse(text); } catch (e) { return null; }
}
// ── Server fetch ─────────────────────────────────────────────────────────
async function fetchProjects() {
var base = location.origin + location.pathname.replace(/\/[^\/]*$/, '/');
try {
var resp = await fetch(base, {
headers: { 'Accept': 'application/json' },
cache: 'no-cache',
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var ctype = resp.headers.get('Content-Type') || '';
var body = await resp.text();
var trimmed = body.trim();
var looksLikeJson = trimmed.startsWith('[') || trimmed.startsWith('{');
if (!ctype.toLowerCase().includes('json') && !looksLikeJson) {
console.warn('Project-list endpoint returned non-JSON', {
requested: base,
finalUrl: resp.url,
redirected: resp.redirected,
contentType: ctype,
bodyStart: trimmed.slice(0, 200)
});
if (resp.redirected) {
loadErrorKind = 'auth';
throw new Error("The request was redirected to " + resp.url + ' — likely to an auth/login page. Sign in and reload.');
}
if (/<title>\s*Loading\s+ZDDC/i.test(trimmed) || /<title>\s*Loading\s+Archive/i.test(trimmed)) {
loadErrorKind = 'static';
throw new Error("This deployment doesn't expose a project list. The server is serving static stubs without a zddc-server backend.");
}
loadErrorKind = 'non-json';
throw new Error("The server at " + base + " returned HTML where a JSON project list was expected. Its zddc-server may be too old (no Accept: application/json dispatch on /), a reverse proxy is stripping the header, or the static site at the root has shadowed the API endpoint.");
}
var data = JSON.parse(body);
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
allProjects = data.map(function(p) {
return {
name: String(p.name || ''),
title: String(p.title || ''),
url: String(p.url || '')
};
}).filter(function(p) { return p.name; });
return true;
} catch (e) {
loadError = e.message || String(e);
return false;
}
}
// ── Filter / sort ────────────────────────────────────────────────────────
function visibleProjects() {
var rows = allProjects.slice();
if (columnFilterASTs.pn && columnFilterASTs.pn.length > 0) {
rows = rows.filter(function(r) { return zddc.filter.matches(r.name, columnFilterASTs.pn); });
}
if (columnFilterASTs.pt && columnFilterASTs.pt.length > 0) {
rows = rows.filter(function(r) { return zddc.filter.matches(r.title || '', columnFilterASTs.pt); });
}
rows.sort(function(a, b) {
var av = (a[sortField] || '').toString();
var bv = (b[sortField] || '').toString();
if (sortField === 'title') {
if (!av && bv) return 1;
if (av && !bv) return -1;
}
var cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' });
return cmp * (sortDirection === 'desc' ? -1 : 1);
});
return rows;
}
// ── Rendering ────────────────────────────────────────────────────────────
function render() {
renderActionBar();
renderGroups();
renderProjects();
renderProjectCount();
renderGroupCount();
}
function renderActionBar() {
var bar = document.getElementById('selectActionBar');
var title = document.getElementById('selectModeTitle');
var input = document.getElementById('groupNameInput');
var newBtn = document.getElementById('newGroupBtn');
if (!bar) return;
if (!selectMode) {
bar.classList.add('hidden');
if (newBtn) newBtn.disabled = false;
return;
}
bar.classList.remove('hidden');
if (newBtn) newBtn.disabled = true;
if (selectMode.kind === 'create') {
title.textContent = 'New group:';
if (document.activeElement !== input) input.value = '';
} else {
title.textContent = 'Editing:';
if (document.activeElement !== input) input.value = selectMode.originalName || '';
}
}
function renderGroups() {
var container = document.getElementById('groupsContainer');
if (!container) return;
if (groups.length === 0) {
container.innerHTML = '<div class="groups-empty">No saved groups yet. Use <strong>+ New group</strong> to bundle a set of projects.</div>';
return;
}
var html = '<table class="groups-table">';
html += '<tbody>';
for (var i = 0; i < groups.length; i++) {
var g = groups[i];
var n = escapeHtml(g.name);
var count = g.projects.length;
html += '<tr class="groups-row" data-name="' + n + '" onclick="LandingApp.openGroup(event)">';
html += '<td class="groups-row-name">' + n + '</td>';
html += '<td class="groups-row-count">' + count + ' project' + (count === 1 ? '' : 's') + '</td>';
html += '<td class="groups-row-actions">';
html += '<button class="groups-btn-edit" data-name="' + n + '" onclick="event.stopPropagation(); LandingApp.startEditGroup(event)" title="Edit group">✎</button>';
html += '<button class="groups-btn-delete" data-name="' + n + '" onclick="event.stopPropagation(); LandingApp.deleteGroup(event)" title="Delete group">×</button>';
html += '</td>';
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
}
function renderProjects() {
var container = document.getElementById('projectListContainer');
if (loadError) {
var heading, help;
if (loadErrorKind === 'static') {
heading = 'This server doesn\'t list projects';
help = 'You\'re on a static deployment (Caddy serving stubs) — there\'s no zddc-server backend here to enumerate projects. '
+ 'Open a project directly via its URL (e.g. <code>/&lt;project&gt;/Archive/</code>), or ask whoever sent you this link for the project URL they meant.';
} else if (loadErrorKind === 'auth') {
heading = 'Sign-in required';
help = 'The server bounced this request to an auth page. Sign in there, then reload this URL.';
} else {
heading = 'Couldn\'t load the project list';
help = 'Reload the page to try again. If this keeps happening, the server may be down or your link may be stale.';
}
container.innerHTML =
'<div class="project-list-empty">'
+ '<h3>' + escapeHtml(heading) + '</h3>'
+ '<p>' + escapeHtml(loadError) + '</p>'
+ '<p class="landing-empty-help">' + help + '</p>'
+ '</div>';
return;
}
if (allProjects.length === 0) {
container.innerHTML =
'<div class="project-list-empty">'
+ '<h3>No projects to show</h3>'
+ '<p>Either you don\'t have access to any projects on this server yet, or none have been set up.</p>'
+ '<p class="landing-empty-help">If someone shared this link with you, ask them which project administrator can grant your account access — and double-check that you\'re signed in with the same email they expected.</p>'
+ '</div>';
return;
}
var rows = visibleProjects();
var anyTitles = allProjects.some(function(p) { return p.title; });
var inSelect = !!selectMode;
var visibleSelected = inSelect ? rows.filter(function(r) { return selected.has(r.name); }).length : 0;
var headerCheckedState = !inSelect ? 'unchecked'
: visibleSelected === 0 ? 'unchecked'
: visibleSelected === rows.length ? 'checked' : 'indeterminate';
var html = '<table class="project-table' + (inSelect ? ' is-select-mode' : '') + '">';
html += '<thead>';
html += '<tr class="project-table-headers">';
if (inSelect) {
html += '<th class="project-table-checkbox-col">'
+ '<input type="checkbox" id="headerCheckbox" '
+ (headerCheckedState === 'checked' ? 'checked ' : '')
+ 'onclick="LandingApp.toggleHeaderCheckbox()" '
+ 'title="Check / uncheck all visible projects">'
+ '</th>';
}
html += '<th class="project-table-name-col" data-sort="name" onclick="LandingApp.toggleSort(\'name\')">'
+ 'Project number ' + sortIndicator('name')
+ '</th>';
if (anyTitles) {
html += '<th class="project-table-title-col" data-sort="title" onclick="LandingApp.toggleSort(\'title\')">'
+ 'Title ' + sortIndicator('title')
+ '</th>';
}
html += '</tr>';
html += '<tr class="project-table-filters">';
if (inSelect) html += '<th></th>';
html += '<th><input type="text" class="column-filter ' + (columnFilters.pn ? 'filter-active' : '') + '" '
+ 'data-column="pn" placeholder="filter…" '
+ 'value="' + escapeHtml(columnFilters.pn) + '" '
+ 'oninput="LandingApp.onColumnFilterInput(event)"></th>';
if (anyTitles) {
html += '<th><input type="text" class="column-filter ' + (columnFilters.pt ? 'filter-active' : '') + '" '
+ 'data-column="pt" placeholder="filter…" '
+ 'value="' + escapeHtml(columnFilters.pt) + '" '
+ 'oninput="LandingApp.onColumnFilterInput(event)"></th>';
}
html += '</tr>';
html += '</thead>';
var colspan = (inSelect ? 1 : 0) + 1 + (anyTitles ? 1 : 0);
if (rows.length === 0) {
html += '<tbody><tr><td colspan="' + colspan + '" class="project-table-no-match">'
+ 'No projects match the current filters.'
+ '</td></tr></tbody>';
} else {
html += '<tbody>';
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
var isSel = inSelect && selected.has(r.name);
html += '<tr class="project-table-row' + (isSel ? ' is-selected' : '') + '" '
+ 'data-name="' + escapeHtml(r.name) + '" onclick="LandingApp.onProjectRowClick(event)">';
if (inSelect) {
html += '<td class="project-table-checkbox-col"><input type="checkbox" value="' + escapeHtml(r.name) + '"'
+ (isSel ? ' checked' : '')
+ ' onclick="event.stopPropagation(); LandingApp.toggleByCheckbox(event)"></td>';
}
html += '<td class="project-table-name-col">' + escapeHtml(r.name) + '</td>';
if (anyTitles) {
html += '<td class="project-table-title-col">' + (r.title ? escapeHtml(r.title) : '<span class="project-table-no-title">—</span>') + '</td>';
}
html += '</tr>';
}
html += '</tbody>';
}
html += '</table>';
container.innerHTML = html;
var headerCb = document.getElementById('headerCheckbox');
if (headerCb) headerCb.indeterminate = headerCheckedState === 'indeterminate';
}
function sortIndicator(field) {
if (sortField !== field) return '<span class="sort-indicator">↕</span>';
return '<span class="sort-indicator active">' + (sortDirection === 'asc' ? '▲' : '▼') + '</span>';
}
function renderProjectCount() {
var el = document.getElementById('projectCount');
if (!el) return;
if (loadError || allProjects.length === 0) { el.textContent = ''; return; }
var rows = visibleProjects();
var base = rows.length === allProjects.length
? '(' + allProjects.length + ')'
: '(' + rows.length + ' of ' + allProjects.length + ')';
if (selectMode) {
base = base + ' — ' + selected.size + ' checked';
}
el.textContent = base;
}
function renderGroupCount() {
var el = document.getElementById('groupCount');
if (!el) return;
el.textContent = groups.length === 0 ? '' : '(' + groups.length + ')';
}
// ── Events / actions ─────────────────────────────────────────────────────
function toggleSort(field) {
if (sortField === field) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDirection = 'asc';
}
urlPush();
render();
}
function onColumnFilterInput(e) {
var col = e.target.getAttribute('data-column');
var val = e.target.value;
columnFilters[col] = val;
columnFilterASTs[col] = parseFilterAST(val);
urlPush();
renderProjects();
renderProjectCount();
var sel = document.querySelector('.column-filter[data-column="' + col + '"]');
if (sel) {
sel.focus();
sel.setSelectionRange(sel.value.length, sel.value.length);
}
}
function onProjectRowClick(e) {
var row = e.target.closest('.project-table-row');
if (!row) return;
var name = row.getAttribute('data-name');
if (!name) return;
if (selectMode) {
// In select-mode the row toggles its checkbox.
if (selected.has(name)) selected.delete(name);
else selected.add(name);
render();
} else {
// Default mode: click opens that single project directly.
openArchiveWith([name]);
}
}
function toggleByCheckbox(e) {
if (!selectMode) return;
var cb = e.target;
var name = cb.value;
if (cb.checked) selected.add(name);
else selected.delete(name);
render();
}
function toggleHeaderCheckbox() {
if (!selectMode) return;
var cb = document.getElementById('headerCheckbox');
if (!cb) return;
var rows = visibleProjects();
if (cb.checked) rows.forEach(function(r) { selected.add(r.name); });
else rows.forEach(function(r) { selected.delete(r.name); });
render();
}
// Navigation hook — tests replace this via LandingApp._setNavigate.
// (Patching window.location.href is unreliable in modern engines.)
var navigate = function(url) { location.href = url; };
function openArchiveWith(names) {
if (!names || names.length === 0) return;
var base = location.pathname.replace(/\/[^\/]*$/, '/');
var v = new URLSearchParams(location.search).get('v');
if (names.length === 1) {
// Single project → canonical project-subtree URL so the user
// can edit the address bar to swap archive.html for
// working/, staging/, reviewing/, etc. zddc-server's
// availability.go auto-serves the right tool at each.
// Multi-project (the `else` branch) keeps the ?projects=
// form because there's no single subtree root.
var url = base + encodeURIComponent(names[0]) + '/archive.html';
if (v) url += '?v=' + encodeURIComponent(v);
navigate(url);
return;
}
var params = ['projects=' + names.map(encodeURIComponent).join(',')];
if (v) params.push('v=' + encodeURIComponent(v));
navigate(base + 'archive.html?' + params.join('&'));
}
function openGroup(e) {
var row = e.target.closest('.groups-row');
if (!row) return;
var name = row.getAttribute('data-name');
var g = groups.find(function(x) { return x.name === name; });
if (!g) return;
// Drop projects the user no longer has access to (server-side ACL may
// have changed since the group was saved).
var accessible = new Set(allProjects.map(function(p) { return p.name; }));
var openable = g.projects.filter(function(p) { return accessible.has(p); });
if (openable.length === 0) {
showWarning('Group "' + name + '" has no projects you currently have access to.');
return;
}
if (openable.length < g.projects.length) {
// Open with what we can; warn but don't block.
console.warn('Skipping inaccessible projects in group', name, g.projects.filter(function(p) { return !accessible.has(p); }));
}
openArchiveWith(openable);
}
function dismissWarning() {
var el = document.getElementById('accessWarningBanner');
if (el) el.classList.add('hidden');
}
function showWarning(message) {
var el = document.getElementById('accessWarningBanner');
var txt = document.getElementById('accessWarningText');
if (!el || !txt) return;
txt.textContent = message;
el.classList.remove('hidden');
}
// ── Select-mode (create / edit groups) ───────────────────────────────────
function startCreateGroup() {
selectMode = { kind: 'create' };
selected = new Set();
render();
var input = document.getElementById('groupNameInput');
if (input) input.focus();
}
function startEditGroup(e) {
var btn = e.target.closest('.groups-btn-edit');
if (!btn) return;
var name = btn.getAttribute('data-name');
var g = groups.find(function(x) { return x.name === name; });
if (!g) return;
selectMode = { kind: 'edit', originalName: g.name };
selected = new Set(g.projects);
render();
var input = document.getElementById('groupNameInput');
if (input) input.focus();
}
function deleteGroup(e) {
var btn = e.target.closest('.groups-btn-delete');
if (!btn) return;
var name = btn.getAttribute('data-name');
if (!confirm('Delete group "' + name + '"?')) return;
groups = groups.filter(function(g) { return g.name !== name; });
persistGroups();
render();
}
function cancelSelect() {
selectMode = null;
selected = new Set();
render();
}
function saveGroup() {
if (!selectMode) return;
var input = document.getElementById('groupNameInput');
if (!input) return;
var name = (input.value || '').trim();
if (!name) {
input.focus();
return;
}
var projects = Array.from(selected).sort();
if (projects.length === 0) {
alert('Select at least one project before saving the group.');
return;
}
if (selectMode.kind === 'create') {
// Reject duplicate names so two groups can't share an identity.
if (groups.some(function(g) { return g.name === name; })) {
alert('A group named "' + name + '" already exists. Pick a different name or edit that group instead.');
return;
}
groups.push({ name: name, projects: projects });
} else {
// Editing: rename if name changed (and the new name doesn't collide).
if (name !== selectMode.originalName && groups.some(function(g) { return g.name === name; })) {
alert('A group named "' + name + '" already exists. Pick a different name.');
return;
}
groups = groups.map(function(g) {
return g.name === selectMode.originalName
? { name: name, projects: projects }
: g;
});
}
persistGroups();
selectMode = null;
selected = new Set();
render();
}
function openSelectedVisible() {
if (!selectMode) return;
// Rule: open only those that are currently visible (filtered in) AND
// checked. Filter-hidden but checked items are intentionally left out.
var visibleNames = new Set(visibleProjects().map(function(r) { return r.name; }));
var openable = Array.from(selected).filter(function(n) { return visibleNames.has(n); });
if (openable.length === 0) {
alert('No checked projects are currently visible. Adjust filters or check more projects to open.');
return;
}
openArchiveWith(openable);
}
// ── Persistence ──────────────────────────────────────────────────────────
function loadGroups() {
var raw;
try { raw = localStorage.getItem(GROUPS_KEY); }
catch (e) { raw = null; }
if (raw) {
try {
var parsed = JSON.parse(raw);
groups = Array.isArray(parsed) ? parsed.filter(isValidGroup) : [];
return;
} catch (e) { /* fall through to legacy */ }
}
// One-shot migration: convert old `zddc_landing_presets` (which carried
// filter+sort state alongside the project list) to plain groups.
try {
var legacy = localStorage.getItem(LEGACY_PRESETS_KEY);
if (!legacy) return;
var legacyParsed = JSON.parse(legacy);
if (!Array.isArray(legacyParsed)) return;
groups = legacyParsed.map(function(p) {
var projects = (p && p.state && Array.isArray(p.state.projects)) ? p.state.projects : [];
return { name: String(p && p.name || ''), projects: projects };
}).filter(isValidGroup);
persistGroups();
} catch (e) { groups = []; }
}
function isValidGroup(g) {
return g && typeof g.name === 'string' && g.name.length > 0
&& Array.isArray(g.projects);
}
function persistGroups() {
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(groups)); }
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 ────────────────────────────────────────────────────────────
async function init() {
if (detectMode() === 'project') {
await renderProjectMode();
return;
}
await initPicker();
}
async function initPicker() {
loadGroups();
urlRestore();
var ok = await fetchProjects();
if (ok) {
// No URL-restored selection in the new model, but warn about
// groups that reference inaccessible projects so the user knows
// why their group is shorter than expected when opened.
var accessibleNames = new Set(allProjects.map(function(p) { return p.name; }));
var ghostlyGroups = groups.filter(function(g) {
return g.projects.some(function(p) { return !accessibleNames.has(p); });
});
if (ghostlyGroups.length > 0) {
console.info('Some saved groups reference projects you no longer have access to; they will open with the accessible subset only.', ghostlyGroups.map(function(g) { return g.name; }));
}
}
render();
// Wire up keyboard shortcuts in the action-bar input: Enter saves,
// Escape cancels.
var input = document.getElementById('groupNameInput');
if (input) {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); saveGroup(); }
else if (e.key === 'Escape') { e.preventDefault(); cancelSelect(); }
});
}
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = String(text == null ? '' : text);
return div.innerHTML;
}
document.addEventListener('DOMContentLoaded', init);
// Public API for inline handlers.
window.LandingApp = {
init: init,
toggleByCheckbox: toggleByCheckbox,
toggleHeaderCheckbox: toggleHeaderCheckbox,
toggleSort: toggleSort,
onColumnFilterInput: onColumnFilterInput,
onProjectRowClick: onProjectRowClick,
openGroup: openGroup,
startCreateGroup: startCreateGroup,
startEditGroup: startEditGroup,
deleteGroup: deleteGroup,
cancelSelect: cancelSelect,
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; }
};
})();