(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 (/\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); // The root JSON is now a generic listing.FileInfo[] (same // shape every other directory returns). Filter to // directories (projects are folders), strip the trailing // "/" the server adds to dir names, and pick up `title` // (the per-project .zddc title:, populated by the // server-side listing pipeline). allProjects = data .filter(function (p) { return p && p.is_dir; }) .map(function (p) { var raw = String(p.name || '').replace(/\/$/, ''); return { name: raw, title: String(p.title || ''), url: String(p.url || '') }; }) .filter(function (p) { if (!p.name) return false; var c = p.name.charAt(0); return c !== '.' && c !== '_'; }); 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>/<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 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 v = new URLSearchParams(location.search).get('v'); if (names.length === 1) { // Single project → canonical project-subtree URL so the user // can edit the address bar to swap archive.html for // working/, staging/, reviewing/, etc. zddc-server's // availability.go auto-serves the right tool at each. // Multi-project (the `else` branch) keeps the ?projects= // form because there's no single subtree root. var url = base + encodeURIComponent(names[0]) + '/archive.html'; if (v) url += '?v=' + encodeURIComponent(v); navigate(url); return; } var params = ['projects=' + names.map(encodeURIComponent).join(',')]; 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 */ } } // ── Project mode ───────────────────────────────────────────────────────── // // The same landing tool serves at /<project> as the project-workspace // page. Mode is determined from location.pathname: // // / → 'picker' (existing behavior) // /<single-segment> → 'project' // /index.html → 'picker' (file:// + standalone-served root) // anything else → 'picker' (best-effort fallback) // // Project mode shows the four canonical lifecycle-stage cards, a // "browse all files" link, and a Master Deliverables List section // with direct links to any parties currently in archive/. The party // list is fetched from <project>/<archive>/?json=1; failures fall // back to the static "no parties yet" copy. function detectMode() { if (typeof location === 'undefined') return 'picker'; var path = location.pathname || '/'; // Strip any trailing /index.html so the deployment-root case // matches even on file:// or behind some servers. var trimmed = path.replace(/\/index\.html$/, '/'); if (trimmed === '' || trimmed === '/') return 'picker'; // Single non-slash, non-dot segment → project root. var parts = trimmed.split('/').filter(Boolean); if (parts.length === 1 && parts[0].indexOf('.') === -1) { return 'project'; } return 'picker'; } function projectFromPath() { var parts = (location.pathname || '/').split('/').filter(Boolean); return parts[0] || ''; } // Render the project-workspace view: title, four stage links, MDL // section. Stage hrefs use the no-trailing-slash form so the server // routes them to each canonical default tool (browse for working/+ // reviewing/, transmittal for staging/, etc.). Browse-all and the // archive deep link use the slash form to land on the directory listing. async function renderProjectMode() { var project = projectFromPath(); if (!project) return; // Hide picker, show project view. var picker = document.getElementById('pickerView'); var projectView = document.getElementById('projectView'); if (picker) picker.classList.add('hidden'); if (projectView) projectView.classList.remove('hidden'); document.title = project + ' — ZDDC'; var titleEl = document.getElementById('projectName'); if (titleEl) titleEl.textContent = project; var p = encodeURIComponent(project); var stages = [ { id: 'stageArchive', href: '/' + p + '/archive' }, // working/staging/reviewing get a trailing slash so the user lands // INSIDE the folder (the dir_tool browse listing of parties), // not on the browse tool scoped at the project level. { id: 'stageWorking', href: '/' + p + '/working/' }, { id: 'stageStaging', href: '/' + p + '/staging/' }, { id: 'stageReviewing', href: '/' + p + '/reviewing/' }, ]; for (var i = 0; i < stages.length; i++) { var a = document.getElementById(stages[i].id); if (a) a.setAttribute('href', stages[i].href); } var browseAll = document.getElementById('browseAllLink'); if (browseAll) { browseAll.setAttribute('href', '/' + p + '/'); browseAll.textContent = 'Browse all files →'; } // MDL card. Same shape as the stage cards above, but // interactive: a <select> populated with party folders and an // Open button that opens the chosen party's MDL. The view // auto-renders at any archive/<party>/mdl/ URL even when the // folder doesn't exist on disk (zddc-server commit 3fc3717), // so we offer the operator-supplied party list directly AND // a "type a new party name" affordance via a free-text last // option. var mdlSelect = document.getElementById('mdlPartySelect'); var mdlOpenBtn = document.getElementById('mdlOpenBtn'); var mdlHint = document.getElementById('mdlHint'); if (!mdlSelect || !mdlOpenBtn) return; // Wire the Open button regardless of fetch outcome — even if // party enumeration fails, an operator can still navigate by // typing the party folder name in the URL bar. function openSelectedMdl() { var party = mdlSelect.value; if (!party) return; // No trailing slash: per the convention, the no-slash form // serves the tables tool with the MDL view. The slash form // would serve browse, which is not what the user wants when // they click "Open MDL". var url = '/' + p + '/archive/' + encodeURIComponent(party) + '/mdl'; window.location.assign(url); } mdlOpenBtn.addEventListener('click', openSelectedMdl); mdlSelect.addEventListener('change', function () { mdlOpenBtn.disabled = !mdlSelect.value; }); // Enter inside the select also opens. mdlSelect.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); openSelectedMdl(); } }); var parties = await fetchParties(p); // Repopulate the select. mdlSelect starts with a single // "Loading…" option; replace its contents either way. mdlSelect.innerHTML = ''; if (parties == null) { // Network error or unauthenticated. Leave the select // disabled but visible; user can still navigate via URL. var optErr = document.createElement('option'); optErr.value = ''; optErr.textContent = '(could not enumerate parties)'; mdlSelect.appendChild(optErr); mdlSelect.disabled = true; mdlOpenBtn.disabled = true; return; } if (parties.length === 0) { // No parties yet, but per the hint the URL still works for // any party name. Give a placeholder option that disables // Open until the user has typed something — except we // don't have a text input. So just say "(none yet)" and // disable. Operator can still navigate via the URL bar. var optNone = document.createElement('option'); optNone.value = ''; optNone.textContent = '(no party folders yet)'; mdlSelect.appendChild(optNone); mdlSelect.disabled = true; mdlOpenBtn.disabled = true; if (mdlHint) { mdlHint.innerHTML = 'No <code>archive/<party>/</code> folders yet. The MDL view still ' + 'auto-renders at any such URL, even before the folder exists — type a ' + 'party name into the URL bar (or wait for the first transmittal) to start editing.'; } return; } // Populate the select with each party. var optPlaceholder = document.createElement('option'); optPlaceholder.value = ''; optPlaceholder.textContent = 'Choose a party…'; mdlSelect.appendChild(optPlaceholder); for (var j = 0; j < parties.length; j++) { var opt = document.createElement('option'); opt.value = parties[j].name; opt.textContent = parties[j].name; mdlSelect.appendChild(opt); } mdlSelect.disabled = false; // Open button stays disabled until the user picks something. mdlOpenBtn.disabled = true; } // Returns an array of {name, url} for each party folder in the // project's archive/, sorted by name. Returns null if the listing // can't be fetched (offline, 4xx, or non-JSON response). Returns // [] if the listing succeeds but archive/ is empty / has no // visible party folders. async function fetchParties(projectURL) { try { var resp = await fetch('/' + projectURL + '/archive/', { headers: { 'Accept': 'application/json' }, cache: 'no-cache', credentials: 'same-origin' }); if (!resp.ok) return null; var ctype = resp.headers.get('Content-Type') || ''; if (!ctype.toLowerCase().includes('json')) return null; var data = await resp.json(); if (!Array.isArray(data)) return null; // Server emits directories with trailing "/" on the name. // Filter to dirs only, strip the slash for display. var out = []; for (var i = 0; i < data.length; i++) { var e = data[i]; if (!e.is_dir) continue; var nm = String(e.name || '').replace(/\/$/, ''); if (!nm) continue; if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue; out.push({ name: nm, url: e.url || ('/' + projectURL + '/archive/' + encodeURIComponent(nm) + '/') }); } out.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; }); return out; } catch (e) { return null; } } // ── Bootstrap ──────────────────────────────────────────────────────────── async function init() { if (detectMode() === 'project') { await renderProjectMode(); return; } await initPicker(); } async function initPicker() { 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, // Project-mode entry points (also tested directly). detectMode: detectMode, renderProjectMode: renderProjectMode, // Test-only: override the navigation function (avoids the messy // browser-locked-down state of window.location). _setNavigate: function(fn) { navigate = fn; } }; })();