ZDDC/landing/js/landing.js
ZDDC c95f07966d feat(tools,build): in-flight HTML-tool reworks and build-infra updates
Bundles a stretch of in-progress work across the SPA tools so the
tree returns to a coherent shippable state ahead of cutting a new
zddc-server stable image:

- landing: substantial rework of the project picker (sortable/filterable
  table, presets refactor, ?projects= filter, ?v= channel propagation,
  loading/error states)
- archive: presets cleanup, source.js refactor, filtering/url-state
  alignment with the landing page
- mdedit: file-system module split, resizer, file-tree improvements,
  base/toc styling tweaks
- transmittal/classifier: small template touch-ups for shared chrome
- shared: build-lib.sh helpers, new favicon.svg
- bootstrap, build.sh: pick up the channel-aware install/track zip
  generation
- tests: new landing.spec.js, expanded archive/mdedit/build-label specs
- docs: CLAUDE.md picks up the zddc-server section and freshens the
  alpha-build exception note
- regenerated artifacts: install.zip, track-{alpha,beta,stable}.zip,
  *_alpha.html — these are produced by `sh build.sh` and per project
  convention are committed alongside the source changes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:52:27 -05:00

632 lines
27 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.
//
// Pulls the ACL-filtered project list from zddc-server (GET /
// Accept: application/json → ProjectInfo[]), renders a sortable/filterable
// table, persists view state in the URL (so links are shareable), and lets
// the user save named presets in localStorage. Pressing "Open Archive"
// navigates to archive.html?projects=<selected>.
// ── State ────────────────────────────────────────────────────────────────
var allProjects = []; // [{name, title, url}] from server
var selected = new Set(); // selected project names
var columnFilters = { pn: '', pt: '' };
var columnFilterASTs = { pn: null, pt: null };
var sortField = 'name'; // 'name' | 'title'
var sortDirection = 'asc';
var presets = []; // [{name, state}] from localStorage
var loadError = null; // user-facing error string
var loadErrorKind = null; // 'static' | 'auth' | 'non-json' | 'network'
var presetSavingMode = false; // when true, dropdown shows naming input
var PRESETS_KEY = 'zddc_landing_presets';
var DEFAULT_SORT_FIELD = 'name';
var DEFAULT_SORT_DIRECTION = 'asc';
// ── URL state ────────────────────────────────────────────────────────────
function urlSerialize() {
var p = new URLSearchParams();
if (selected.size > 0) {
p.set('projects', Array.from(selected).sort().join(','));
}
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('projects')) {
var names = p.get('projects').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
selected = new Set(names);
}
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);
// Read the body as text first so we can give a useful error when
// the server returns HTML (zddc-server too old to honor
// Accept: application/json on /, or a proxy in the way that
// strips the header). resp.json() in that case throws an opaque
// "Unexpected token <" SyntaxError.
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.');
}
// Heuristic: the level-2 bootstrap stub identifies itself with
// its loading title. When the server returns it for our JSON
// request, we're on a plain static deployment (no zddc-server
// backend with the project-list API).
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();
// Empty titles sort last regardless of direction.
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() {
renderTable();
renderPresetMenu();
renderSelectionSummary();
renderProjectCount();
}
function renderTable() {
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 visibleSelected = rows.filter(function(r) { return selected.has(r.name); }).length;
var headerCheckedState = visibleSelected === 0 ? 'unchecked'
: visibleSelected === rows.length ? 'checked' : 'indeterminate';
var html = '<table class="project-table">';
html += '<thead>';
html += '<tr class="project-table-headers">';
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">';
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>';
if (rows.length === 0) {
html += '<tbody><tr><td colspan="' + (anyTitles ? 3 : 2) + '" 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 checked = selected.has(r.name) ? ' checked' : '';
html += '<tr class="project-table-row' + (selected.has(r.name) ? ' is-selected' : '') + '" data-name="' + escapeHtml(r.name) + '" onclick="LandingApp.toggleRow(event)">';
html += '<td class="project-table-checkbox-col"><input type="checkbox" value="' + escapeHtml(r.name) + '"' + 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 renderPresetMenu() {
var menu = document.getElementById('openArchiveMenu');
if (!menu) return;
// Each row's primary click action is "apply preset and open the archive"
// — the most common path. The smaller "Load" button on the right just
// applies the preset to the page so the user can edit the selection
// before opening. × deletes.
var listHtml = presets.length === 0
? '<div class="preset-menu-empty"><i>No saved presets</i></div>'
: presets.map(function(p) {
var n = escapeHtml(p.name);
return '<div class="preset-menu-item" data-action="apply-open" data-name="' + n + '" title="Apply preset and open archive">'
+ '<span class="preset-menu-item-name">' + n + '</span>'
+ '<button class="preset-load-btn" data-action="apply-stay" data-name="' + n + '" title="Load preset (stay on this page)">Load</button>'
+ '<button class="preset-delete-btn" data-action="delete" data-name="' + n + '" title="Delete preset">&times;</button>'
+ '</div>';
}).join('');
var footerHtml;
if (presetSavingMode) {
footerHtml = '<div class="preset-menu-saving">'
+ '<input type="text" id="presetNameInput" class="preset-name-input" placeholder="Preset name" autoFocus>'
+ '<button class="btn btn-primary btn-sm" data-action="confirm-save">Save</button>'
+ '<button class="btn btn-secondary btn-sm" data-action="cancel-save">Cancel</button>'
+ '</div>';
} else {
var anySelectedOrFiltered = selected.size > 0 || columnFilters.pn || columnFilters.pt
|| sortField !== DEFAULT_SORT_FIELD || sortDirection !== DEFAULT_SORT_DIRECTION;
footerHtml = '<div class="preset-menu-actions">'
+ '<button class="btn btn-primary btn-sm" '
+ (anySelectedOrFiltered ? '' : 'disabled ')
+ 'data-action="start-save">Save current as preset…</button>'
+ '</div>';
}
menu.innerHTML = '<div class="preset-menu-header">Saved presets</div>'
+ '<div class="preset-menu-list">' + listHtml + '</div>'
+ '<div class="preset-menu-divider"></div>'
+ footerHtml;
}
function renderSelectionSummary() {
var el = document.getElementById('selectionSummary');
var btn = document.getElementById('openArchiveBtn');
if (!el || !btn) return;
if (selected.size === 0) {
el.textContent = '';
btn.disabled = true;
} else {
el.textContent = selected.size + (selected.size === 1 ? ' project selected' : ' projects selected');
btn.disabled = false;
}
}
function renderProjectCount() {
var el = document.getElementById('projectCount');
if (!el) return;
if (loadError || allProjects.length === 0) { el.textContent = ''; return; }
var rows = visibleProjects();
if (rows.length === allProjects.length) {
el.textContent = '(' + allProjects.length + ')';
} else {
el.textContent = '(' + rows.length + ' of ' + allProjects.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();
// Re-render table only — don't lose input focus by re-rendering preset menu.
renderTable();
renderProjectCount();
// Refocus the input we typed into.
var sel = document.querySelector('.column-filter[data-column="' + col + '"]');
if (sel) {
sel.focus();
sel.setSelectionRange(sel.value.length, sel.value.length);
}
}
function toggleByCheckbox(e) {
var cb = e.target;
var name = cb.value;
if (cb.checked) selected.add(name);
else selected.delete(name);
urlPush();
render();
}
function toggleRow(e) {
var row = e.target.closest('.project-table-row');
if (!row) return;
var name = row.getAttribute('data-name');
if (!name) return;
if (selected.has(name)) selected.delete(name);
else selected.add(name);
urlPush();
render();
}
function toggleHeaderCheckbox() {
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); });
}
urlPush();
render();
}
function openArchive() {
if (selected.size === 0) return;
var base = location.pathname.replace(/\/[^\/]*$/, '/');
var params = ['projects=' + Array.from(selected).map(encodeURIComponent).join(',')];
var v = new URLSearchParams(location.search).get('v');
if (v) params.push('v=' + encodeURIComponent(v));
location.href = base + 'archive.html?' + params.join('&');
}
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');
}
// ── Presets ──────────────────────────────────────────────────────────────
function loadPresets() {
try {
var raw = localStorage.getItem(PRESETS_KEY);
var parsed = raw ? JSON.parse(raw) : [];
presets = Array.isArray(parsed) ? parsed : [];
} catch (e) {
presets = [];
}
}
function persistPresets() {
try { localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); }
catch (e) { /* private mode / quota */ }
}
function snapshotState() {
return {
projects: Array.from(selected).sort(),
pn: columnFilters.pn || '',
pt: columnFilters.pt || '',
sort: sortField,
dir: sortDirection
};
}
function applyState(s) {
selected = new Set(Array.isArray(s.projects) ? s.projects : []);
columnFilters.pn = s.pn || '';
columnFilters.pt = s.pt || '';
columnFilterASTs.pn = parseFilterAST(columnFilters.pn);
columnFilterASTs.pt = parseFilterAST(columnFilters.pt);
sortField = s.sort === 'title' ? 'title' : 'name';
sortDirection = s.dir === 'desc' ? 'desc' : 'asc';
}
function toggleOpenMenu() {
var menu = document.getElementById('openArchiveMenu');
var btn = document.getElementById('openArchiveMenuBtn');
if (!menu) return;
var hidden = menu.classList.contains('hidden');
if (hidden) {
renderPresetMenu();
menu.classList.remove('hidden');
if (btn) btn.setAttribute('aria-expanded', 'true');
// Attach delegation once. Stops bubbling (so the document-level
// outside-click handler doesn't close us) and dispatches actions
// by data-action without inline onclick attribute quoting issues.
if (!menu.dataset.delegationAttached) {
menu.addEventListener('click', handlePresetMenuClick);
menu.dataset.delegationAttached = '1';
}
} else {
menu.classList.add('hidden');
if (btn) btn.setAttribute('aria-expanded', 'false');
presetSavingMode = false;
}
}
function closeOpenMenu() {
var menu = document.getElementById('openArchiveMenu');
var btn = document.getElementById('openArchiveMenuBtn');
if (menu) menu.classList.add('hidden');
if (btn) btn.setAttribute('aria-expanded', 'false');
presetSavingMode = false;
}
function handlePresetMenuClick(e) {
e.stopPropagation();
var item = e.target.closest('[data-action]');
if (!item) return;
var action = item.getAttribute('data-action');
var name = item.getAttribute('data-name') || '';
if (action === 'apply-open') {
applyPreset(name, true);
} else if (action === 'apply-stay') {
applyPreset(name, false);
} else if (action === 'delete') {
deletePreset(name);
} else if (action === 'start-save') {
startSavePreset();
} else if (action === 'confirm-save') {
confirmSavePreset();
} else if (action === 'cancel-save') {
cancelSavePreset();
}
}
function startSavePreset() {
presetSavingMode = true;
renderPresetMenu();
var input = document.getElementById('presetNameInput');
if (input) {
input.focus();
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); confirmSavePreset(); }
else if (e.key === 'Escape') { e.preventDefault(); cancelSavePreset(); }
});
}
}
function cancelSavePreset() {
presetSavingMode = false;
renderPresetMenu();
}
function confirmSavePreset() {
var input = document.getElementById('presetNameInput');
if (!input) return;
var name = (input.value || '').trim();
if (!name) return;
presets = presets.filter(function(p) { return p.name !== name; });
presets.push({ name: name, state: snapshotState() });
persistPresets();
// Close the menu — the user just completed an action; they can re-open
// via the caret if they want to apply the just-saved preset.
closeOpenMenu();
}
function applyPreset(name, openAfter) {
var preset = presets.find(function(p) { return p.name === name; });
if (!preset || !preset.state) return;
applyState(preset.state);
urlPush();
render();
if (openAfter) {
openArchive();
return;
}
closeOpenMenu();
}
function deletePreset(name) {
presets = presets.filter(function(p) { return p.name !== name; });
persistPresets();
renderPresetMenu();
}
// ── Bootstrap ────────────────────────────────────────────────────────────
async function init() {
loadPresets();
urlRestore();
var ok = await fetchProjects();
if (ok) {
// Drop any URL-restored selections that don't exist on the server.
var accessibleNames = new Set(allProjects.map(function(p) { return p.name; }));
var missing = [];
var pruned = new Set();
selected.forEach(function(n) {
if (accessibleNames.has(n)) pruned.add(n);
else missing.push(n);
});
selected = pruned;
if (missing.length > 0) {
showWarning('This link includes projects you don\'t have access to: ' + missing.map(escapeHtml).join(', '));
}
}
render();
// Close preset menu on outside click.
document.addEventListener('click', function(e) {
var menu = document.getElementById('openArchiveMenu');
var btn = document.getElementById('openArchiveMenuBtn');
if (!menu || menu.classList.contains('hidden')) return;
if (!menu.contains(e.target) && e.target !== btn) {
closeOpenMenu();
}
});
}
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,
toggleRow: toggleRow,
toggleHeaderCheckbox: toggleHeaderCheckbox,
toggleSort: toggleSort,
onColumnFilterInput: onColumnFilterInput,
openArchive: openArchive,
toggleOpenMenu: toggleOpenMenu,
startSavePreset: startSavePreset,
cancelSavePreset: cancelSavePreset,
confirmSavePreset: confirmSavePreset,
applyPreset: applyPreset,
deletePreset: deletePreset,
dismissWarning: dismissWarning
};
})();