ZDDC/landing/js/landing.js
ZDDC d0929a2aa9 feat(landing): groups + click-to-open redesign
Replaces the caret-dropdown preset menu with two stacked cards:

  Groups (top)    — saved bundles of projects; click to open the archive
                    with that group's project set; per-row edit/delete
                    buttons.
  Projects        — filterable table; in default mode no checkboxes,
                    click any row to open just that project.

"+ New group" or a row's edit button enters select-mode: checkboxes
appear on each project row, an action bar shows above the projects card
with Save group / Open visible-checked / Cancel.

"Open visible-checked" intentionally excludes filter-hidden checked
projects so users can scope to a subset they're currently looking at.

Storage migrates from old zddc_landing_presets to zddc_landing_groups
(simpler shape: {name, projects: [...]}). One-shot migration runs on
first load.

Adds the new favicon SVG to the landing header alongside the title.
Drops the ?projects= URL state since selection is no longer the page's
primary state in click-to-open mode.

Updates Playwright suite: 9 new test cases covering click-to-open, group
crud, edit pre-population, "open selected visible" scoping, and legacy
preset migration. Adds a LandingApp._setNavigate test hook since
window.location.href cannot be reliably patched in modern engines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:23:42 -05:00

663 lines
29 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 params = ['projects=' + names.map(encodeURIComponent).join(',')];
var v = new URLSearchParams(location.search).get('v');
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 */ }
}
// ── Bootstrap ────────────────────────────────────────────────────────────
async function init() {
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,
// Test-only: override the navigation function (avoids the messy
// browser-locked-down state of window.location).
_setNavigate: function(fn) { navigate = fn; }
};
})();