(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=. // ── 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 (/\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>/<project>/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">×</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 }; })();