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>
632 lines
27 KiB
JavaScript
632 lines
27 KiB
JavaScript
(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>/<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
|
||
};
|
||
|
||
})();
|