These three are virtual party-aggregator folders — the trailing slash serves their dir_tool (the browse folder-nav listing of parties INSIDE the folder), while the no-slash form served the browse tool scoped at the project level. Land the user inside the folder. archive/ keeps the no-slash form (its default_tool is the archive tool, which is the intended landing there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
902 lines
40 KiB
JavaScript
902 lines
40 KiB
JavaScript
(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);
|
||
// 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; }
|
||
};
|
||
|
||
})();
|