ZDDC/landing/js/landing.js
ZDDC f8a3da2ea1 feat(archive,landing): local-mode ?projects= filter + ?v= propagation
Two small additions to the project-filter / channel-selector flow that
already worked end-to-end for HTTP-mode but were missing in the local
File-System-Access path and across landing→archive navigation:

* archive: scanLocalRecursive now applies window.app.projectFilter at
  depth 0, mirroring the HTTP source's existing filter at source.js:316.
  Loading archive.html?projects=A,B in local mode (file://) now virtually
  merges A and B into one combined view, same as HTTP mode does today.

* landing: openArchive() reads ?v= from its own URL and passes it through
  to the archive.html link it generates. This keeps the user on the same
  channel (alpha/beta/stable/<version>) when they cross from the project
  picker to the archive — without it, alpha-channel users would silently
  drop back to whatever the deployment-default channel is at the
  archive.html boundary.

Test exercises the local-mode filter via the existing mock-fs-api
fixture: three top-level projects, projectFilter set to {A, B}, scan
produces only A's and B's files. (The url-state.restore() URL parsing
path is well-trodden in the HTTP case — the test sets projectFilter
directly to isolate the new source.js change from a pre-existing init()
fragility in the mock environment.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:24:07 -05:00

222 lines
9.1 KiB
JavaScript

(function() {
'use strict';
var accessibleProjects = []; // [{name, url}, ...] from server
var presets = []; // [{name, projects[]}, ...] from localStorage
var PRESETS_KEY = 'zddc_landing_presets';
// ── Initialise ──────────────────────────────────────────────────────────
async function init() {
loadPresets();
var urlProjects = getUrlProjects();
var projectList = document.getElementById('projectList');
projectList.innerHTML = '<div class="project-list-loading">Loading projects…<\/div>';
try {
var resp = await fetch(location.origin + location.pathname.replace(/\/[^\/]*$/, '/'), {
headers: { 'Accept': 'application/json' }
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
accessibleProjects = await resp.json();
} catch (e) {
projectList.innerHTML = '<div class="project-list-empty">Could not load project list: ' + escapeHtml(e.message) + '<\/div>';
return;
}
// Warn about URL projects that are not accessible
if (urlProjects.size > 0) {
var missing = Array.from(urlProjects).filter(function(p) {
return !accessibleProjects.some(function(ap) { return ap.name === p; });
});
if (missing.length > 0) {
showWarning('This link includes projects you don\'t have access to: ' + missing.map(escapeHtml).join(', '));
}
}
renderProjects(urlProjects);
renderPresetMenu();
// Close preset menu on outside click
document.addEventListener('click', function(e) {
var menu = document.getElementById('presetMenu');
var btn = document.getElementById('presetMenuBtn');
if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
menu.classList.add('hidden');
}
});
}
function getUrlProjects() {
var params = new URLSearchParams(location.search);
var val = params.get('projects');
if (!val) return new Set();
return new Set(val.split(',').map(function(p) { return p.trim(); }).filter(Boolean));
}
// ── Rendering ────────────────────────────────────────────────────────────
function renderProjects(preCheck) {
var container = document.getElementById('projectList');
if (accessibleProjects.length === 0) {
container.innerHTML = '<div class="project-list-empty">No projects available.<\/div>';
return;
}
var html = accessibleProjects.map(function(p) {
var checked = (preCheck.size === 0 || preCheck.has(p.name)) ? ' checked' : '';
return '<div class="project-item" onclick="LandingApp.toggleProject(this)">' +
'<input type="checkbox" value="' + escapeHtml(p.name) + '"' + checked + ' onclick="event.stopPropagation()">' +
'<span class="project-item-name">' + escapeHtml(p.name) + '<\/span>' +
'<\/div>';
}).join('');
container.innerHTML = html;
}
function renderPresetMenu() {
var menu = document.getElementById('presetMenu');
if (!menu) return;
if (presets.length === 0) {
menu.innerHTML = '<div class="preset-menu-empty">No presets saved.<\/div>';
return;
}
menu.innerHTML = presets.map(function(preset) {
return '<div class="preset-menu-item">' +
'<span class="preset-menu-item-name" onclick="LandingApp.applyPreset(' + JSON.stringify(preset.name) + ')">' +
escapeHtml(preset.name) + '<\/span>' +
'<button class="preset-delete-btn" onclick="LandingApp.deletePreset(' + JSON.stringify(preset.name) + ')" title="Delete preset">&times;<\/button>' +
'<\/div>';
}).join('');
}
// ── Actions ──────────────────────────────────────────────────────────────
function selectAll() {
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
cb.checked = true;
});
}
function selectNone() {
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
cb.checked = false;
});
}
function toggleProject(row) {
var cb = row.querySelector('input[type="checkbox"]');
if (cb) cb.checked = !cb.checked;
}
function openArchive() {
var checked = Array.from(document.querySelectorAll('#projectList input[type="checkbox"]:checked'))
.map(function(cb) { return cb.value; });
if (checked.length === 0) {
alert('Select at least one project to open.');
return;
}
var base = location.pathname.replace(/\/[^\/]*$/, '/');
var params = ['projects=' + checked.map(encodeURIComponent).join(',')];
// Propagate ?v= (channel selector) so the archive page loads through
// the same level-2 bootstrap stub on the same channel as this landing.
var v = new URLSearchParams(location.search).get('v');
if (v) {
params.push('v=' + encodeURIComponent(v));
}
location.href = base + 'archive.html?' + params.join('&');
}
function savePreset() {
var name = prompt('Enter a name for this preset:');
if (!name || !name.trim()) return;
name = name.trim();
var checked = Array.from(document.querySelectorAll('#projectList input[type="checkbox"]:checked'))
.map(function(cb) { return cb.value; });
// Replace existing preset with same name
presets = presets.filter(function(p) { return p.name !== name; });
presets.push({ name: name, projects: checked });
savePresets();
renderPresetMenu();
}
function togglePresetMenu() {
var menu = document.getElementById('presetMenu');
if (menu) menu.classList.toggle('hidden');
}
function applyPreset(name) {
var preset = presets.find(function(p) { return p.name === name; });
if (!preset) return;
var projectSet = new Set(preset.projects);
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
cb.checked = projectSet.has(cb.value);
});
document.getElementById('presetMenu').classList.add('hidden');
}
function deletePreset(name) {
presets = presets.filter(function(p) { return p.name !== name; });
savePresets();
renderPresetMenu();
}
function dismissWarning() {
var el = document.getElementById('accessWarningBanner');
if (el) el.classList.add('hidden');
}
// ── Warning ───────────────────────────────────────────────────────────────
function showWarning(message) {
var el = document.getElementById('accessWarningBanner');
var txt = document.getElementById('accessWarningText');
if (!el || !txt) return;
txt.textContent = message;
el.classList.remove('hidden');
}
// ── Persistence ───────────────────────────────────────────────────────────
function loadPresets() {
try {
var raw = localStorage.getItem(PRESETS_KEY);
presets = raw ? JSON.parse(raw) : [];
if (!Array.isArray(presets)) presets = [];
} catch (e) {
presets = [];
}
}
function savePresets() {
try {
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
} catch (e) { /* quota exceeded or private browsing */ }
}
// ── Utilities ─────────────────────────────────────────────────────────────
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
window.LandingApp = {
init: init,
selectAll: selectAll,
selectNone: selectNone,
toggleProject: toggleProject,
openArchive: openArchive,
savePreset: savePreset,
togglePresetMenu: togglePresetMenu,
applyPreset: applyPreset,
deletePreset: deletePreset,
dismissWarning: dismissWarning
};
})();