feat(tools,build): in-flight HTML-tool reworks and build-infra updates
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>
This commit is contained in:
parent
e44ccc3500
commit
c95f07966d
50 changed files with 4329 additions and 1569 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -28,17 +28,24 @@ sh build.sh # build all five HTML tools (dist/ o
|
|||
sh tool/build.sh # build one (archive|transmittal|classifier|mdedit|landing)
|
||||
sh tool/build.sh --release [version] # cut stable; tag, write website/releases/<tool>_v<ver>.html, refresh _stable symlink
|
||||
sh tool/build.sh --release alpha|beta # cut channel build; overwrites website/releases/<tool>_<channel>.html (mutable, no tag)
|
||||
./freshen-channel <tool> <channel> # rebuild alpha/beta from current stable tag (run after every stable release)
|
||||
npm test # all Playwright specs (build first!)
|
||||
npx playwright test <tool> # one spec
|
||||
./dev-server start # ./dev-server stop # cache-busting HTTP on :8000
|
||||
|
||||
# zddc/ Go server (separate sub-project, not part of sh build.sh)
|
||||
(cd zddc && go test ./...) # unit tests (Go 1.24+)
|
||||
podman build -t zddc-server zddc/ # build container image
|
||||
sh release-image.sh <version> [alpha|beta|stable] # canonical image release (default: alpha; cascades down channels)
|
||||
```
|
||||
|
||||
No lint/typecheck/format commands exist — vanilla JS + POSIX sh by design.
|
||||
No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSIX sh by design.
|
||||
|
||||
## Things that bite if you forget
|
||||
|
||||
- **`dist/` is gitignored but force-committed** (`git add -f tool/dist/tool.html`). Never hand-edit a `dist/` file.
|
||||
- **Never write to `website/index.html`, `website/releases/<tool>_v*.html`, `website/releases/<tool>_stable.html`, or `website/releases/<tool>_beta.html` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/<tool>_v<ver>.html` (immutable) and refresh `<tool>_stable.html`; alpha/beta overwrite `<tool>_<channel>.html` in place. **Exception: `<tool>_alpha.html` files** — every plain `tool/build.sh` mirrors the dist file there as a real copy (not symlink — the canonical Caddy serves only `website/` and can't follow `../` paths). Side-effect: dev builds dirty those files; commit alongside the source change or `git checkout` to discard.
|
||||
- **Never write to `website/index.html`, `website/releases/<tool>_v*.html`, `website/releases/<tool>_stable.html`, or `website/releases/<tool>_beta.html` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/<tool>_v<ver>.html` (immutable) and refresh `<tool>_stable.html`; alpha/beta overwrite `<tool>_<channel>.html` in place.
|
||||
- **Alpha exception — every plain build dirties `website/releases/<tool>_alpha.html`.** Every `tool/build.sh` (no flags) mirrors the just-built dist file into `<tool>_alpha.html` as a real copy (not symlink — the canonical Caddy serves only `website/` and can't follow `../` paths). Side-effect: dev builds dirty those files; commit alongside the source change or `git checkout` to discard.
|
||||
- **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`.
|
||||
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
|
||||
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ escape_js_close_tags "$js_raw" "$js_temp"
|
|||
compute_build_label "archive" "${1:-}" "${2:-}"
|
||||
|
||||
# Process template: inject CSS/JS, substitute build label, strip CDN refs.
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" '
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" '
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
|
|
@ -76,6 +76,11 @@ awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label"
|
|||
print
|
||||
next
|
||||
}
|
||||
/\{\{FAVICON\}\}/ {
|
||||
gsub(/\{\{FAVICON\}\}/, favicon_uri)
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https?:\/\// { next }
|
||||
/<link rel="stylesheet" href="https?:\/\// { next }
|
||||
{ print }
|
||||
|
|
|
|||
|
|
@ -106,6 +106,22 @@ th[data-sort="desc"] .sort-indicator::after {
|
|||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Preview-active highlight: marks the row + specific file (when there are
|
||||
multiple files per revision) that the preview popup is currently showing,
|
||||
so the user can match what's on screen to its location in the table. */
|
||||
.files-table tbody tr.is-previewing {
|
||||
background: var(--bg-selected, rgba(42, 90, 138, 0.10));
|
||||
box-shadow: inset 3px 0 0 var(--primary);
|
||||
}
|
||||
.files-table tbody tr.is-previewing:hover {
|
||||
background: var(--bg-selected-hover, rgba(42, 90, 138, 0.18));
|
||||
}
|
||||
.revision-file.is-previewing {
|
||||
outline: 1.5px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.files-table td {
|
||||
padding: 0.25rem 1rem;
|
||||
vertical-align: top;
|
||||
|
|
|
|||
|
|
@ -83,23 +83,75 @@
|
|||
var lastSlash = href.lastIndexOf('/');
|
||||
var baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
|
||||
|
||||
// Check for projects that are in the URL filter but not accessible on the server
|
||||
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
// Multi-project mode is opt-in via ?projects= in the URL.
|
||||
// ?projects= absent → not multi-project; scan whatever the URL
|
||||
// points to (single-project or in-archive
|
||||
// mode). The server's project list, if any,
|
||||
// stays out of view.
|
||||
// ?projects= empty → multi-project; include every project the
|
||||
// server says the user can access.
|
||||
// ?projects=A,B → multi-project; include only the listed
|
||||
// projects (intersected with server access).
|
||||
// The archive never sees projects beyond this scope — the visibility
|
||||
// dropdown only narrows what's already in availableProjects.
|
||||
var urlParams = new URLSearchParams(location.search);
|
||||
var projectsParamPresent = urlParams.has('projects');
|
||||
|
||||
if (projectsParamPresent) {
|
||||
window.app.isMultiProject = true;
|
||||
|
||||
// Fetch the server's ACL-filtered project list so we can drop any
|
||||
// listed names the user doesn't actually have access to (and so
|
||||
// the empty-projects= "include everything" mode has a list to use).
|
||||
var serverNames = null;
|
||||
try {
|
||||
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
|
||||
if (resp.ok) {
|
||||
var serverProjects = await resp.json();
|
||||
var accessibleNames = new Set(serverProjects.map(function(p) { return p.name; }));
|
||||
var missing = Array.from(window.app.projectFilter).filter(function(p) {
|
||||
return !accessibleNames.has(p);
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
showProjectWarning(missing);
|
||||
if (Array.isArray(serverProjects) && serverProjects.length > 0
|
||||
&& serverProjects[0] && typeof serverProjects[0].name === 'string') {
|
||||
serverNames = new Set(serverProjects.map(function(p) { return p.name; }));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore — server may not support the project list API
|
||||
// Plain Caddy or proxy-stripped — trust the URL list as-is.
|
||||
}
|
||||
|
||||
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
// Listed names: intersect with server access, warn about misses.
|
||||
if (serverNames) {
|
||||
var accessible = new Set();
|
||||
var missing = [];
|
||||
window.app.projectFilter.forEach(function(p) {
|
||||
if (serverNames.has(p)) accessible.add(p);
|
||||
else missing.push(p);
|
||||
});
|
||||
window.app.projectFilter = accessible;
|
||||
if (missing.length > 0) showProjectWarning(missing);
|
||||
}
|
||||
window.app.availableProjects = Array.from(window.app.projectFilter).sort();
|
||||
} else if (serverNames) {
|
||||
// Empty ?projects= — scan everything the user can access.
|
||||
window.app.projectFilter = new Set(serverNames);
|
||||
window.app.availableProjects = Array.from(serverNames).sort();
|
||||
}
|
||||
// else: ?projects= empty AND no server list — leave projectFilter
|
||||
// empty; source.js will fall through to in-archive mode.
|
||||
}
|
||||
|
||||
// visibleProjects: default to projectFilter (everything visible), or
|
||||
// honor an explicit ?show= from the URL (intersected with projectFilter
|
||||
// to drop names that aren't in scope). An empty ?show= means "hide
|
||||
// everything" — distinct from "no ?show= at all".
|
||||
var showInUrl = urlParams.has('show');
|
||||
if (showInUrl) {
|
||||
var inScope = new Set();
|
||||
(window.app.visibleProjects || new Set()).forEach(function(n) {
|
||||
if (window.app.projectFilter.has(n)) inScope.add(n);
|
||||
});
|
||||
window.app.visibleProjects = inScope;
|
||||
} else {
|
||||
window.app.visibleProjects = new Set(window.app.projectFilter);
|
||||
}
|
||||
|
||||
await addHttpSource(baseUrl);
|
||||
|
|
@ -161,12 +213,13 @@
|
|||
try {
|
||||
await source.scan(baseUrl, callbacks);
|
||||
|
||||
// Auto-select top-level party folders (shallowest depth)
|
||||
// Auto-select top-level party folders (shallowest depth). Keyed by
|
||||
// party NAME so duplicate third-party folders across projects merge.
|
||||
var groupingDepths = window.app.groupingFolders.map(function(f) { return f.path.split('/').length; });
|
||||
var minGroupingDepth = groupingDepths.length > 0 ? Math.min.apply(null, groupingDepths) : 1;
|
||||
window.app.groupingFolders.forEach(function(folder) {
|
||||
if (folder.path.split('/').length === minGroupingDepth) {
|
||||
window.app.selectedGroupingFolders.add(folder.path);
|
||||
window.app.selectedGroupingFolders.add(folder.name);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -316,7 +369,10 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Render grouping folders as a flat list of party names (depth 1 only)
|
||||
// Render grouping folders as a flat list of unique party names. Same-named
|
||||
// third-party folders across multiple projects collapse to one row.
|
||||
// selectedGroupingFolders is a Set of party NAMES (not paths) so toggling
|
||||
// affects every project occurrence at once.
|
||||
function renderGroupingFolders() {
|
||||
const container = document.getElementById('groupingFoldersList');
|
||||
|
||||
|
|
@ -328,21 +384,35 @@
|
|||
const minDepth = allDepths.length > 0 ? Math.min(...allDepths) : 1;
|
||||
const partyFolders = filteredFolders.filter(f => f.path.split('/').length === minDepth);
|
||||
|
||||
// Sort alphabetically
|
||||
partyFolders.sort((a, b) => a.path.localeCompare(b.path));
|
||||
// Dedupe by name (keep first occurrence per name). In multi-project mode,
|
||||
// skip parties whose every occurrence is under a hidden project — if at
|
||||
// least one occurrence is in a visible project, the party stays.
|
||||
const seen = new Set();
|
||||
const uniqueParties = [];
|
||||
for (const f of partyFolders) {
|
||||
if (seen.has(f.name)) continue;
|
||||
if (window.app.isMultiProject) {
|
||||
const hasVisible = partyFolders.some(p =>
|
||||
p.name === f.name && pathIsInVisibleProject(p.path)
|
||||
);
|
||||
if (!hasVisible) continue;
|
||||
}
|
||||
seen.add(f.name);
|
||||
uniqueParties.push(f);
|
||||
}
|
||||
uniqueParties.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Build set of paths for quick lookup
|
||||
const partyPaths = new Set(partyFolders.map(f => f.path));
|
||||
const partyNames = new Set(uniqueParties.map(f => f.name));
|
||||
|
||||
// If "Select All" mode is active, auto-select all visible party folders
|
||||
// If "Select All" mode is active, auto-select all visible party names.
|
||||
if (window.app.selectAllGroupingFolders) {
|
||||
window.app.selectedGroupingFolders.clear();
|
||||
partyFolders.forEach(f => window.app.selectedGroupingFolders.add(f.path));
|
||||
uniqueParties.forEach(f => window.app.selectedGroupingFolders.add(f.name));
|
||||
} else {
|
||||
// Remove selections for folders that are no longer visible
|
||||
for (const selectedPath of window.app.selectedGroupingFolders) {
|
||||
if (!partyPaths.has(selectedPath)) {
|
||||
window.app.selectedGroupingFolders.delete(selectedPath);
|
||||
// Remove selections for names that are no longer visible.
|
||||
for (const selectedName of window.app.selectedGroupingFolders) {
|
||||
if (!partyNames.has(selectedName)) {
|
||||
window.app.selectedGroupingFolders.delete(selectedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -351,17 +421,17 @@
|
|||
const checkbox = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (checkbox) checkbox.checked = window.app.selectAllGroupingFolders;
|
||||
|
||||
if (partyFolders.length === 0 && window.app.groupingFilter) {
|
||||
if (uniqueParties.length === 0 && window.app.groupingFilter) {
|
||||
container.innerHTML = '<div class="folder-list-empty">No parties match your filter</div>';
|
||||
updateFolderSelectionState('groupingFoldersList');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = partyFolders.map(folder => `
|
||||
<div class="folder-item ${window.app.selectedGroupingFolders.has(folder.path) ? 'selected' : ''}"
|
||||
data-path="${escapeHtml(folder.path)}"
|
||||
container.innerHTML = uniqueParties.map(folder => `
|
||||
<div class="folder-item ${window.app.selectedGroupingFolders.has(folder.name) ? 'selected' : ''}"
|
||||
data-path="${escapeHtml(folder.name)}"
|
||||
data-folder-type="grouping">
|
||||
<span class="folder-item-name" title="${escapeHtml(folder.path)}">${escapeHtml(folder.name)}</span>
|
||||
<span class="folder-item-name" title="${escapeHtml(folder.name)}">${escapeHtml(folder.name)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
|
|
@ -383,9 +453,12 @@
|
|||
}).join('');
|
||||
}
|
||||
|
||||
// Toggle a folder type on/off globally
|
||||
// Toggle a folder type on/off globally.
|
||||
// Off->on triggers a refresh because source.js skips listings for disabled folder types
|
||||
// entirely (no listing fetched), so newly-enabled types need a rescan to surface their data.
|
||||
function toggleFolderType(type) {
|
||||
if (window.app.enabledFolderTypes.has(type)) {
|
||||
const wasEnabled = window.app.enabledFolderTypes.has(type);
|
||||
if (wasEnabled) {
|
||||
window.app.enabledFolderTypes.delete(type);
|
||||
} else {
|
||||
window.app.enabledFolderTypes.add(type);
|
||||
|
|
@ -395,19 +468,31 @@
|
|||
renderTransmittalFolders();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
window.app.modules.urlState.push();
|
||||
|
||||
if (!wasEnabled && window.app.directories.length > 0) {
|
||||
window.app.modules.directory.refreshDirectories();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if an outstanding file's actualPath is under a selected grouping folder
|
||||
// that is itself visible (not hidden by folder type toggles).
|
||||
// In multi-project mode, returns true if the path contains a segment matching
|
||||
// a checked project in the picker. Single-project mode always returns true
|
||||
// (no project segment to match against).
|
||||
function pathIsInVisibleProject(path) {
|
||||
if (!window.app.isMultiProject) return true;
|
||||
if (!window.app.visibleProjects || window.app.visibleProjects.size === 0) return false;
|
||||
return path.split('/').some(seg => window.app.visibleProjects.has(seg));
|
||||
}
|
||||
|
||||
// Returns true if an outstanding file's actualPath has a path segment matching
|
||||
// any selected party name and is not under a hidden folder type. Segment-equality
|
||||
// (not prefix) so the same party name selected across projects matches all
|
||||
// occurrences regardless of project ID prefix.
|
||||
function outstandingFileIsVisible(file) {
|
||||
const selectedGrouping = window.app.selectedGroupingFolders;
|
||||
if (selectedGrouping.size === 0) return false;
|
||||
// The actualPath must not be under a hidden folder type
|
||||
if (isUnderHiddenFolderType(file.actualPath)) return false;
|
||||
// The actualPath must be at or under one of the selected grouping folder paths
|
||||
return Array.from(selectedGrouping).some(function(gPath) {
|
||||
return file.actualPath === gPath || file.actualPath.startsWith(gPath + '/');
|
||||
});
|
||||
if (!pathIsInVisibleProject(file.actualPath)) return false;
|
||||
return file.actualPath.split('/').some(seg => selectedGrouping.has(seg));
|
||||
}
|
||||
|
||||
// Returns true if any outstanding (non-transmittal) files exist under the currently
|
||||
|
|
@ -419,40 +504,26 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Returns true if a transmittal folder is under a selected party and an enabled folder type.
|
||||
// Handles both HTTP paths (party at depth 0) and local paths (party at depth 1+ due to root dir prefix).
|
||||
// Returns true if any path segment of the transmittal folder matches a selected
|
||||
// party name AND the segment immediately after it (if it's a folder-type name)
|
||||
// is in enabledFolderTypes. Segment-equality matching means a party "BM" selected
|
||||
// matches every "<...>/BM/<...>" path regardless of the prefix.
|
||||
function transmittalIsUnderVisibleParty(folder) {
|
||||
if (!pathIsInVisibleProject(folder.path)) return false;
|
||||
const parts = folder.path.split('/');
|
||||
|
||||
// Find which segment is the party (the one that matches a selected grouping folder path prefix).
|
||||
// The party path is the selected grouping folder path, so check prefix matches.
|
||||
for (const partyPath of window.app.selectedGroupingFolders) {
|
||||
const partyParts = partyPath.split('/');
|
||||
const partyDepth = partyParts.length; // e.g. 1 for HTTP ("ACME"), 2 for local ("RootDir/ACME")
|
||||
|
||||
// Check that folder path starts with partyPath
|
||||
if (!folder.path.startsWith(partyPath + '/') && folder.path !== partyPath) continue;
|
||||
|
||||
// The segment immediately after partyPath is either a folder type or the transmittal itself
|
||||
const remainder = folder.path.substring(partyPath.length + 1); // e.g. "Issued/2025-01-01_..." or "2025-01-01_..."
|
||||
const remainderParts = remainder.split('/');
|
||||
|
||||
if (remainderParts.length >= 2) {
|
||||
// There's a folder type segment before the transmittal
|
||||
const folderType = remainderParts[0].toLowerCase();
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (!window.app.selectedGroupingFolders.has(parts[i])) continue;
|
||||
// i-th segment is a selected party. The segment after is either a
|
||||
// folder-type marker (Issued/Received/MDL/Incoming) or the transmittal
|
||||
// folder itself.
|
||||
const next = parts[i + 1];
|
||||
if (!next) return true;
|
||||
const folderType = next.toLowerCase();
|
||||
if (window.app.FOLDER_TYPE_NAMES.includes(folderType)) {
|
||||
// Must be an enabled type
|
||||
return window.app.enabledFolderTypes.has(folderType);
|
||||
}
|
||||
// Unknown folder type — treat as visible
|
||||
return true;
|
||||
}
|
||||
|
||||
// Transmittal is directly under the party (no folder type level) — always show
|
||||
return true;
|
||||
}
|
||||
|
||||
// Party not selected
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -699,8 +770,22 @@
|
|||
window.app.availableModifiers.add(modType);
|
||||
});
|
||||
|
||||
// Select all by default
|
||||
window.app.selectedModifiers = new Set(window.app.availableModifiers);
|
||||
// Default selection: 'base' (un-modified revisions) plus '+C' (comment
|
||||
// markups against base). Other modifier types (+B, +D, …) are
|
||||
// available in the dropdown but hidden by default — users opt them in
|
||||
// via the Modifiers dropdown when they want to see scratch / draft /
|
||||
// hold-style markups. Falls back to selecting whatever is available
|
||||
// when neither default exists, so the table never goes empty out of
|
||||
// the gate.
|
||||
const defaults = ['base', '+C'];
|
||||
const selected = new Set();
|
||||
defaults.forEach(d => {
|
||||
if (window.app.availableModifiers.has(d)) selected.add(d);
|
||||
});
|
||||
if (selected.size === 0) {
|
||||
window.app.availableModifiers.forEach(m => selected.add(m));
|
||||
}
|
||||
window.app.selectedModifiers = selected;
|
||||
|
||||
// Update the dropdown UI
|
||||
renderModifierDropdown();
|
||||
|
|
@ -858,6 +943,7 @@
|
|||
outstandingFileIsVisible,
|
||||
hasVisibleOutstandingFiles,
|
||||
transmittalIsUnderVisibleParty,
|
||||
pathIsInVisibleProject,
|
||||
renderFolderLists,
|
||||
getFilteredGroupingFolders,
|
||||
showProjectWarning,
|
||||
|
|
|
|||
|
|
@ -65,12 +65,14 @@
|
|||
try {
|
||||
await source.scan(dirHandle, callbacks);
|
||||
|
||||
// Only auto-select top-level party folders (shallowest depth)
|
||||
// Only auto-select top-level party folders (shallowest depth).
|
||||
// Selection is keyed by party NAME so the same-named third-party folder
|
||||
// appearing under multiple projects is selected/deselected as a unit.
|
||||
const groupingDepths = window.app.groupingFolders.map(f => f.path.split('/').length);
|
||||
const minGroupingDepth = groupingDepths.length > 0 ? Math.min(...groupingDepths) : 1;
|
||||
window.app.groupingFolders.forEach(folder => {
|
||||
if (folder.path.split('/').length === minGroupingDepth) {
|
||||
window.app.selectedGroupingFolders.add(folder.path);
|
||||
window.app.selectedGroupingFolders.add(folder.name);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ function applyFilters() {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Multi-project visibility filter — files under unchecked projects are
|
||||
// hidden from view (without re-scanning).
|
||||
if (!window.app.modules.app.pathIsInVisibleProject(file.path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// File must be in a selected transmittal folder
|
||||
if (!window.app.selectedTransmittalFolders.has(file.folderPath)) {
|
||||
return false;
|
||||
|
|
@ -25,11 +31,13 @@ function applyFilters() {
|
|||
if (!window.app.modules.app.outstandingFileIsVisible(file)) return false;
|
||||
}
|
||||
|
||||
// If grouping folders exist and are selected, the file's transmittal folder must be within one.
|
||||
// Outstanding files are exempt — their grouping scope is enforced by the actualPath check above.
|
||||
// If grouping folders exist and are selected, the file's transmittal folder
|
||||
// path must contain a path segment matching one of the selected party names.
|
||||
// Outstanding files are exempt — their grouping scope is enforced by the
|
||||
// actualPath check above.
|
||||
if (file.folderPath !== '__outstanding__' && window.app.groupingFolders.length > 0 && window.app.selectedGroupingFolders.size > 0) {
|
||||
const inSelectedGrouping = Array.from(window.app.selectedGroupingFolders).some(groupingPath =>
|
||||
file.folderPath.startsWith(groupingPath + '/')
|
||||
const inSelectedGrouping = file.folderPath.split('/').some(seg =>
|
||||
window.app.selectedGroupingFolders.has(seg)
|
||||
);
|
||||
if (!inSelectedGrouping) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -33,8 +33,12 @@
|
|||
availableModifiers: new Set(),
|
||||
selectedModifiers: new Set(),
|
||||
showSelectedOnly: false,
|
||||
projectFilter: new Set(),
|
||||
projectFilter: new Set(), // URL-derived; controls what gets SCANNED
|
||||
visibleProjects: new Set(), // dropdown-derived; controls VISIBILITY of already-scanned data
|
||||
availableProjects: [], // populated by autoConnectHttpSource from server's ProjectInfo[]
|
||||
isMultiProject: false, // true when ?projects= is set OR server returns ProjectInfo
|
||||
FOLDER_TYPE_NAMES: ['issued', 'received', 'mdl', 'incoming'],
|
||||
ARCHIVE_STAGE_NAME: 'archive',
|
||||
modules: {}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,399 +1,103 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// Party presets for archive browser — IIFE module
|
||||
// Project-picker dropdown for the archive browser.
|
||||
//
|
||||
// In multi-project mode (HTTP source against zddc-server, OR ?projects=
|
||||
// present in the URL), this dropdown lets the user toggle which projects
|
||||
// are scanned. Toggling a checkbox updates window.app.projectFilter, pushes
|
||||
// the new ?projects= state to the URL, and triggers a re-scan.
|
||||
//
|
||||
// In single-project mode the dropdown is hidden — only one project is ever
|
||||
// in scope, so picking is meaningless.
|
||||
|
||||
// State (module scope, NOT on window.app)
|
||||
let presets = [];
|
||||
let activePresetName = null;
|
||||
let isOpen = false;
|
||||
let isNamingMode = false;
|
||||
|
||||
// Get localStorage key based on source mode and directory
|
||||
function getStorageKey() {
|
||||
if (window.app.sourceMode === 'http' && window.app.directories.length > 0) {
|
||||
var u = window.app.directories[0].url || '';
|
||||
return 'zddc-presets:http:' + u;
|
||||
} else if (window.app.sourceMode === 'local' && window.app.directories.length > 0) {
|
||||
return 'zddc-presets:local:' + window.app.directories[0].name;
|
||||
// The set of project names currently shown in the dropdown.
|
||||
function getKnownProjects() {
|
||||
if (window.app.availableProjects && window.app.availableProjects.length > 0) {
|
||||
return window.app.availableProjects.slice();
|
||||
}
|
||||
return 'zddc-presets:default';
|
||||
// Fall back to whatever is in the URL filter — useful when the server's
|
||||
// ProjectInfo endpoint isn't reachable but ?projects= names the set.
|
||||
return Array.from(window.app.projectFilter || []);
|
||||
}
|
||||
|
||||
// Load presets from localStorage
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
var stored = localStorage.getItem(getStorageKey());
|
||||
if (stored) {
|
||||
var parsed = JSON.parse(stored);
|
||||
if (parsed && Array.isArray(parsed.presets)) {
|
||||
presets = parsed.presets;
|
||||
} else {
|
||||
presets = [];
|
||||
}
|
||||
} else {
|
||||
presets = [];
|
||||
}
|
||||
} catch (e) {
|
||||
presets = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save presets to localStorage
|
||||
function saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify({ presets: presets }));
|
||||
} catch (e) {
|
||||
// Silently fail on storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Load a preset by name
|
||||
function loadPreset(name) {
|
||||
var preset = presets.find(p => p.name === name);
|
||||
if (!preset) return;
|
||||
|
||||
// Filter paths to only include folders that exist in groupingFolders
|
||||
var validPaths = preset.paths.filter(p =>
|
||||
window.app.groupingFolders.some(f => f.path === p)
|
||||
);
|
||||
|
||||
window.app.selectedGroupingFolders = new Set(validPaths);
|
||||
window.app.selectAllGroupingFolders = false;
|
||||
|
||||
var checkbox = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
|
||||
activePresetName = name;
|
||||
|
||||
// Trigger UI updates
|
||||
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
// Visibility-only filter: change visibleProjects, push URL state, re-render
|
||||
// UI. No rescan — already-scanned data stays in memory. URL is updated via
|
||||
// history.replaceState (same mechanism as every other UI control).
|
||||
function applyVisibility(names) {
|
||||
window.app.visibleProjects = new Set(names);
|
||||
window.app.modules.urlState.push();
|
||||
window.app.modules.app.updateUI();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
|
||||
renderButton();
|
||||
renderDropdown();
|
||||
}
|
||||
|
||||
// Save current selection as a preset
|
||||
function savePreset(name) {
|
||||
// Build paths array from current selection
|
||||
var paths = Array.from(window.app.selectedGroupingFolders);
|
||||
|
||||
// Upsert preset
|
||||
var existingIndex = presets.findIndex(p => p.name === name);
|
||||
if (existingIndex >= 0) {
|
||||
presets[existingIndex] = { name: name, paths: paths };
|
||||
} else {
|
||||
presets.push({ name: name, paths: paths });
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
activePresetName = name;
|
||||
isNamingMode = false;
|
||||
|
||||
renderButton();
|
||||
renderDropdown();
|
||||
}
|
||||
|
||||
// Delete a preset by name
|
||||
function deletePreset(name) {
|
||||
presets = presets.filter(p => p.name !== name);
|
||||
if (activePresetName === name) {
|
||||
activePresetName = null;
|
||||
}
|
||||
saveToStorage();
|
||||
renderButton();
|
||||
renderDropdown();
|
||||
}
|
||||
|
||||
// Check if current selection differs from active preset
|
||||
function checkDirty() {
|
||||
if (activePresetName === null) return;
|
||||
|
||||
var preset = presets.find(p => p.name === activePresetName);
|
||||
if (!preset) return;
|
||||
|
||||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||||
var presetPaths = new Set(preset.paths || []);
|
||||
|
||||
// Compare sets
|
||||
var dirty = currentPaths.size !== presetPaths.size ||
|
||||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||||
|
||||
if (dirty) {
|
||||
renderButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Get minimum depth of grouping folders (for top-level Only)
|
||||
function getMinDepth() {
|
||||
if (window.app.groupingFolders.length === 0) return 1;
|
||||
return Math.min.apply(null, window.app.groupingFolders.map(f => f.path.split('/').length));
|
||||
}
|
||||
|
||||
// Render the preset button label
|
||||
function renderButton() {
|
||||
var btn = document.getElementById('presetBtn');
|
||||
if (!btn) return;
|
||||
|
||||
if (activePresetName !== null) {
|
||||
// Check if dirty
|
||||
var preset = presets.find(p => p.name === activePresetName);
|
||||
var dirty = false;
|
||||
if (preset) {
|
||||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||||
var presetPaths = new Set(preset.paths || []);
|
||||
dirty = currentPaths.size !== presetPaths.size ||
|
||||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||||
}
|
||||
btn.textContent = '▾ ' + activePresetName + (dirty ? '*' : '');
|
||||
} else {
|
||||
btn.textContent = '▾ Presets';
|
||||
}
|
||||
}
|
||||
|
||||
// Escape HTML for safe insertion
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Render the dropdown panel
|
||||
function renderDropdown() {
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
var minDepth = getMinDepth();
|
||||
var selected = new Set(window.app.visibleProjects || []);
|
||||
var known = getKnownProjects().slice().sort();
|
||||
|
||||
// Build presets list HTML
|
||||
var presetsHtml = '';
|
||||
if (presets.length === 0) {
|
||||
presetsHtml = '<div class="preset-no-presets"><i>No saved presets</i></div>';
|
||||
} else {
|
||||
presetsHtml = presets.map(preset => {
|
||||
var escapedName = escapeHtml(preset.name);
|
||||
return (
|
||||
'<div class="preset-item" data-name="' + escapedName + '">' +
|
||||
'<span>' + escapedName + '</span>' +
|
||||
'<button class="preset-delete" data-name="' + escapedName + '">×</button>' +
|
||||
'</div>'
|
||||
);
|
||||
var projectsHtml = known.map(name => {
|
||||
var checked = selected.has(name) ? ' checked' : '';
|
||||
var n = escapeHtml(name);
|
||||
return '<div class="preset-project-item">'
|
||||
+ '<label class="preset-project-label">'
|
||||
+ '<input type="checkbox" class="preset-checkbox" data-name="' + n + '"' + checked + '>'
|
||||
+ ' ' + n
|
||||
+ '</label>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
if (!projectsHtml) {
|
||||
projectsHtml = '<div class="preset-no-presets"><i>No projects available</i></div>';
|
||||
}
|
||||
|
||||
// Build project checkboxes HTML
|
||||
var projectsHtml = '';
|
||||
window.app.groupingFolders.forEach(folder => {
|
||||
// Only include top-level folders (minDepth)
|
||||
var pathParts = folder.path.split('/');
|
||||
if (pathParts.length !== minDepth) return;
|
||||
|
||||
var isSelected = window.app.selectedGroupingFolders.has(folder.path);
|
||||
var escapedPath = escapeHtml(folder.path);
|
||||
var escapedName = escapeHtml(folder.name);
|
||||
|
||||
projectsHtml += (
|
||||
'<div class="preset-project-item">' +
|
||||
'<label class="preset-project-label">' +
|
||||
'<input type="checkbox" class="preset-checkbox" data-path="' + escapedPath + '"' +
|
||||
(isSelected ? ' checked' : '') + '>' +
|
||||
' ' + escapedName +
|
||||
'</label>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
|
||||
// Footer HTML
|
||||
var footerHtml = '';
|
||||
if (activePresetName !== null) {
|
||||
// Check if dirty
|
||||
var preset = presets.find(p => p.name === activePresetName);
|
||||
var dirty = false;
|
||||
if (preset) {
|
||||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||||
var presetPaths = new Set(preset.paths || []);
|
||||
dirty = currentPaths.size !== presetPaths.size ||
|
||||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||||
dropdown.innerHTML =
|
||||
'<div class="preset-section-bottom">'
|
||||
+ '<div class="preset-section-label">Projects:</div>'
|
||||
+ '<div class="preset-projects-list">' + projectsHtml + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
if (isNamingMode) {
|
||||
footerHtml = (
|
||||
'<div class="preset-footer-naming">' +
|
||||
'<input type="text" class="preset-name-input" placeholder="Preset name" autoFocus>' +
|
||||
'<button class="preset-confirm-name btn btn-sm">✓</button>' +
|
||||
'<button class="preset-cancel-name btn btn-sm">✗</button>' +
|
||||
'</div>'
|
||||
);
|
||||
} else if (dirty) {
|
||||
footerHtml = (
|
||||
'<div class="preset-footer-actions">' +
|
||||
'<button class="preset-update-btn btn btn-primary btn-sm">Update "' + escapeHtml(activePresetName) + '"</button>' +
|
||||
'<button class="preset-save-new-btn btn btn-secondary btn-sm">Save as New</button>' +
|
||||
'</div>'
|
||||
);
|
||||
} else {
|
||||
footerHtml = (
|
||||
'<div class="preset-footer-actions">' +
|
||||
'<button class="preset-save-new-btn btn btn-primary btn-sm">Save as New</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No active preset — disabled if nothing selected
|
||||
var selectedCount = window.app.selectedGroupingFolders.size;
|
||||
var disabledAttr = selectedCount === 0 ? ' disabled' : '';
|
||||
footerHtml = (
|
||||
'<div class="preset-footer-actions">' +
|
||||
'<button class="preset-save-btn btn btn-primary btn-sm' + (selectedCount === 0 ? ' btn-disabled' : '') + '" ' +
|
||||
'data-disabled="' + (selectedCount === 0 ? 'true' : 'false') + '">Save as Preset</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
dropdown.innerHTML = (
|
||||
'<div class="preset-section-top">' +
|
||||
'<div class="preset-section-label">Saved Presets:</div>' +
|
||||
'<div class="preset-list">' + presetsHtml + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="preset-divider"></div>' +
|
||||
'<div class="preset-section-bottom">' +
|
||||
'<div class="preset-section-label">Projects:</div>' +
|
||||
'<div class="preset-projects-list">' + projectsHtml + '</div>' +
|
||||
'</div>' +
|
||||
footerHtml
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle dropdown visibility
|
||||
function toggleDropdown() {
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
if (isOpen) { closeDropdown(); return; }
|
||||
isOpen = true;
|
||||
if (dropdown) dropdown.classList.remove('hidden');
|
||||
renderDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown
|
||||
function closeDropdown() {
|
||||
isOpen = false;
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
isNamingMode = false;
|
||||
}
|
||||
|
||||
// Set up event delegation on dropdown
|
||||
function setupDropdownDelegation() {
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
dropdown.addEventListener('click', function(e) {
|
||||
// Close on clicks inside dropdown
|
||||
e.stopPropagation();
|
||||
|
||||
// Preset item click — load preset (do NOT close dropdown)
|
||||
var presetItem = e.target.closest('.preset-item');
|
||||
if (presetItem && !e.target.classList.contains('preset-delete')) {
|
||||
var name = presetItem.getAttribute('data-name');
|
||||
if (name) loadPreset(name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete button
|
||||
var deleteBtn = e.target.closest('.preset-delete');
|
||||
if (deleteBtn) {
|
||||
e.stopPropagation();
|
||||
var name = deleteBtn.getAttribute('data-name');
|
||||
if (name) deletePreset(name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Checkbox click
|
||||
var checkbox = e.target.closest('.preset-checkbox');
|
||||
if (checkbox) {
|
||||
var path = checkbox.getAttribute('data-path');
|
||||
if (path) {
|
||||
if (checkbox.checked) {
|
||||
window.app.selectedGroupingFolders.add(path);
|
||||
} else {
|
||||
window.app.selectedGroupingFolders.delete(path);
|
||||
}
|
||||
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
checkDirty();
|
||||
renderButton();
|
||||
renderDropdown(); // Re-render to update checkbox states and footer
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save button (not in naming mode)
|
||||
var saveBtn = e.target.closest('.preset-save-btn');
|
||||
if (saveBtn && !isNamingMode) {
|
||||
if (saveBtn.getAttribute('data-disabled') !== 'true') {
|
||||
isNamingMode = true;
|
||||
renderDropdown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update button — save current selection as active preset
|
||||
var updateBtn = e.target.closest('.preset-update-btn');
|
||||
if (updateBtn) {
|
||||
if (activePresetName) savePreset(activePresetName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save as New button
|
||||
var saveNewBtn = e.target.closest('.preset-save-new-btn');
|
||||
if (saveNewBtn) {
|
||||
isNamingMode = true;
|
||||
renderDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm name input
|
||||
var confirmBtn = e.target.closest('.preset-confirm-name');
|
||||
if (confirmBtn) {
|
||||
var input = dropdown.querySelector('.preset-name-input');
|
||||
if (input && input.value.trim()) {
|
||||
savePreset(input.value.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel name input
|
||||
var cancelBtn = e.target.closest('.preset-cancel-name');
|
||||
if (cancelBtn) {
|
||||
isNamingMode = false;
|
||||
renderDropdown();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Keydown on name input
|
||||
dropdown.addEventListener('keydown', function(e) {
|
||||
var input = e.target.closest('.preset-name-input');
|
||||
if (!input) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
if (input.value.trim()) {
|
||||
savePreset(input.value.trim());
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
isNamingMode = false;
|
||||
renderDropdown();
|
||||
}
|
||||
if (!checkbox) return;
|
||||
var projectName = checkbox.getAttribute('data-name');
|
||||
if (!projectName) return;
|
||||
var sel = new Set(window.app.visibleProjects || []);
|
||||
if (checkbox.checked) sel.add(projectName);
|
||||
else sel.delete(projectName);
|
||||
applyVisibility(Array.from(sel));
|
||||
});
|
||||
}
|
||||
|
||||
// Handle outside click to close dropdown
|
||||
function setupOutsideClickHandler() {
|
||||
document.addEventListener('click', function(e) {
|
||||
var section = document.getElementById('presetSection');
|
||||
|
|
@ -404,12 +108,22 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Initialize presets module — called after first scan completes
|
||||
function init() {
|
||||
// Idempotent: skip if button listener already attached
|
||||
var section = document.getElementById('presetSection');
|
||||
if (!section) return;
|
||||
|
||||
// Hide the dropdown entirely outside multi-project mode.
|
||||
if (!window.app.isMultiProject) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
section.classList.remove('hidden');
|
||||
|
||||
var btn = document.getElementById('presetBtn');
|
||||
if (!btn || btn.dataset.presetInit) return;
|
||||
btn.dataset.presetInit = '1';
|
||||
btn.title = 'Project picker';
|
||||
btn.textContent = '▾ Projects';
|
||||
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
|
|
@ -418,20 +132,16 @@
|
|||
|
||||
setupDropdownDelegation();
|
||||
setupOutsideClickHandler();
|
||||
loadFromStorage();
|
||||
renderButton();
|
||||
}
|
||||
|
||||
// Register module
|
||||
window.app.modules.presets = {
|
||||
init: init,
|
||||
loadPreset: loadPreset,
|
||||
savePreset: savePreset,
|
||||
deletePreset: deletePreset,
|
||||
checkDirty: checkDirty,
|
||||
renderButton: renderButton,
|
||||
toggleDropdown: toggleDropdown,
|
||||
closeDropdown: closeDropdown
|
||||
closeDropdown: closeDropdown,
|
||||
// No-op kept so existing callers (events.js after grouping-folder click)
|
||||
// don't need to null-check; preset dirty state was removed with the
|
||||
// saved-presets feature.
|
||||
checkDirty: function() {}
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,24 @@
|
|||
'use strict';
|
||||
// Source abstraction — local (File System Access API) and HTTP (Caddy JSON browse)
|
||||
//
|
||||
// Three scan modes, decided once at the entry point:
|
||||
// 1. Multi-project — ?projects=A,B URL param non-empty. The scan root holds project
|
||||
// folders; for each in the filter, descend into its `archive/` subfolder (case-
|
||||
// insensitive) and scan from there.
|
||||
// 2. Project-root — scan root has an `archive/` child (case-insensitive). Descend
|
||||
// into it and scan from there. Other stage folders (reviewing/staging/mdl/working)
|
||||
// are not entered.
|
||||
// 3. In-archive (default) — scan root's children are third-party (grouping) folders.
|
||||
// Today's behavior, unchanged.
|
||||
//
|
||||
// The recursion below the entry point never re-applies the mode check: once we are
|
||||
// inside the archive folder for a given project, descent is uniform across modes.
|
||||
//
|
||||
// Listing skip: at any depth, a directory child whose lowercased name is a member of
|
||||
// FOLDER_TYPE_NAMES (issued/received/mdl/incoming) AND not currently in
|
||||
// enabledFolderTypes is skipped entirely — we do not even fetch its listing. Toggling
|
||||
// a type back on triggers a refresh in app.js.
|
||||
|
||||
// Shared utility used by both source implementations
|
||||
function getDisplayPath(fullPath) {
|
||||
if (fullPath.length <= 100) {
|
||||
|
|
@ -14,6 +32,13 @@
|
|||
return '...' + fullPath.substring(fullPath.length - 80);
|
||||
}
|
||||
|
||||
// True if a directory child should be skipped entirely (don't fetch its listing).
|
||||
function isHiddenFolderTypeName(rawName) {
|
||||
const lower = rawName.toLowerCase();
|
||||
return window.app.FOLDER_TYPE_NAMES.includes(lower)
|
||||
&& !window.app.enabledFolderTypes.has(lower);
|
||||
}
|
||||
|
||||
// createSource(type, options) returns a source object:
|
||||
// source.type — 'local' | 'http'
|
||||
// source.canWrite — boolean
|
||||
|
|
@ -43,7 +68,7 @@
|
|||
canWrite: true,
|
||||
|
||||
scan: function(dirHandle, callbacks) {
|
||||
return scanLocalRecursive(dirHandle, dirHandle.name, 0, callbacks);
|
||||
return scanLocalRoot(dirHandle, dirHandle.name, callbacks);
|
||||
},
|
||||
|
||||
fetchFile: function(fileRef) {
|
||||
|
|
@ -54,6 +79,63 @@
|
|||
};
|
||||
}
|
||||
|
||||
async function listLocalEntries(dirHandle, currentPath) {
|
||||
const entries = [];
|
||||
try {
|
||||
for await (const entry of dirHandle.values()) {
|
||||
entries.push(entry);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not read directory ' + currentPath + ':', err);
|
||||
return null;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function findArchiveInEntries(entries) {
|
||||
const stage = window.app.ARCHIVE_STAGE_NAME;
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'directory' && entry.name.toLowerCase() === stage) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function scanLocalRoot(dirHandle, rootPath, callbacks) {
|
||||
callbacks.onProgress('Scanning ' + rootPath + '...');
|
||||
|
||||
const entries = await listLocalEntries(dirHandle, rootPath);
|
||||
if (!entries) return;
|
||||
|
||||
// Mode 1 — multi-project (?projects= set)
|
||||
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'directory') continue;
|
||||
if (!window.app.projectFilter.has(entry.name)) continue;
|
||||
const projPath = rootPath + '/' + entry.name;
|
||||
const projEntries = await listLocalEntries(entry, projPath);
|
||||
if (!projEntries) continue;
|
||||
const archive = findArchiveInEntries(projEntries);
|
||||
if (!archive) continue;
|
||||
const archivePath = projPath + '/' + archive.name;
|
||||
await scanLocalRecursive(archive, archivePath, 0, callbacks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mode 2 — project-root (scan root has an archive/ child)
|
||||
const archive = findArchiveInEntries(entries);
|
||||
if (archive) {
|
||||
const archivePath = rootPath + '/' + archive.name;
|
||||
await scanLocalRecursive(archive, archivePath, 0, callbacks);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mode 3 — in-archive (default)
|
||||
await processLocalEntries(entries, rootPath, 0, callbacks);
|
||||
}
|
||||
|
||||
async function scanLocalRecursive(dirHandle, currentPath, depth, callbacks) {
|
||||
if (currentPath.length > 200) {
|
||||
console.warn('Path too long, skipping deeper scan: ' + currentPath);
|
||||
|
|
@ -66,25 +148,17 @@
|
|||
|
||||
callbacks.onProgress('Scanning ' + currentPath + '...');
|
||||
|
||||
const entries = [];
|
||||
try {
|
||||
for await (const entry of dirHandle.values()) {
|
||||
entries.push(entry);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not read directory ' + currentPath + ':', err);
|
||||
return;
|
||||
const entries = await listLocalEntries(dirHandle, currentPath);
|
||||
if (!entries) return;
|
||||
|
||||
await processLocalEntries(entries, currentPath, depth, callbacks);
|
||||
}
|
||||
|
||||
async function processLocalEntries(entries, currentPath, depth, callbacks) {
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'directory') {
|
||||
// Project filter: at root depth, skip directories not in the
|
||||
// allowed set so ?projects=A,B virtually merges A and B into a
|
||||
// single combined view. Mirrors the HTTP-source filter at the
|
||||
// depth === 0 site below.
|
||||
if (depth === 0 && window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
if (!window.app.projectFilter.has(entry.name)) continue;
|
||||
}
|
||||
if (isHiddenFolderTypeName(entry.name)) continue;
|
||||
|
||||
const subPath = currentPath + '/' + entry.name;
|
||||
try {
|
||||
if (window.app.modules.parser.isTransmittalFolder(entry.name)) {
|
||||
|
|
@ -115,11 +189,6 @@
|
|||
try {
|
||||
const file = await entry.getFile();
|
||||
const parsed = zddc.parseFilename(file.name) || {};
|
||||
|
||||
if (!parsed.trackingNumber) {
|
||||
console.warn('File does not match ZDDC naming convention: ' + file.name);
|
||||
}
|
||||
|
||||
const fullPath = currentPath + '/' + file.name;
|
||||
const displayPath = fullPath.length > 250
|
||||
? '...' + fullPath.substring(fullPath.length - 200)
|
||||
|
|
@ -184,11 +253,6 @@
|
|||
try {
|
||||
const file = await entry.getFile();
|
||||
const parsed = zddc.parseFilename(file.name) || {};
|
||||
|
||||
if (!parsed.trackingNumber) {
|
||||
console.warn('File does not match ZDDC naming convention: ' + file.name);
|
||||
}
|
||||
|
||||
const fullPath = folderPath + '/' + file.name;
|
||||
const displayPath = fullPath.length > 250
|
||||
? '...' + fullPath.substring(fullPath.length - 200)
|
||||
|
|
@ -261,7 +325,7 @@
|
|||
|
||||
scan: function(rootUrl, callbacks) {
|
||||
const scanRoot = (rootUrl && rootUrl !== root) ? rootUrl : root;
|
||||
return scanHttpRecursive(scanRoot, root, 0, null, callbacks);
|
||||
return scanHttpRoot(scanRoot, root, callbacks);
|
||||
},
|
||||
|
||||
fetchFile: function(fileRef) {
|
||||
|
|
@ -273,52 +337,116 @@
|
|||
};
|
||||
}
|
||||
|
||||
async function fetchHttpListing(dirUrl) {
|
||||
try {
|
||||
const resp = await fetch(dirUrl, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!resp.ok) {
|
||||
// 403/404 on a sub-path is expected when ACLs deny access or a
|
||||
// listed dir doesn't exist — log at info level to avoid alarming
|
||||
// users in the console.
|
||||
console.info('skip ' + dirUrl + ' (' + resp.status + ')');
|
||||
return null;
|
||||
}
|
||||
const items = await resp.json();
|
||||
if (!Array.isArray(items)) {
|
||||
// Server returned 200 but the body wasn't a JSON array — most
|
||||
// commonly Caddy serving an HTML error page or an index.html
|
||||
// when file_browse isn't enabled at that path. Silent skip.
|
||||
return null;
|
||||
}
|
||||
return items;
|
||||
} catch (err) {
|
||||
// JSON parse failures, network errors, etc. — single concise line.
|
||||
console.info('skip ' + dirUrl + ': ' + (err.message || err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rawNameOf(item) {
|
||||
return item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
|
||||
}
|
||||
|
||||
function findArchiveInItems(items) {
|
||||
const stage = window.app.ARCHIVE_STAGE_NAME;
|
||||
for (const item of items) {
|
||||
if (!item.is_dir) continue;
|
||||
const name = rawNameOf(item);
|
||||
if (name.toLowerCase() === stage) return { item: item, name: name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function scanHttpRoot(scanRootUrl, rootUrl, callbacks) {
|
||||
// Mode 1 — multi-project (?projects= set). Skip listing scanRootUrl entirely:
|
||||
// the zddc-server returns a ProjectInfo array there (not a Caddy fileInfo
|
||||
// listing), so iterating it as if it were a directory listing wouldn't work.
|
||||
// Project URLs are deterministic — go straight to each one.
|
||||
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
const tasks = [];
|
||||
for (const name of window.app.projectFilter) {
|
||||
if (!name || name.startsWith('.')) continue;
|
||||
const projUrl = resolveHttpUrl(scanRootUrl, name, true);
|
||||
tasks.push((async () => {
|
||||
const projItems = await fetchHttpListing(projUrl);
|
||||
if (!projItems) return;
|
||||
const found = findArchiveInItems(projItems);
|
||||
if (!found) return;
|
||||
const archiveUrl = resolveHttpUrl(projUrl, found.name, true);
|
||||
await scanHttpRecursive(archiveUrl, rootUrl, 0, null, callbacks);
|
||||
})());
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await fetchHttpListing(scanRootUrl);
|
||||
if (!items) return;
|
||||
|
||||
// Mode 2 — project-root (scan root has archive/ child)
|
||||
const found = findArchiveInItems(items);
|
||||
if (found) {
|
||||
const archiveUrl = resolveHttpUrl(scanRootUrl, found.name, true);
|
||||
await scanHttpRecursive(archiveUrl, rootUrl, 0, null, callbacks);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mode 3 — in-archive (default)
|
||||
await processHttpItems(items, scanRootUrl, rootUrl, 0, null, callbacks);
|
||||
}
|
||||
|
||||
async function scanHttpRecursive(dirUrl, rootUrl, depth, transmittalPath, callbacks) {
|
||||
if (depth > 10) {
|
||||
console.warn('HTTP directory depth limit reached at: ' + dirUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
let items;
|
||||
try {
|
||||
// Caddy returns JSON when the Accept header requests it
|
||||
const resp = await fetch(dirUrl, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error('HTTP ' + resp.status + ' listing ' + dirUrl);
|
||||
}
|
||||
// Caddy encodes listing.Items directly — a bare JSON array of fileInfo objects
|
||||
// fileInfo fields: name (dirs have trailing "/"), size, url, mod_time, mode, is_dir, is_symlink
|
||||
items = await resp.json();
|
||||
if (!Array.isArray(items)) {
|
||||
throw new Error('Unexpected response format (expected JSON array)');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch directory listing for ' + dirUrl + ':', err);
|
||||
return;
|
||||
const items = await fetchHttpListing(dirUrl);
|
||||
if (!items) return;
|
||||
|
||||
await processHttpItems(items, dirUrl, rootUrl, depth, transmittalPath, callbacks);
|
||||
}
|
||||
|
||||
async function processHttpItems(items, dirUrl, rootUrl, depth, transmittalPath, callbacks) {
|
||||
// Collect subdirectory scan promises so siblings run in parallel
|
||||
const subdirPromises = [];
|
||||
|
||||
for (const item of items) {
|
||||
// Caddy appends "/" to directory names; strip it to get the bare name for matching
|
||||
const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
|
||||
const rawName = rawNameOf(item);
|
||||
|
||||
// Skip hidden files
|
||||
if (rawName.startsWith('.')) continue;
|
||||
|
||||
const isDir = item.is_dir === true;
|
||||
// Project filter: at root depth, skip directories not in the allowed set
|
||||
if (depth === 0 && isDir && window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
if (!window.app.projectFilter.has(rawName)) continue;
|
||||
}
|
||||
|
||||
const itemUrl = resolveHttpUrl(dirUrl, rawName, isDir);
|
||||
const logicalPath = urlToLogicalPath(itemUrl, rootUrl);
|
||||
|
||||
if (isDir) {
|
||||
// Skip listings for folder-types that are toggled off — applies at any depth.
|
||||
if (transmittalPath === null && isHiddenFolderTypeName(rawName)) continue;
|
||||
|
||||
if (transmittalPath !== null) {
|
||||
// Inside a transmittal folder — recurse into subdirectories
|
||||
subdirPromises.push(
|
||||
|
|
@ -356,10 +484,6 @@
|
|||
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
||||
const dirLogicalPath = urlToLogicalPath(dirUrl, rootUrl);
|
||||
const parsed = zddc.parseFilename(rawName) || {};
|
||||
if (!parsed.trackingNumber) {
|
||||
console.warn('File does not match ZDDC naming convention: ' + rawName);
|
||||
}
|
||||
|
||||
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
|
||||
|
||||
callbacks.onFile({
|
||||
|
|
@ -379,10 +503,6 @@
|
|||
} else {
|
||||
// Inside a transmittal folder
|
||||
const parsed = zddc.parseFilename(rawName) || {};
|
||||
if (!parsed.trackingNumber) {
|
||||
console.warn('File does not match ZDDC naming convention: ' + rawName);
|
||||
}
|
||||
|
||||
// mod_time is an ISO 8601 string from Go's time.Time.UTC()
|
||||
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,54 @@
|
|||
const loadedLibraries = new Map();
|
||||
let resizing = null;
|
||||
|
||||
// Currently-previewing file (visual highlight in the file table). Survives
|
||||
// re-renders via applyPreviewHighlight, which is called at the tail of
|
||||
// updateFileTable. Cleared when the preview popup is closed.
|
||||
let currentPreviewFileId = null;
|
||||
let previewWindowWatcher = null;
|
||||
|
||||
function setCurrentPreviewFile(fileId) {
|
||||
currentPreviewFileId = fileId;
|
||||
applyPreviewHighlight();
|
||||
}
|
||||
|
||||
function applyPreviewHighlight() {
|
||||
const tbody = document.getElementById('filesTableBody');
|
||||
if (!tbody) return;
|
||||
// Clear any prior highlight first.
|
||||
tbody.querySelectorAll('tr.is-previewing').forEach(el => el.classList.remove('is-previewing'));
|
||||
tbody.querySelectorAll('.revision-file.is-previewing').forEach(el => el.classList.remove('is-previewing'));
|
||||
if (!currentPreviewFileId) return;
|
||||
const checkbox = tbody.querySelector(`input[type="checkbox"][data-file-id="${cssEscape(currentPreviewFileId)}"]`);
|
||||
if (!checkbox) return;
|
||||
const wrapper = checkbox.closest('.revision-file');
|
||||
if (wrapper) wrapper.classList.add('is-previewing');
|
||||
const row = checkbox.closest('tr');
|
||||
if (row) row.classList.add('is-previewing');
|
||||
}
|
||||
|
||||
function cssEscape(s) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(s);
|
||||
return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c);
|
||||
}
|
||||
|
||||
// Watch the preview popup; clear the highlight when the user closes it so
|
||||
// the table doesn't keep advertising a preview that's no longer on screen.
|
||||
function watchPreviewWindow() {
|
||||
if (previewWindowWatcher) {
|
||||
clearInterval(previewWindowWatcher);
|
||||
previewWindowWatcher = null;
|
||||
}
|
||||
if (!filePreviewWindow) return;
|
||||
previewWindowWatcher = setInterval(() => {
|
||||
if (!filePreviewWindow || filePreviewWindow.closed) {
|
||||
clearInterval(previewWindowWatcher);
|
||||
previewWindowWatcher = null;
|
||||
if (currentPreviewFileId) setCurrentPreviewFile(null);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get or create a blob URL for a file.
|
||||
|
|
@ -93,6 +141,11 @@
|
|||
|
||||
tbody.innerHTML = rows.join('');
|
||||
|
||||
// Re-apply the preview highlight after every re-render so a file that
|
||||
// was being previewed when filters changed still shows as previewing if
|
||||
// it's still in the visible set.
|
||||
applyPreviewHighlight();
|
||||
|
||||
// Clean up blob URLs for files no longer visible
|
||||
cleanupUnusedBlobUrls();
|
||||
}
|
||||
|
|
@ -607,6 +660,8 @@
|
|||
// Check if file preview is enabled and file type is previewable
|
||||
if (isFilePreviewEnabled() && PREVIEW_EXTENSIONS.includes(file.extension.toLowerCase())) {
|
||||
await showFilePreview(file);
|
||||
setCurrentPreviewFile(file.id);
|
||||
watchPreviewWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@
|
|||
rv: 'columnFilters.revisions',
|
||||
types: 'enabledFolderTypes',
|
||||
gf: 'groupingFilter',
|
||||
tf: 'transmittalFilter'
|
||||
tf: 'transmittalFilter',
|
||||
projects: 'projectFilter',
|
||||
show: 'visibleProjects'
|
||||
};
|
||||
|
||||
// Serialize current state to URL query string
|
||||
|
|
@ -66,6 +68,19 @@
|
|||
params.set('projects', Array.from(window.app.projectFilter).join(','));
|
||||
}
|
||||
|
||||
// Visibility filter (project picker). Emit only when it's a strict subset
|
||||
// of projectFilter — the common "everything visible" case keeps URLs clean.
|
||||
if (window.app.visibleProjects && window.app.projectFilter
|
||||
&& window.app.projectFilter.size > 0) {
|
||||
var pfSize = window.app.projectFilter.size;
|
||||
var vp = Array.from(window.app.visibleProjects).filter(function(n) {
|
||||
return window.app.projectFilter.has(n);
|
||||
});
|
||||
if (vp.length < pfSize) {
|
||||
params.set('show', vp.slice().sort().join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Build query string
|
||||
var qs = params.toString();
|
||||
return qs ? '?' + qs : '';
|
||||
|
|
@ -149,6 +164,15 @@
|
|||
window.app.projectFilter = new Set(projNames);
|
||||
}
|
||||
|
||||
// Restore visibility filter. autoConnectHttpSource will intersect against
|
||||
// projectFilter / availableProjects after the project list resolves, so
|
||||
// dropping bogus names is handled there. We just parse here.
|
||||
if (params.has('show')) {
|
||||
var showValue = params.get('show');
|
||||
var showNames = showValue.split(',').map(function(p) { return p.trim(); }).filter(Boolean);
|
||||
window.app.visibleProjects = new Set(showNames);
|
||||
}
|
||||
|
||||
// Update DOM inputs to reflect restored values
|
||||
updateFilterInputs();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Archive</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
|
|
@ -102,9 +103,9 @@
|
|||
<!-- Reset Filters -->
|
||||
<button id="resetFiltersBtn" class="btn btn-secondary btn-icon-only" title="Reset all column filters">↺</button>
|
||||
|
||||
<!-- Preview toggle -->
|
||||
<!-- Preview toggle (default on; users can opt out for direct downloads) -->
|
||||
<label class="preview-toggle-label" title="Preview PDF, Word, and Excel files in a popup window instead of downloading">
|
||||
<input type="checkbox" id="filePreviewToggle">
|
||||
<input type="checkbox" id="filePreviewToggle" checked>
|
||||
<span>Preview</span>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Loading {{TOOL_TITLE}}…</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<style>html,body{margin:0;font:14px system-ui,sans-serif;color:#666;padding:1rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Loading {{TOOL_TITLE}}…</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<style>html,body{margin:0;font:14px system-ui,sans-serif;color:#666;padding:1rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
9
build.sh
9
build.sh
|
|
@ -69,13 +69,18 @@ classifier|classifier.html|Classifier
|
|||
mdedit|mdedit.html|Markdown Editor
|
||||
landing|index.html|ZDDC'
|
||||
|
||||
# Substitute {{TOOL}}, {{TOOL_TITLE}}, {{CHANNEL}} in a template.
|
||||
# Substitute {{TOOL}}, {{TOOL_TITLE}}, {{CHANNEL}} in a template.
|
||||
# Substitute {{TOOL}}, {{TOOL_TITLE}}, {{CHANNEL}}, {{FAVICON}} in a template.
|
||||
# The favicon is a base64-encoded data URI built once from shared/favicon.svg.
|
||||
_favicon_data_uri=""
|
||||
if [ -f "$SCRIPT_DIR/shared/favicon.svg" ]; then
|
||||
_favicon_data_uri="data:image/svg+xml;base64,$(base64 -w 0 "$SCRIPT_DIR/shared/favicon.svg")"
|
||||
fi
|
||||
render_stub() {
|
||||
sed \
|
||||
-e "s|{{TOOL_TITLE}}|$3|g" \
|
||||
-e "s|{{TOOL}}|$2|g" \
|
||||
-e "s|{{CHANNEL}}|${4:-}|g" \
|
||||
-e "s|{{FAVICON}}|$_favicon_data_uri|g" \
|
||||
"$1" > "$5"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ escape_js_close_tags "$js_raw" "$js_temp"
|
|||
compute_build_label "classifier" "${1:-}" "${2:-}"
|
||||
|
||||
# Process template: inject CSS/JS, substitute build label, strip CDN refs.
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" '
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" '
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
|
|
@ -74,6 +74,11 @@ awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label"
|
|||
print
|
||||
next
|
||||
}
|
||||
/\{\{FAVICON\}\}/ {
|
||||
gsub(/\{\{FAVICON\}\}/, favicon_uri)
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https?:\/\// { next }
|
||||
/<link rel="stylesheet" href="https?:\/\// { next }
|
||||
{ print }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Classifier</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ concat_files \
|
|||
> "$css_temp"
|
||||
|
||||
concat_files \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
"../shared/theme.js" \
|
||||
"js/landing.js" \
|
||||
> "$js_raw"
|
||||
|
|
@ -34,7 +36,7 @@ escape_js_close_tags "$js_raw" "$js_temp"
|
|||
compute_build_label "landing" "${1:-}" "${2:-}"
|
||||
|
||||
# Process template: inject CSS/JS, substitute build label, strip CDN refs
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" '
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" '
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
|
|
@ -54,6 +56,11 @@ awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label"
|
|||
print
|
||||
next
|
||||
}
|
||||
/\{\{FAVICON\}\}/ {
|
||||
gsub(/\{\{FAVICON\}\}/, favicon_uri)
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https?:\/\// { next }
|
||||
/<link rel="stylesheet" href="https?:\/\// { next }
|
||||
{ print }
|
||||
|
|
|
|||
|
|
@ -8,9 +8,28 @@ body {
|
|||
}
|
||||
|
||||
.landing-main {
|
||||
max-width: 640px;
|
||||
max-width: 880px;
|
||||
margin: 32px auto;
|
||||
padding: 0 16px;
|
||||
padding: 0 16px 64px;
|
||||
}
|
||||
|
||||
/* Welcome / hero */
|
||||
.landing-hero {
|
||||
margin: 0 0 24px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.landing-hero h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.landing-hero-sub {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Access warning banner */
|
||||
|
|
@ -43,7 +62,8 @@ body {
|
|||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.landing-card-header {
|
||||
|
|
@ -55,13 +75,21 @@ body {
|
|||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.landing-card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.landing-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.landing-header-actions {
|
||||
display: flex;
|
||||
|
|
@ -70,40 +98,145 @@ body {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Project list */
|
||||
.project-list {
|
||||
padding: 8px 0;
|
||||
/* Project list container */
|
||||
.project-list-container {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Empty / error states */
|
||||
.project-list-empty {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.project-list-empty h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.project-list-empty p {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.landing-empty-help {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem !important;
|
||||
margin-top: 12px !important;
|
||||
max-width: 50ch;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.project-list-loading {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
transition: background 0.1s;
|
||||
/* Project table */
|
||||
.project-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.project-item:hover { background: var(--bg-hover); }
|
||||
.project-item input[type="checkbox"] {
|
||||
.project-table thead {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.project-table-headers th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
.project-table-headers th[data-sort] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.project-table-headers th[data-sort]:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.project-table-checkbox-col {
|
||||
width: 32px;
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.project-table-checkbox-col input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--primary);
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.project-item-name {
|
||||
font-size: 0.9375rem;
|
||||
.project-table-name-col {
|
||||
min-width: 140px;
|
||||
}
|
||||
.project-table-title-col {
|
||||
width: 100%;
|
||||
}
|
||||
.sort-indicator {
|
||||
color: var(--text-muted);
|
||||
margin-left: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sort-indicator.active {
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.project-table-filters th {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.project-table-filters .column-filter {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.project-table-filters .column-filter:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.project-table-filters .column-filter.filter-active {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.project-table-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.08s;
|
||||
}
|
||||
.project-table-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.project-table-row.is-selected {
|
||||
background: var(--bg-selected, rgba(0, 105, 217, 0.08));
|
||||
}
|
||||
.project-table-row.is-selected:hover {
|
||||
background: var(--bg-selected-hover, rgba(0, 105, 217, 0.15));
|
||||
}
|
||||
.project-table-row td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.project-table-no-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.project-table-no-match {
|
||||
padding: 24px 16px !important;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
|
@ -115,25 +248,58 @@ body {
|
|||
border-top: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Preset menu */
|
||||
.preset-control {
|
||||
position: relative;
|
||||
.landing-selection-summary {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.preset-menu {
|
||||
|
||||
/* Open Archive split button (footer): primary action + preset dropdown caret */
|
||||
.open-archive-split {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.open-archive-main {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.open-archive-caret {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: 1px solid rgba(255,255,255,0.25);
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.open-archive-main:disabled + .open-archive-caret {
|
||||
/* Caret stays usable even when no projects are checked, so the user can
|
||||
still open the menu to load a preset. */
|
||||
}
|
||||
.open-archive-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||
min-width: 200px;
|
||||
min-width: 280px;
|
||||
z-index: 100;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.preset-menu.hidden { display: none; }
|
||||
.open-archive-menu.hidden { display: none; }
|
||||
.preset-menu-header {
|
||||
padding: 8px 12px 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.preset-menu-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.preset-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -150,6 +316,21 @@ body {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.preset-load-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preset-load-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
.preset-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -170,11 +351,30 @@ body {
|
|||
border-top: 1px solid var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.project-list-loading {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
.preset-menu-actions {
|
||||
padding: 6px 12px 8px;
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.preset-menu-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
.preset-menu-saving {
|
||||
padding: 6px 12px 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preset-name-input {
|
||||
flex: 1 1 100%;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.preset-name-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,173 +1,426 @@
|
|||
(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 accessibleProjects = []; // [{name, url}, ...] from server
|
||||
var presets = []; // [{name, projects[]}, ...] from localStorage
|
||||
var PRESETS_KEY = 'zddc_landing_presets';
|
||||
var DEFAULT_SORT_FIELD = 'name';
|
||||
var DEFAULT_SORT_DIRECTION = 'asc';
|
||||
|
||||
// ── Initialise ──────────────────────────────────────────────────────────
|
||||
// ── URL state ────────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
loadPresets();
|
||||
|
||||
var urlProjects = getUrlProjects();
|
||||
|
||||
var projectList = document.getElementById('projectList');
|
||||
projectList.innerHTML = '<div class="project-list-loading">Loading projects…<\/div>';
|
||||
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 {
|
||||
var resp = await fetch(location.origin + location.pathname.replace(/\/[^\/]*$/, '/'), {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
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);
|
||||
accessibleProjects = await resp.json();
|
||||
|
||||
// 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) {
|
||||
projectList.innerHTML = '<div class="project-list-empty">Could not load project list: ' + escapeHtml(e.message) + '<\/div>';
|
||||
return;
|
||||
loadError = e.message || String(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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; });
|
||||
// ── 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);
|
||||
});
|
||||
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));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderProjects(preCheck) {
|
||||
var container = document.getElementById('projectList');
|
||||
if (accessibleProjects.length === 0) {
|
||||
container.innerHTML = '<div class="project-list-empty">No projects available.<\/div>';
|
||||
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;
|
||||
}
|
||||
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('');
|
||||
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('presetMenu');
|
||||
var menu = document.getElementById('openArchiveMenu');
|
||||
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">×<\/button>' +
|
||||
'<\/div>';
|
||||
|
||||
// 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>';
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function selectAll() {
|
||||
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
|
||||
cb.checked = true;
|
||||
});
|
||||
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 selectNone() {
|
||||
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
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 toggleProject(row) {
|
||||
var cb = row.querySelector('input[type="checkbox"]');
|
||||
if (cb) cb.checked = !cb.checked;
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
if (selected.size === 0) 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 params = ['projects=' + Array.from(selected).map(encodeURIComponent).join(',')];
|
||||
var v = new URLSearchParams(location.search).get('v');
|
||||
if (v) {
|
||||
params.push('v=' + encodeURIComponent(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');
|
||||
|
|
@ -176,44 +429,201 @@
|
|||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────
|
||||
// ── Presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
function loadPresets() {
|
||||
try {
|
||||
var raw = localStorage.getItem(PRESETS_KEY);
|
||||
presets = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(presets)) presets = [];
|
||||
var parsed = raw ? JSON.parse(raw) : [];
|
||||
presets = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
presets = [];
|
||||
}
|
||||
}
|
||||
|
||||
function savePresets() {
|
||||
try {
|
||||
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
|
||||
} catch (e) { /* quota exceeded or private browsing */ }
|
||||
function persistPresets() {
|
||||
try { localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); }
|
||||
catch (e) { /* private mode / quota */ }
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||
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);
|
||||
div.textContent = String(text == null ? '' : text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
// Public API for inline handlers.
|
||||
window.LandingApp = {
|
||||
init: init,
|
||||
selectAll: selectAll,
|
||||
selectNone: selectNone,
|
||||
toggleProject: toggleProject,
|
||||
toggleByCheckbox: toggleByCheckbox,
|
||||
toggleRow: toggleRow,
|
||||
toggleHeaderCheckbox: toggleHeaderCheckbox,
|
||||
toggleSort: toggleSort,
|
||||
onColumnFilterInput: onColumnFilterInput,
|
||||
openArchive: openArchive,
|
||||
savePreset: savePreset,
|
||||
togglePresetMenu: togglePresetMenu,
|
||||
toggleOpenMenu: toggleOpenMenu,
|
||||
startSavePreset: startSavePreset,
|
||||
cancelSavePreset: cancelSavePreset,
|
||||
confirmSavePreset: confirmSavePreset,
|
||||
applyPreset: applyPreset,
|
||||
deletePreset: deletePreset,
|
||||
dismissWarning: dismissWarning
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Archive — Projects</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
|
|
@ -20,33 +21,43 @@
|
|||
</header>
|
||||
|
||||
<main class="landing-main">
|
||||
<!-- Welcome / hero -->
|
||||
<section class="landing-hero">
|
||||
<h1>Welcome to the ZDDC Archive</h1>
|
||||
<p class="landing-hero-sub">
|
||||
Pick the projects you want to view, then open the archive. Filter by
|
||||
project number or title, and save your selection as a preset to
|
||||
share or come back to later.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Access warning banner (shown when URL ?projects= contains inaccessible items) -->
|
||||
<div id="accessWarningBanner" class="access-warning-banner hidden" role="alert">
|
||||
<span id="accessWarningText"></span>
|
||||
<button class="warning-dismiss-btn" onclick="LandingApp.dismissWarning()" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Project picker card -->
|
||||
<div class="landing-card">
|
||||
<div class="landing-card-header">
|
||||
<h2>Select Projects</h2>
|
||||
<div class="landing-header-actions">
|
||||
<!-- Presets dropdown -->
|
||||
<div class="preset-control">
|
||||
<button id="presetMenuBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.togglePresetMenu()">▾ Presets</button>
|
||||
<div id="presetMenu" class="preset-menu hidden"></div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="LandingApp.selectAll()">Select All</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="LandingApp.selectNone()">Select None</button>
|
||||
<div class="landing-card-title">
|
||||
<h2>Projects</h2>
|
||||
<span id="projectCount" class="landing-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="projectList" class="project-list">
|
||||
<!-- Populated by JS -->
|
||||
<div id="projectListContainer" class="project-list-container">
|
||||
<!-- Populated by JS: either a table, a friendly empty state, or a loading message. -->
|
||||
<div class="project-list-loading">Loading projects…</div>
|
||||
</div>
|
||||
|
||||
<div class="landing-card-footer">
|
||||
<button id="savePresetBtn" class="btn btn-secondary" onclick="LandingApp.savePreset()">Save Preset…</button>
|
||||
<button id="openArchiveBtn" class="btn btn-primary" onclick="LandingApp.openArchive()">Open Archive →</button>
|
||||
<span id="selectionSummary" class="landing-selection-summary"></span>
|
||||
<div class="open-archive-split">
|
||||
<button id="openArchiveBtn" class="btn btn-primary open-archive-main" onclick="LandingApp.openArchive()" disabled>Open Archive →</button>
|
||||
<button id="openArchiveMenuBtn" class="btn btn-primary open-archive-caret" onclick="LandingApp.toggleOpenMenu()" aria-haspopup="menu" aria-expanded="false" aria-label="Presets" title="Presets">▾</button>
|
||||
<div id="openArchiveMenu" class="open-archive-menu hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ awk \
|
|||
-v toastui_css="$toastui_css" \
|
||||
-v build_label="$build_label" \
|
||||
-v is_red="$is_red" \
|
||||
-v favicon_uri="$favicon_data_uri" \
|
||||
'
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
|
|
@ -94,6 +95,11 @@ awk \
|
|||
print
|
||||
next
|
||||
}
|
||||
/\{\{FAVICON\}\}/ {
|
||||
gsub(/\{\{FAVICON\}\}/, favicon_uri)
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https:\/\/cdn\.tailwindcss\.com"/ {
|
||||
# Stripped: Tailwind utility classes are in css/tailwind-utils.css instead
|
||||
next
|
||||
|
|
|
|||
|
|
@ -79,6 +79,15 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Always-visible action buttons (e.g. scratchpad download) */
|
||||
.tree-actions--always { opacity: 1; }
|
||||
|
||||
.tree-btn:disabled,
|
||||
.tree-btn.is-disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -226,77 +235,59 @@
|
|||
.bg-white { background-color: var(--bg) !important; }
|
||||
.bg-gray-100 { background-color: var(--bg-secondary) !important; }
|
||||
|
||||
/* ── Front matter nav bar ──────────────────────────────────────────────────── */
|
||||
.front-matter-nav {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.front-matter-nav__header {
|
||||
/* ── Section headers (YAML front matter, TOC, etc.) ───────────────────────── */
|
||||
/* Shared style for all collapsible/section headers inside the side pane —
|
||||
keeps font, padding, weight identical to the file-tree pane header. */
|
||||
.pane-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.front-matter-nav__header:hover {
|
||||
.pane-section-header .toggle-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
width: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Front matter section ──────────────────────────────────────────────────── */
|
||||
.front-matter-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.front-matter-header:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.front-matter-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: transform 0.2s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.front-matter-toggle:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.front-matter-toggle svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Toggle arrow rotation for collapsed state */
|
||||
.front-matter-nav.collapsed .front-matter-toggle .arrow-down {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Front matter content area */
|
||||
.front-matter-content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, padding 0.25s ease, opacity 0.25s ease;
|
||||
max-height: 500px;
|
||||
padding: 0 1rem;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* When collapsed, hide content completely */
|
||||
/* When collapsed, hide content; height shrinks to header */
|
||||
.front-matter-nav.collapsed {
|
||||
border-bottom: none;
|
||||
height: auto !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.front-matter-nav.collapsed .front-matter-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Front matter textarea */
|
||||
/* Front matter textarea fills the content area */
|
||||
.front-matter-textarea {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
|
|
@ -304,14 +295,34 @@
|
|||
resize: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre; /* preserve yaml structure, enables horiz scroll */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden; /* height is set by JS to fit content exactly */
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.front-matter-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Horizontal pane resizer (height split) ─────────────────────────────── */
|
||||
.pane-resizer.horizontal {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
cursor: row-resize;
|
||||
background-color: var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.pane-resizer.horizontal:hover,
|
||||
.pane-resizer.horizontal.active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
/* ── Hidden utility (for disabled buttons) ─────────────────────────────────── */
|
||||
.hide { display: none; }
|
||||
|
||||
|
|
|
|||
|
|
@ -3,22 +3,26 @@
|
|||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toc-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toc-container,
|
||||
.toc-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Header layout — font/padding/weight come from .pane-section-header. */
|
||||
.toc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toc-depth-selector {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ let directoryHandle = null;
|
|||
let fileTree = {};
|
||||
let currentFileHandle = null;
|
||||
|
||||
// True when the page is served over HTTP(S) and the file tree is sourced
|
||||
// from the server's JSON directory listing instead of the local FS API.
|
||||
let serverSourceMode = false;
|
||||
|
||||
// Map to store editor instances for each file
|
||||
// Key: file path, Value: { editor, container, tocContainer, etc. }
|
||||
const editorInstances = new Map();
|
||||
|
|
@ -22,3 +26,18 @@ let tocMaxDepth = 3;
|
|||
|
||||
// Scratchpad ID constant
|
||||
const SCRATCHPAD_ID = '__scratchpad__';
|
||||
|
||||
// Default scratchpad markdown — shown the first time mdedit loads.
|
||||
// Acts as both a welcome message and a starter pad for quick notes.
|
||||
const SCRATCHPAD_WELCOME = [
|
||||
'# Welcome to ZDDC Markdown',
|
||||
'',
|
||||
'All editing happens locally on your computer — nothing is uploaded.',
|
||||
'',
|
||||
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
|
||||
'button on the Scratchpad row in the file list.',
|
||||
'',
|
||||
'Click **Select Directory** above to open a folder of Markdown files,',
|
||||
'or just start typing here.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
|
|
|||
|
|
@ -64,21 +64,23 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
|
|||
|
||||
// Determine if this is a scratchpad (no file handle)
|
||||
const isScratchpad = !fileHandle;
|
||||
const isReadOnlyHandle = !!(fileHandle && fileHandle._readOnly);
|
||||
|
||||
// Save button (or Save As for scratchpads)
|
||||
// Save button (or Save As for scratchpads / read-only server files)
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'btn btn-primary btn-sm';
|
||||
saveButton.textContent = isScratchpad ? 'Save As...' : 'Save File';
|
||||
saveButton.disabled = !isScratchpad; // Scratchpads can always save
|
||||
saveButton.textContent = (isScratchpad || isReadOnlyHandle) ? 'Save As...' : 'Save File';
|
||||
saveButton.disabled = !isScratchpad; // Scratchpads can always save; read-only enables on edit
|
||||
buttonContainer.appendChild(saveButton);
|
||||
|
||||
// Reload button (only for files, not scratchpads)
|
||||
// Reload button (only for files, not scratchpads) — icon to match file-tree refresh
|
||||
let reloadButton = null;
|
||||
if (!isScratchpad) {
|
||||
reloadButton = document.createElement('button');
|
||||
reloadButton.className = 'btn btn-secondary btn-sm';
|
||||
reloadButton.textContent = 'Reload from Disk';
|
||||
reloadButton.title = 'Reload file from disk (discards unsaved changes)';
|
||||
reloadButton.textContent = '↻';
|
||||
reloadButton.title = 'Reload from disk (discards unsaved changes)';
|
||||
reloadButton.setAttribute('aria-label', 'Reload from disk');
|
||||
buttonContainer.appendChild(reloadButton);
|
||||
}
|
||||
|
||||
|
|
@ -103,16 +105,17 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
|
|||
tocPane.style.width = '325px';
|
||||
tocPane.style.minWidth = '150px';
|
||||
|
||||
// Front matter nav bar (collapsible)
|
||||
// Front matter section (collapsible, height-resizable)
|
||||
const frontMatterNav = document.createElement('div');
|
||||
frontMatterNav.className = 'front-matter-nav border-b border-gray-200 dark:border-gray-700';
|
||||
frontMatterNav.className = 'front-matter-nav';
|
||||
frontMatterNav.style.height = '180px';
|
||||
|
||||
const frontMatterHeader = document.createElement('div');
|
||||
frontMatterHeader.className = 'front-matter-header px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center gap-2';
|
||||
frontMatterHeader.className = 'front-matter-header pane-section-header cursor-pointer';
|
||||
|
||||
const toggleIcon = document.createElement('span');
|
||||
toggleIcon.textContent = '▼';
|
||||
toggleIcon.className = 'toggle-icon text-sm';
|
||||
toggleIcon.className = 'toggle-icon';
|
||||
frontMatterHeader.appendChild(toggleIcon);
|
||||
|
||||
const headerText = document.createElement('span');
|
||||
|
|
@ -121,11 +124,13 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
|
|||
|
||||
frontMatterNav.appendChild(frontMatterHeader);
|
||||
|
||||
const frontMatterContent = document.createElement('div');
|
||||
frontMatterContent.className = 'front-matter-content';
|
||||
|
||||
frontMatterTextarea = document.createElement('textarea');
|
||||
frontMatterTextarea.className = 'front-matter-textarea w-full px-4 py-2 text-sm focus:outline-none resize-none overflow-x-auto';
|
||||
frontMatterTextarea.className = 'front-matter-textarea';
|
||||
frontMatterTextarea.placeholder = 'title: Document Title\ndate: 2024-01-01\ntags: [example]';
|
||||
|
||||
// Set front matter content
|
||||
if (frontMatterData && Object.keys(frontMatterData).length > 0) {
|
||||
try {
|
||||
let yamlText = '';
|
||||
|
|
@ -143,11 +148,21 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
|
|||
}
|
||||
}
|
||||
|
||||
frontMatterNav.appendChild(frontMatterTextarea);
|
||||
frontMatterContent.appendChild(frontMatterTextarea);
|
||||
frontMatterNav.appendChild(frontMatterContent);
|
||||
tocPane.appendChild(frontMatterNav);
|
||||
|
||||
// Horizontal resizer between front-matter and TOC
|
||||
const fmTocResizer = document.createElement('div');
|
||||
fmTocResizer.className = 'pane-resizer horizontal';
|
||||
tocPane.appendChild(fmTocResizer);
|
||||
|
||||
// TOC section
|
||||
const tocSection = document.createElement('div');
|
||||
tocSection.className = 'toc-section';
|
||||
|
||||
const tocHeader = document.createElement('div');
|
||||
tocHeader.className = 'toc-header px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
tocHeader.className = 'toc-header pane-section-header';
|
||||
|
||||
const tocTitle = document.createElement('span');
|
||||
tocTitle.textContent = 'Table of Contents';
|
||||
|
|
@ -165,42 +180,41 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
|
|||
`;
|
||||
tocHeader.appendChild(tocDepthSelector);
|
||||
|
||||
tocPane.appendChild(tocHeader);
|
||||
tocSection.appendChild(tocHeader);
|
||||
|
||||
tocContainer = document.createElement('div');
|
||||
tocContainer.className = 'toc-container toc-content p-4 h-full overflow-auto';
|
||||
tocPane.appendChild(tocContainer);
|
||||
tocContainer.className = 'toc-container toc-content';
|
||||
tocSection.appendChild(tocContainer);
|
||||
|
||||
// Set up TOC container overflow when front matter is toggled
|
||||
tocPane.appendChild(tocSection);
|
||||
|
||||
// Toggle: collapsed only shows the header. Hide content + horizontal resizer.
|
||||
let fmIsCollapsed = false;
|
||||
frontMatterHeader.addEventListener('click', () => {
|
||||
fmIsCollapsed = !fmIsCollapsed;
|
||||
frontMatterNav.classList.toggle('collapsed', fmIsCollapsed);
|
||||
toggleIcon.textContent = fmIsCollapsed ? '▶' : '▼';
|
||||
fmTocResizer.style.display = fmIsCollapsed ? 'none' : '';
|
||||
if (fmIsCollapsed) {
|
||||
frontMatterNav.style.height = '';
|
||||
} else {
|
||||
frontMatterNav.style.height = '180px';
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-size textarea: no vertical scroll, horizontal scroll for long lines
|
||||
frontMatterTextarea.style.overflowY = 'hidden';
|
||||
frontMatterTextarea.style.overflowX = 'auto';
|
||||
const autoResizeFm = () => {
|
||||
frontMatterTextarea.style.height = 'auto';
|
||||
frontMatterTextarea.style.height = frontMatterTextarea.scrollHeight + 'px';
|
||||
};
|
||||
frontMatterTextarea.addEventListener('input', autoResizeFm);
|
||||
// Defer initial resize until element is in the DOM and has layout
|
||||
requestAnimationFrame(() => requestAnimationFrame(autoResizeFm));
|
||||
|
||||
editorArea.appendChild(tocPane);
|
||||
|
||||
// TOC resizer
|
||||
// Vertical resizer between toc-pane and editor (placed inside editorArea)
|
||||
const tocResizer = document.createElement('div');
|
||||
tocResizer.className = 'pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500';
|
||||
tocResizer.setAttribute('data-resizer-for', 'toc-pane');
|
||||
contentArea.appendChild(tocResizer);
|
||||
editorArea.appendChild(tocResizer);
|
||||
|
||||
makeResizable(tocResizer, tocPane);
|
||||
|
||||
// TOC depth selector event
|
||||
// Make the front-matter / TOC split height-adjustable
|
||||
makeHeightResizable(fmTocResizer, frontMatterNav, tocPane);
|
||||
|
||||
tocDepthSelector.addEventListener('change', function () {
|
||||
const depth = parseInt(this.value);
|
||||
if (window.updateToc && editorInstance) {
|
||||
|
|
@ -274,6 +288,8 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
|
|||
updateUnsavedCount();
|
||||
}
|
||||
saveButton.disabled = false;
|
||||
|
||||
if (filePath === SCRATCHPAD_ID) updateScratchpadDownloadState();
|
||||
});
|
||||
|
||||
// Scroll listener for TOC highlighting
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ function openScratchpad() {
|
|||
document.getElementById('welcome-screen').classList.add('hidden');
|
||||
document.getElementById('content-container').classList.remove('hidden');
|
||||
|
||||
// Initialize editor with no file handle
|
||||
initializeEditor('', true, SCRATCHPAD_ID, 'Scratchpad', null, null);
|
||||
// Initialize editor with the welcome text seeded as the starting content.
|
||||
initializeEditor(SCRATCHPAD_WELCOME, true, SCRATCHPAD_ID, 'Scratchpad', null, null);
|
||||
|
||||
// Mark as scratchpad
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
|
|
@ -35,9 +35,63 @@ function openScratchpad() {
|
|||
instance.isScratchpad = true;
|
||||
}
|
||||
|
||||
// Reflect non-empty starting content on the scratchpad row's download button.
|
||||
updateScratchpadDownloadState();
|
||||
|
||||
if (DEBUG) console.log('Opened scratchpad');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the scratchpad-row download button based on whether the
|
||||
* scratchpad currently holds any content. Idempotent — safe to call from
|
||||
* editor change listeners.
|
||||
*/
|
||||
function updateScratchpadDownloadState() {
|
||||
const btn = document.getElementById('scratchpad-download-btn');
|
||||
if (!btn) return;
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
let hasContent = false;
|
||||
if (instance && instance.editor) {
|
||||
try {
|
||||
hasContent = (instance.editor.getMarkdown() || '').trim().length > 0;
|
||||
} catch (_) { /* editor may not be ready yet */ }
|
||||
}
|
||||
btn.disabled = !hasContent;
|
||||
btn.classList.toggle('is-disabled', !hasContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of the current scratchpad markdown.
|
||||
* No-op if the scratchpad has no content.
|
||||
*/
|
||||
function downloadScratchpad() {
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
if (!instance || !instance.editor) return;
|
||||
let content = '';
|
||||
try { content = instance.editor.getMarkdown() || ''; } catch (_) { return; }
|
||||
|
||||
// Pull front matter from the textarea if any
|
||||
if (instance.frontMatterTextarea) {
|
||||
const fmText = instance.frontMatterTextarea.value.trim();
|
||||
if (fmText) content = `---\n${fmText}\n---\n${content}`;
|
||||
}
|
||||
|
||||
if (!content.trim()) return;
|
||||
|
||||
// Suggest a filename derived from the first H1 if present
|
||||
let suggested = 'scratchpad.md';
|
||||
const h1 = content.match(/^#\s+(.+)$/m);
|
||||
if (h1) {
|
||||
const slug = h1[1].trim().toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.substring(0, 60);
|
||||
if (slug) suggested = `${slug}.md`;
|
||||
}
|
||||
|
||||
saveFileAs(content, suggested);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file using Save As dialog (for scratchpads or new saves)
|
||||
* @param {string} content - Content to save
|
||||
|
|
@ -266,6 +320,17 @@ async function saveFile(filePath) {
|
|||
? stringifyFrontMatter(markdownContent, frontMatterData)
|
||||
: markdownContent;
|
||||
|
||||
// Server-mode files are read-only: fall back to a Save-As download.
|
||||
if (typeof fileHandle.createWritable !== 'function') {
|
||||
const downloadName = (fileHandle.name || filePath.split('/').pop() || 'untitled.md');
|
||||
await saveFileAs(finalContent, downloadName);
|
||||
editorInstance.isDirty = false;
|
||||
updateFileDirtyStatus(filePath, false);
|
||||
updateUnsavedCount();
|
||||
if (editorInstance.saveButton) editorInstance.saveButton.disabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Write to file
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(finalContent);
|
||||
|
|
@ -460,6 +525,10 @@ async function applyAfterSaveHooks(frontMatter, markdownContent, fileHandle) {
|
|||
* Refresh directory from disk without losing unsaved work
|
||||
*/
|
||||
async function refreshDirectory() {
|
||||
if (serverSourceMode) {
|
||||
await loadServerDirectory();
|
||||
return;
|
||||
}
|
||||
if (!directoryHandle) {
|
||||
if (DEBUG) console.log('No directory selected, cannot refresh');
|
||||
return;
|
||||
|
|
@ -494,6 +563,144 @@ async function refreshDirectory() {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic, read-only "file handle" backed by a URL.
|
||||
* Implements `getFile()` so the rest of the app (which only needs to read)
|
||||
* works without changes. Lacks `createWritable()` — saveFile detects this
|
||||
* and routes to a Save-As download.
|
||||
*/
|
||||
function createServerFileHandle(name, url) {
|
||||
let cached = null;
|
||||
return {
|
||||
kind: 'file',
|
||||
name,
|
||||
_serverUrl: url,
|
||||
_readOnly: true,
|
||||
async getFile() {
|
||||
if (cached) return cached;
|
||||
const resp = await fetch(url, { cache: 'no-cache' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
|
||||
const lastMod = resp.headers.get('Last-Modified');
|
||||
const lastModified = lastMod ? Date.parse(lastMod) : Date.now();
|
||||
const blob = await resp.blob();
|
||||
cached = new File([blob], name, { type: blob.type, lastModified });
|
||||
return cached;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic directory handle (read-only) backed by a server URL.
|
||||
* Returned for nested entries so existing code paths that probe for `.handle`
|
||||
* still work; not currently used for traversal.
|
||||
*/
|
||||
function createServerDirectoryHandle(name, url) {
|
||||
return {
|
||||
kind: 'directory',
|
||||
name,
|
||||
_serverUrl: url,
|
||||
_readOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fetch the JSON directory listing for `dirUrl` and populate
|
||||
* `parentNode.entries` with synthetic handles. Returns folder/file counts.
|
||||
* Uses the same Caddy/zddc-server JSON shape archive's source.js consumes.
|
||||
*/
|
||||
async function readServerDirectory(dirUrl, parentNode, depth) {
|
||||
if (depth > 10) return { folderCount: 0, fileCount: 0 };
|
||||
|
||||
let items;
|
||||
try {
|
||||
const resp = await fetch(dirUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
items = await resp.json();
|
||||
if (!Array.isArray(items)) throw new Error('Expected JSON array');
|
||||
} catch (err) {
|
||||
if (DEBUG) console.warn(`Server listing failed for ${dirUrl}:`, err);
|
||||
return { folderCount: 0, fileCount: 0 };
|
||||
}
|
||||
|
||||
const stats = { folderCount: 0, fileCount: 0 };
|
||||
const subdirPromises = [];
|
||||
|
||||
for (const item of items) {
|
||||
const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
|
||||
if (rawName.startsWith('.') || rawName.startsWith('_')) continue;
|
||||
|
||||
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
|
||||
const childUrl = base + encodeURIComponent(rawName) + (item.is_dir ? '/' : '');
|
||||
|
||||
if (item.is_dir) {
|
||||
const dirNode = {
|
||||
name: rawName,
|
||||
type: 'directory',
|
||||
handle: createServerDirectoryHandle(rawName, childUrl),
|
||||
entries: {},
|
||||
};
|
||||
parentNode.entries[rawName] = dirNode;
|
||||
stats.folderCount++;
|
||||
subdirPromises.push(
|
||||
readServerDirectory(childUrl, dirNode, depth + 1).then((s) => {
|
||||
stats.folderCount += s.folderCount;
|
||||
stats.fileCount += s.fileCount;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
parentNode.entries[rawName] = {
|
||||
name: rawName,
|
||||
type: 'file',
|
||||
handle: createServerFileHandle(rawName, childUrl),
|
||||
};
|
||||
stats.fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(subdirPromises);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect HTTP context, fetch the directory the page lives under, and render
|
||||
* the resulting subtree in the file pane. Idempotent — safe to re-call.
|
||||
*/
|
||||
async function loadServerDirectory() {
|
||||
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
|
||||
serverSourceMode = true;
|
||||
|
||||
let href = window.location.href.split('?')[0].split('#')[0];
|
||||
const lastSlash = href.lastIndexOf('/');
|
||||
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
|
||||
|
||||
const rootName = (() => {
|
||||
const path = baseUrl.replace(/\/$/, '');
|
||||
const seg = path.substring(path.lastIndexOf('/') + 1);
|
||||
return seg || baseUrl;
|
||||
})();
|
||||
|
||||
fileTree = {
|
||||
name: rootName,
|
||||
type: 'directory',
|
||||
handle: createServerDirectoryHandle(rootName, baseUrl),
|
||||
entries: {},
|
||||
};
|
||||
|
||||
// Surface refresh, hide write-only controls
|
||||
const refreshBtn = document.getElementById('refresh-directory');
|
||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
|
||||
const selectDirBtn = document.getElementById('select-directory');
|
||||
if (selectDirBtn) {
|
||||
selectDirBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
const stats = await readServerDirectory(baseUrl, fileTree, 0);
|
||||
renderFileTree();
|
||||
updateStatusCounts(stats.folderCount, stats.fileCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring files for external changes
|
||||
*/
|
||||
|
|
@ -503,6 +710,7 @@ function startFileChangeMonitoring() {
|
|||
try {
|
||||
const fileHandle = editorInstance.fileHandle;
|
||||
if (!fileHandle) continue;
|
||||
if (fileHandle._readOnly) continue;
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
const currentLastModified = file.lastModified;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ function createActionButtons(filePath, type) {
|
|||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'tree-actions';
|
||||
|
||||
// Server mode is read-only: no rename, delete, or new-file actions.
|
||||
if (serverSourceMode) return actionsDiv;
|
||||
|
||||
if (type === 'directory') {
|
||||
// Directory: + (new file) + ✕ (delete)
|
||||
const newFileBtn = document.createElement('button');
|
||||
|
|
@ -91,22 +94,46 @@ function renderFileTree() {
|
|||
|
||||
// Always show scratchpad at top
|
||||
const scratchpadElement = document.createElement('div');
|
||||
scratchpadElement.className = 'file-item px-2 py-1 cursor-pointer rounded whitespace-nowrap overflow-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2';
|
||||
scratchpadElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2';
|
||||
scratchpadElement.dataset.type = 'file';
|
||||
scratchpadElement.dataset.path = SCRATCHPAD_ID;
|
||||
scratchpadElement.dataset.name = 'Scratchpad';
|
||||
scratchpadElement.innerHTML = '<div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick editing (no file)</div>';
|
||||
|
||||
const scratchLabel = document.createElement('span');
|
||||
scratchLabel.className = 'tree-row__label';
|
||||
scratchLabel.innerHTML = '<div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div>';
|
||||
scratchpadElement.appendChild(scratchLabel);
|
||||
|
||||
const scratchActions = document.createElement('div');
|
||||
scratchActions.className = 'tree-actions tree-actions--always';
|
||||
|
||||
const scratchDownloadBtn = document.createElement('button');
|
||||
scratchDownloadBtn.id = 'scratchpad-download-btn';
|
||||
scratchDownloadBtn.className = 'tree-btn';
|
||||
scratchDownloadBtn.title = 'Download scratchpad as a Markdown file';
|
||||
scratchDownloadBtn.setAttribute('aria-label', 'Download scratchpad');
|
||||
scratchDownloadBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>';
|
||||
scratchDownloadBtn.disabled = true;
|
||||
scratchDownloadBtn.classList.add('is-disabled');
|
||||
scratchDownloadBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (scratchDownloadBtn.disabled) return;
|
||||
downloadScratchpad();
|
||||
};
|
||||
scratchActions.appendChild(scratchDownloadBtn);
|
||||
scratchpadElement.appendChild(scratchActions);
|
||||
|
||||
scratchpadElement.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openScratchpad();
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active-file'));
|
||||
scratchpadElement.classList.add('active-file');
|
||||
updateScratchpadDownloadState();
|
||||
});
|
||||
|
||||
fileTreeElement.appendChild(scratchpadElement);
|
||||
// Sync button state with current scratchpad content (re-renders preserve it)
|
||||
updateScratchpadDownloadState();
|
||||
|
||||
function createFileTreeHTML(directory, parentElement, path = '') {
|
||||
if (!directory || !directory.entries) return;
|
||||
|
|
@ -136,7 +163,14 @@ function renderFileTree() {
|
|||
dirIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
|
||||
|
||||
const dirName = document.createElement('span');
|
||||
dirName.className = 'tree-row__name';
|
||||
const parsedFolder = zddc.parseFolder(name);
|
||||
if (parsedFolder && parsedFolder.valid) {
|
||||
const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`;
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
|
||||
} else {
|
||||
dirName.textContent = `📁 ${name}`;
|
||||
}
|
||||
|
||||
const dirLabel = document.createElement('span');
|
||||
dirLabel.className = 'tree-row__label';
|
||||
|
|
@ -178,18 +212,16 @@ function renderFileTree() {
|
|||
let fileNameDisplay;
|
||||
const parsed = zddc.parseFilename(name);
|
||||
if (parsed && parsed.valid) {
|
||||
// Strip extension from title for display (it's already in the icon)
|
||||
const titleDisplay = parsed.title;
|
||||
const metaDisplay = `${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`;
|
||||
const titleDisplay = escapeHtml(parsed.title);
|
||||
const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`);
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
} else if (name.includes(' - ')) {
|
||||
// Fallback: simple split for files with ' - ' but not fully ZDDC-compliant
|
||||
const dashIdx = name.lastIndexOf(' - ');
|
||||
const secondary = name.substring(0, dashIdx);
|
||||
const primary = name.substring(dashIdx + 3).replace(/\.[^.]+$/, '');
|
||||
const secondary = escapeHtml(name.substring(0, dashIdx));
|
||||
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
} else {
|
||||
fileNameDisplay = `<span>${fileIcon} ${name}</span>`;
|
||||
fileNameDisplay = `<span>${fileIcon} ${escapeHtml(name)}</span>`;
|
||||
}
|
||||
|
||||
const fileLabel = document.createElement('span');
|
||||
|
|
|
|||
|
|
@ -15,7 +15,17 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
// Show scratchpad in file tree on startup
|
||||
renderFileTree();
|
||||
|
||||
// Always start with scratchpad selected and loaded
|
||||
openScratchpad();
|
||||
const scratchpadEl = document.querySelector(`.file-item[data-path="${SCRATCHPAD_ID}"]`);
|
||||
if (scratchpadEl) scratchpadEl.classList.add('active-file');
|
||||
|
||||
// In server (HTTP) mode, fetch and render the current directory subtree.
|
||||
if (location.protocol === 'http:' || location.protocol === 'https:') {
|
||||
loadServerDirectory().catch((err) => {
|
||||
if (DEBUG) console.warn('Server directory load failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -44,6 +44,50 @@ function makeResizable(resizer, pane) {
|
|||
resizer.addEventListener('mousedown', mouseDownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a horizontal split height-adjustable: the resizer drags the height
|
||||
* of `topPane` while it remains a sibling of the bottom section inside `container`.
|
||||
*
|
||||
* @param {HTMLElement} resizer - The horizontal resizer between the panes
|
||||
* @param {HTMLElement} topPane - The pane whose height is set
|
||||
* @param {HTMLElement} container - The flex column containing both panes
|
||||
*/
|
||||
function makeHeightResizable(resizer, topPane, container) {
|
||||
let y = 0;
|
||||
let topHeight = 0;
|
||||
let containerHeight = 0;
|
||||
|
||||
const mouseDownHandler = (e) => {
|
||||
y = e.clientY;
|
||||
topHeight = topPane.offsetHeight;
|
||||
containerHeight = container.offsetHeight;
|
||||
document.addEventListener('mousemove', mouseMoveHandler);
|
||||
document.addEventListener('mouseup', mouseUpHandler);
|
||||
resizer.classList.add('active');
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const mouseMoveHandler = (e) => {
|
||||
const dy = e.clientY - y;
|
||||
// Reserve at least 80px for the bottom pane (TOC); cap top at containerHeight - 80.
|
||||
const minTop = 60;
|
||||
const maxTop = Math.max(minTop, containerHeight - 100);
|
||||
const newHeight = Math.max(minTop, Math.min(maxTop, topHeight + dy));
|
||||
topPane.style.height = `${newHeight}px`;
|
||||
};
|
||||
|
||||
const mouseUpHandler = () => {
|
||||
document.removeEventListener('mousemove', mouseMoveHandler);
|
||||
document.removeEventListener('mouseup', mouseUpHandler);
|
||||
resizer.classList.remove('active');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
resizer.addEventListener('mousedown', mouseDownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the file navigation pane resizer
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@
|
|||
* Utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTML-escape a string for safe insertion into innerHTML.
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text == null ? '' : String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function calls
|
||||
* @param {Function} func - Function to debounce
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Markdown</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Toast UI Editor v3.2.2 -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.2.2/toastui-editor.min.css">
|
||||
|
|
@ -49,11 +50,9 @@
|
|||
<div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div>
|
||||
|
||||
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
|
||||
<div id="welcome-screen" class="welcome-screen flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
|
||||
<h2 class="mb-2 text-xl">Welcome to ZDDC Markdown</h2>
|
||||
<p class="mb-4">All files are edited on your local computer.</p>
|
||||
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> to start editing,<br>or <strong>Select Directory</strong> to work with files.</p>
|
||||
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and save via download.</p>
|
||||
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
|
||||
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Select Directory</strong> to work with files.</p>
|
||||
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
|
||||
</div>
|
||||
|
||||
<div id="content-container" class="content-container flex flex-col h-full hidden">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ export default defineConfig({
|
|||
name: 'archive',
|
||||
testMatch: 'archive.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'landing',
|
||||
testMatch: 'landing.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'transmittal',
|
||||
testMatch: 'transmittal.spec.js',
|
||||
|
|
|
|||
|
|
@ -38,11 +38,13 @@
|
|||
# plain build clobbers it.
|
||||
#
|
||||
# Channels and release args:
|
||||
# <none> dev build, dist/ only, label "Built: <ts> BETA" (red).
|
||||
# <none> dev build, dist/ + alpha mirror, label "alpha · <ts> · <sha>[-dirty]" (red).
|
||||
# Plain builds ARE alpha builds — every dev build cascades to the
|
||||
# alpha channel via update_alpha. Label reflects that.
|
||||
# --release stable, auto-bump patch from latest tag (or 0.0.1).
|
||||
# --release X.Y.Z stable, explicit version.
|
||||
# --release alpha alpha channel; mutable file, no tag, label "alpha · <date> · <sha>" (red).
|
||||
# --release beta beta channel; analogous to alpha.
|
||||
# --release alpha alpha channel snapshot at HEAD; label "alpha · <date> · <sha>" (red).
|
||||
# --release beta beta channel; label "beta · <date> · <sha>" (red); opt-in feedback channel.
|
||||
# --release <other> error.
|
||||
# =============================================================================
|
||||
|
||||
|
|
@ -73,6 +75,18 @@ concat_files() {
|
|||
# ISO UTC build timestamp — set once when this file is sourced
|
||||
build_timestamp=$(date -u +"%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Read shared/favicon.svg, base64-encode it, and assemble a data: URI suitable
|
||||
# for inlining into a <link rel="icon" type="image/svg+xml" href="..."> tag.
|
||||
# Set once at source time so every tool's build.sh can pick it up via the
|
||||
# $favicon_data_uri global. Editing shared/favicon.svg auto-propagates to all
|
||||
# tools on the next build.
|
||||
_favicon_path="$root_dir/../shared/favicon.svg"
|
||||
if [ -f "$_favicon_path" ]; then
|
||||
favicon_data_uri="data:image/svg+xml;base64,$(base64 -w 0 "$_favicon_path")"
|
||||
else
|
||||
favicon_data_uri=""
|
||||
fi
|
||||
|
||||
# Rewrite '</script' (case-insensitive) in JS as '<\/script' so the HTML parser
|
||||
# cannot mistake string contents for a closing </script> tag. Per the HTML5 spec
|
||||
# only </script terminates a <script> block — other tags like </div> are safe
|
||||
|
|
@ -122,7 +136,17 @@ compute_build_label() {
|
|||
build_version=""
|
||||
|
||||
if [ "$_flag" != "--release" ]; then
|
||||
build_label="Built: ${build_timestamp} BETA"
|
||||
# Plain builds mirror to website/releases/<tool>_alpha.html, so they ARE
|
||||
# alpha builds. Label format matches `--release alpha` but includes the
|
||||
# full timestamp (more granular than date) and a -dirty marker when the
|
||||
# working tree has uncommitted changes — useful when iterating before
|
||||
# commit.
|
||||
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")
|
||||
if ! git -C "$root_dir" diff --quiet HEAD 2>/dev/null; then
|
||||
_sha="${_sha}-dirty"
|
||||
fi
|
||||
channel="alpha"
|
||||
build_label="alpha · ${build_timestamp} · ${_sha}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
|
|
|||
8
shared/favicon.svg
Normal file
8
shared/favicon.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 293 B |
|
|
@ -87,41 +87,46 @@ test.describe('Archive Browser', () => {
|
|||
expect(fileCountText).toBeTruthy();
|
||||
});
|
||||
|
||||
test('projectFilter filters local-mode scan at root depth (virtual merge)', async ({ page }) => {
|
||||
// The corresponding URL flow is `archive.html?projects=A,B` →
|
||||
// url-state.restore() → window.app.projectFilter Set. We set the Set
|
||||
// directly here because the existing test environment hits an
|
||||
// unrelated init() error before url-state.restore() runs (a
|
||||
// getElementById returning null in events.js); a separate test would
|
||||
// be needed to re-confirm the URL-parsing path. The behavior under
|
||||
// test is the source.js depth-0 filter check, which only reads
|
||||
// window.app.projectFilter.
|
||||
test('Mode 1: ?projects=A,B enters each project\'s Archive subfolder', async ({ page }) => {
|
||||
// Multi-project layout: server root holds project folders, each containing an
|
||||
// Archive/ subfolder with third-party folders. ?projects=A,B (set as
|
||||
// window.app.projectFilter; url-state.restore() handles parsing in real flow)
|
||||
// selects the projects to scan. Each is descended into <project>/Archive/.
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
// Three top-level projects under one root; only A and B should be scanned.
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('combined-root', {
|
||||
'Project-A': {
|
||||
'Archive': {
|
||||
'ACME': {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
|
||||
'123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Project-B': {
|
||||
'Archive': {
|
||||
'Beta': {
|
||||
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
|
||||
'789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Project-C': {
|
||||
'Archive': {
|
||||
'Gamma': {
|
||||
'2025-03-01_345678-EM-TRN-0001 (IFC) - Third': {
|
||||
'345678-EL-SPC-0003_A (IFC) - SpecC.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Set projectFilter to {A, B}, mimicking what url-state.restore()
|
||||
// would do for a URL like archive.html?projects=Project-A,Project-B.
|
||||
await page.evaluate(() => {
|
||||
window.app.projectFilter = new Set(['Project-A', 'Project-B']);
|
||||
});
|
||||
|
|
@ -129,7 +134,17 @@ test.describe('Archive Browser', () => {
|
|||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Surface all files into the table.
|
||||
// Third-party (grouping) folders should be ACME and Beta — never project IDs or "Archive"
|
||||
const groupingFolders = await page.evaluate(() =>
|
||||
window.app.groupingFolders.map(f => f.name)
|
||||
);
|
||||
expect(groupingFolders).toContain('ACME');
|
||||
expect(groupingFolders).toContain('Beta');
|
||||
expect(groupingFolders).not.toContain('Project-A');
|
||||
expect(groupingFolders).not.toContain('Project-B');
|
||||
expect(groupingFolders).not.toContain('Archive');
|
||||
expect(groupingFolders).not.toContain('Gamma');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const cb = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (cb && !cb.checked) cb.click();
|
||||
|
|
@ -142,6 +157,335 @@ test.describe('Archive Browser', () => {
|
|||
expect(tableText).not.toContain('SpecC');
|
||||
});
|
||||
|
||||
test('same-name third-parties across projects merge into one party row', async ({ page }) => {
|
||||
// Both projects have a "BM" third-party folder. The parties pane must
|
||||
// show "BM" once, and selecting it must surface files from both projects.
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('combined-root', {
|
||||
'176109': {
|
||||
'Archive': {
|
||||
'BM': {
|
||||
'Issued': {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
|
||||
'123456-EL-SPC-0001_A (IFC) - SpecFromA.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'197072': {
|
||||
'Archive': {
|
||||
'BM': {
|
||||
'Issued': {
|
||||
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
|
||||
'789012-EL-SPC-0002_A (IFC) - SpecFromB.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.app.projectFilter = new Set(['176109', '197072']);
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// The DOM should render exactly one BM row, not two.
|
||||
const renderedNames = await page.locator('#groupingFoldersList .folder-item-name').allTextContents();
|
||||
const bmRows = renderedNames.filter(n => n.trim() === 'BM');
|
||||
expect(bmRows.length).toBe(1);
|
||||
|
||||
// Both projects' files should be visible under the single BM party.
|
||||
const tableText = await page.locator('#filesTableBody').textContent();
|
||||
expect(tableText).toContain('SpecFromA');
|
||||
expect(tableText).toContain('SpecFromB');
|
||||
});
|
||||
|
||||
test('toggling visibleProjects hides files without re-scanning', async ({ page }) => {
|
||||
// Two projects scanned. Toggling 197072 out of visibleProjects should
|
||||
// hide its files; window.app.files (the in-memory scan result) must NOT
|
||||
// shrink — only the visible/filtered set changes.
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('combined-root', {
|
||||
'176109': {
|
||||
'Archive': {
|
||||
'BM': {
|
||||
'Issued': {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
|
||||
'123456-EL-SPC-0001_A (IFC) - SpecFromA.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'197072': {
|
||||
'Archive': {
|
||||
'BM': {
|
||||
'Issued': {
|
||||
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
|
||||
'789012-EL-SPC-0002_A (IFC) - SpecFromB.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.app.projectFilter = new Set(['176109', '197072']);
|
||||
window.app.visibleProjects = new Set(['176109', '197072']);
|
||||
window.app.isMultiProject = true;
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const beforeFileCount = await page.evaluate(() => window.app.files.length);
|
||||
expect(beforeFileCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Toggle 197072 out of the visible set — must NOT shrink window.app.files.
|
||||
await page.evaluate(() => {
|
||||
window.app.visibleProjects = new Set(['176109']);
|
||||
window.app.modules.app.updateUI();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
});
|
||||
|
||||
const afterFileCount = await page.evaluate(() => window.app.files.length);
|
||||
expect(afterFileCount).toBe(beforeFileCount);
|
||||
|
||||
const tableText = await page.locator('#filesTableBody').textContent();
|
||||
expect(tableText).toContain('SpecFromA');
|
||||
expect(tableText).not.toContain('SpecFromB');
|
||||
|
||||
// Toggle 197072 back — file reappears immediately, no rescan needed.
|
||||
await page.evaluate(() => {
|
||||
window.app.visibleProjects = new Set(['176109', '197072']);
|
||||
window.app.modules.app.updateUI();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
});
|
||||
const tableTextAfter = await page.locator('#filesTableBody').textContent();
|
||||
expect(tableTextAfter).toContain('SpecFromA');
|
||||
expect(tableTextAfter).toContain('SpecFromB');
|
||||
});
|
||||
|
||||
test('?show= URL param round-trips: serialize emits it, restore reads it', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
// Serialize: visibleProjects ⊊ projectFilter → ?show= present
|
||||
const qsWithSubset = await page.evaluate(() => {
|
||||
window.app.projectFilter = new Set(['A', 'B', 'C']);
|
||||
window.app.visibleProjects = new Set(['A']);
|
||||
return window.app.modules.urlState.serialize();
|
||||
});
|
||||
expect(qsWithSubset).toContain('projects=A%2CB%2CC');
|
||||
expect(qsWithSubset).toContain('show=A');
|
||||
|
||||
// Serialize: visibleProjects equals projectFilter → ?show= omitted
|
||||
const qsAllVisible = await page.evaluate(() => {
|
||||
window.app.projectFilter = new Set(['A', 'B']);
|
||||
window.app.visibleProjects = new Set(['A', 'B']);
|
||||
return window.app.modules.urlState.serialize();
|
||||
});
|
||||
expect(qsAllVisible).not.toContain('show=');
|
||||
|
||||
// Restore: ?show= populates visibleProjects from URL.
|
||||
await page.goto(`file://${HTML_PATH}?projects=A,B,C&show=A,B`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
const restored = await page.evaluate(() => {
|
||||
// Re-run restore in case the early init() raced with DOM availability —
|
||||
// matches how the existing projectFilter test bypasses this path too.
|
||||
window.app.modules.urlState.restore();
|
||||
return {
|
||||
search: location.search,
|
||||
projectFilter: Array.from(window.app.projectFilter).sort(),
|
||||
visibleProjects: Array.from(window.app.visibleProjects).sort(),
|
||||
};
|
||||
});
|
||||
expect(restored.search).toBe('?projects=A,B,C&show=A,B');
|
||||
expect(restored.projectFilter).toEqual(['A', 'B', 'C']);
|
||||
expect(restored.visibleProjects).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
test('Mode 2: project-root with Archive child auto-descends into Archive only', async ({ page }) => {
|
||||
// Picked directory is a project root containing Archive/ plus Reviewing/.
|
||||
// The app should enter Archive/ automatically and ignore Reviewing/.
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('project-176109', {
|
||||
'Archive': {
|
||||
'ACME': {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
|
||||
'123456-EL-SPC-0001_A (IFC) - InArchive.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Reviewing': {
|
||||
'ShouldNotShow': {
|
||||
'2025-02-10_789012-EM-TRN-0001 (IFC) - HiddenStage': {
|
||||
'789012-EL-SPC-0002_A (IFC) - InReviewing.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const groupingFolders = await page.evaluate(() =>
|
||||
window.app.groupingFolders.map(f => f.name)
|
||||
);
|
||||
expect(groupingFolders).toContain('ACME');
|
||||
expect(groupingFolders).not.toContain('Archive');
|
||||
expect(groupingFolders).not.toContain('Reviewing');
|
||||
expect(groupingFolders).not.toContain('ShouldNotShow');
|
||||
|
||||
const fileNames = await page.evaluate(() => window.app.files.map(f => f.name));
|
||||
expect(fileNames.some(n => n.includes('InArchive'))).toBe(true);
|
||||
expect(fileNames.some(n => n.includes('InReviewing'))).toBe(false);
|
||||
});
|
||||
|
||||
test('Mode 3: in-archive layout (no projects=, no Archive child) uses today\'s scan', async ({ page }) => {
|
||||
// Picked directory IS the Archive folder — children are third-parties directly.
|
||||
// Behavior must match pre-change baseline.
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('Archive', {
|
||||
'ACME': {
|
||||
'Issued': {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
|
||||
'123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Beta': {
|
||||
'Received': {
|
||||
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
|
||||
'789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const groupingFolders = await page.evaluate(() =>
|
||||
window.app.groupingFolders.map(f => f.name)
|
||||
);
|
||||
expect(groupingFolders).toContain('ACME');
|
||||
expect(groupingFolders).toContain('Beta');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const cb = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (cb && !cb.checked) cb.click();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const tableText = await page.locator('#filesTableBody').textContent();
|
||||
expect(tableText).toContain('SpecA');
|
||||
expect(tableText).toContain('SpecB');
|
||||
});
|
||||
|
||||
test('disabled folder types are skipped at scan time (not just hidden in UI)', async ({ page }) => {
|
||||
// Default enabledFolderTypes is {issued, received}. The Incoming folder should
|
||||
// never be listed — its files must not appear in window.app.files at all.
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('Archive', {
|
||||
'ACME': {
|
||||
'Issued': {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
|
||||
'123456-EL-SPC-0001_A (IFC) - SpecIssued.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
'Incoming': {
|
||||
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
|
||||
'789012-EL-SPC-0002_A (IFC) - SpecIncoming.pdf': '%PDF',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const fileNames = await page.evaluate(() => window.app.files.map(f => f.name));
|
||||
expect(fileNames.some(n => n.includes('SpecIssued'))).toBe(true);
|
||||
// Incoming is in FOLDER_TYPE_NAMES but not in default enabledFolderTypes — must be skipped
|
||||
expect(fileNames.some(n => n.includes('SpecIncoming'))).toBe(false);
|
||||
});
|
||||
|
||||
test('Preview toggle is checked by default', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#filePreviewToggle', { timeout: 15000 });
|
||||
await expect(page.locator('#filePreviewToggle')).toBeChecked();
|
||||
});
|
||||
|
||||
test('default modifier filter selects only base and +C revisions', async ({ page }) => {
|
||||
// Files with mixed revision modifiers — by default only base + +C should
|
||||
// surface in the visible file table; +B (and any other non-+C modifier)
|
||||
// should be hidden until the user explicitly opts in.
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('test-project', {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - Mixed Mods': {
|
||||
'123456-EL-SPC-0001_A (IFC) - SpecBase.pdf': '%PDF',
|
||||
'123456-EL-SPC-0001_A+C1 (IFC) - SpecComment.pdf': '%PDF',
|
||||
'123456-EL-SPC-0001_A+B1 (IFC) - SpecScratch.pdf': '%PDF',
|
||||
'123456-EL-SPC-0001_A+D1 (IFC) - SpecDraft.pdf': '%PDF',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const cb = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (cb && !cb.checked) cb.click();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
available: Array.from(window.app.availableModifiers).sort(),
|
||||
selected: Array.from(window.app.selectedModifiers).sort(),
|
||||
visibleNames: window.app.filteredFiles.map(f => f.name),
|
||||
}));
|
||||
|
||||
// The dropdown still LISTS all modifiers found in the data…
|
||||
expect(state.available).toEqual(['+B', '+C', '+D', 'base']);
|
||||
// …but only base + +C are pre-selected by default.
|
||||
expect(state.selected).toEqual(['+C', 'base']);
|
||||
// Files with hidden modifier types must be absent from the table.
|
||||
expect(state.visibleNames.some(n => n.includes('SpecBase'))).toBe(true);
|
||||
expect(state.visibleNames.some(n => n.includes('SpecComment'))).toBe(true);
|
||||
expect(state.visibleNames.some(n => n.includes('SpecScratch'))).toBe(false);
|
||||
expect(state.visibleNames.some(n => n.includes('SpecDraft'))).toBe(false);
|
||||
});
|
||||
|
||||
test('parser module uses shared zddc helpers (not its own wrappers)', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#appContainer', { timeout: 15000 });
|
||||
|
|
|
|||
|
|
@ -37,16 +37,21 @@ for (const tool of tools) {
|
|||
await expect(el).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test(`dist file: label is a valid timestamp or version string`, async () => {
|
||||
test(`dist file: label is a valid channel or version string`, async () => {
|
||||
const html = fs.readFileSync(distPath, 'utf8');
|
||||
// BETA labels are wrapped in an inner <span style="color:red...">; non-BETA are bare text.
|
||||
// Channel labels (alpha/beta) are wrapped in an inner <span style="color:red...">;
|
||||
// stable version labels are bare text.
|
||||
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
|
||||
expect(match, 'build-timestamp element must have text content').toBeTruthy();
|
||||
const label = match[1];
|
||||
const isTimestamp = /^Built: 20\d\d-\d\d-\d\d \d\d:\d\d:\d\d( BETA)?$/.test(label);
|
||||
const isVersion = /^v\d+\.\d+\.\d+( BETA)?$/.test(label);
|
||||
expect(isTimestamp || isVersion,
|
||||
`Expected timestamp or version, got: "${label}"`
|
||||
// Plain dev builds and --release alpha|beta:
|
||||
// "alpha · 2026-04-29 00:50:17 · 714faf6-dirty" (plain, with timestamp + dirty marker)
|
||||
// "alpha · 2026-04-29 · 714faf6" (--release alpha, date only)
|
||||
// "beta · 2026-04-29 · 714faf6" (--release beta)
|
||||
const isChannel = /^(alpha|beta) · 20\d\d-\d\d-\d\d( \d\d:\d\d:\d\d)? · [0-9a-f]+(-dirty)?$/.test(label);
|
||||
const isVersion = /^v\d+\.\d+\.\d+$/.test(label);
|
||||
expect(isChannel || isVersion,
|
||||
`Expected channel or version label, got: "${label}"`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
139
tests/landing.spec.js
Normal file
139
tests/landing.spec.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
|
||||
const HTML_PATH = path.resolve('landing/dist/index.html');
|
||||
const FILE_URL = 'file://' + HTML_PATH;
|
||||
|
||||
// The landing page fetches its containing directory with Accept: application/json
|
||||
// to get the project list. Chromium refuses fetch() on file:// URLs entirely, so
|
||||
// page.route() can't help — we monkey-patch window.fetch in an init script and
|
||||
// drive the response from window.__mockProjects, set per-test.
|
||||
|
||||
const FETCH_MOCK = `
|
||||
window.__mockProjects = [];
|
||||
const realFetch = window.fetch;
|
||||
window.fetch = async function(url, opts) {
|
||||
const accept = opts && opts.headers && opts.headers.Accept;
|
||||
if (accept && String(accept).includes('json')) {
|
||||
return new Response(JSON.stringify(window.__mockProjects), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return realFetch.apply(this, arguments);
|
||||
};
|
||||
`;
|
||||
|
||||
const SAMPLE_PROJECTS = [
|
||||
{ name: '176109', url: '/176109/', title: 'Greenfield Substation' },
|
||||
{ name: '197072', url: '/197072/', title: 'Brownfield Tap' },
|
||||
{ name: '210045', url: '/210045/', title: '' },
|
||||
];
|
||||
|
||||
async function loadLandingWithProjects(page, projects) {
|
||||
await page.addInitScript({ content: FETCH_MOCK });
|
||||
await page.addInitScript(p => { window.__mockProjects = p; }, projects);
|
||||
await page.goto(FILE_URL, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
test.describe('Landing page', () => {
|
||||
|
||||
test('renders the welcome hero and a project table when projects come back', async ({ page }) => {
|
||||
await loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
||||
await page.waitForSelector('.project-table', { timeout: 5000 });
|
||||
|
||||
await expect(page.locator('.landing-hero h1')).toContainText(/Welcome/i);
|
||||
const rowCount = await page.locator('.project-table tbody tr').count();
|
||||
expect(rowCount).toBe(3);
|
||||
// Title column is shown because at least one project has a title.
|
||||
await expect(page.locator('th.project-table-title-col')).toBeVisible();
|
||||
await expect(page.locator('.project-table tbody')).toContainText('Greenfield Substation');
|
||||
await expect(page.locator('.project-table tbody')).toContainText('Brownfield Tap');
|
||||
// 210045 has no title — should render a dash placeholder, not the empty string.
|
||||
await expect(page.locator('.project-table-no-title')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('column filters narrow the table; URL is updated', async ({ page }) => {
|
||||
await loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
||||
await page.waitForSelector('.project-table', { timeout: 5000 });
|
||||
|
||||
await page.locator('input.column-filter[data-column="pn"]').fill('176');
|
||||
await page.waitForTimeout(150);
|
||||
const rowsAfterPn = await page.locator('.project-table tbody tr').count();
|
||||
expect(rowsAfterPn).toBe(1);
|
||||
await expect(page.locator('.project-table tbody')).toContainText('176109');
|
||||
|
||||
const search = await page.evaluate(() => location.search);
|
||||
expect(search).toContain('pn=176');
|
||||
|
||||
await page.locator('input.column-filter[data-column="pn"]').fill('');
|
||||
await page.locator('input.column-filter[data-column="pt"]').fill('Brown');
|
||||
await page.waitForTimeout(150);
|
||||
const rowsAfterPt = await page.locator('.project-table tbody tr').count();
|
||||
expect(rowsAfterPt).toBe(1);
|
||||
await expect(page.locator('.project-table tbody')).toContainText('Brownfield Tap');
|
||||
});
|
||||
|
||||
test('selecting projects enables Open Archive and writes ?projects=', async ({ page }) => {
|
||||
await loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
||||
await page.waitForSelector('.project-table', { timeout: 5000 });
|
||||
|
||||
await expect(page.locator('#openArchiveBtn')).toBeDisabled();
|
||||
|
||||
await page.locator('.project-table-row[data-name="176109"]').click();
|
||||
await expect(page.locator('#openArchiveBtn')).toBeEnabled();
|
||||
await expect(page.locator('#selectionSummary')).toContainText('1 project selected');
|
||||
|
||||
const search = await page.evaluate(() => location.search);
|
||||
expect(search).toContain('projects=176109');
|
||||
});
|
||||
|
||||
test('shows a friendly empty state when the server returns no projects', async ({ page }) => {
|
||||
await loadLandingWithProjects(page, []);
|
||||
await page.waitForSelector('.project-list-empty', { timeout: 5000 });
|
||||
|
||||
await expect(page.locator('.project-list-empty')).toContainText(/No projects to show/);
|
||||
await expect(page.locator('.project-list-empty')).toContainText(/access/i);
|
||||
await expect(page.locator('#openArchiveBtn')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('save / load named preset round-trips selection + filters', async ({ page }) => {
|
||||
await loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
||||
await page.waitForSelector('.project-table', { timeout: 5000 });
|
||||
|
||||
// Set up state: pick 176109 and filter title by "Green".
|
||||
await page.locator('.project-table-row[data-name="176109"]').click();
|
||||
await page.locator('input.column-filter[data-column="pt"]').fill('Green');
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// Save preset (open the split-button caret menu).
|
||||
await page.locator('#openArchiveMenuBtn').click();
|
||||
await page.locator('button:has-text("Save current as preset")').click();
|
||||
await page.locator('#presetNameInput').fill('My View');
|
||||
await page.locator('button:has-text("Save")').click();
|
||||
|
||||
// Clear state so we can verify preset application restores it.
|
||||
// (Manual clear: unclick the row and empty the title filter.)
|
||||
await page.locator('.project-table-row[data-name="176109"]').click();
|
||||
await page.locator('input.column-filter[data-column="pt"]').fill('');
|
||||
await page.waitForTimeout(150);
|
||||
// Sanity: nothing selected, no filter.
|
||||
const cleared = await page.evaluate(() => ({
|
||||
selectedRows: document.querySelectorAll('.project-table-row.is-selected').length,
|
||||
ptValue: document.querySelector('input.column-filter[data-column="pt"]').value,
|
||||
}));
|
||||
expect(cleared.selectedRows).toBe(0);
|
||||
expect(cleared.ptValue).toBe('');
|
||||
|
||||
// Open the menu and click "Load" on the preset (apply-stay variant —
|
||||
// clicking the preset name itself would navigate to archive.html).
|
||||
await page.locator('#openArchiveMenuBtn').click();
|
||||
await page.waitForTimeout(100);
|
||||
await page.locator('.preset-menu-item:has(.preset-menu-item-name:has-text("My View")) .preset-load-btn').click();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await expect(page.locator('.project-table-row[data-name="176109"]')).toHaveClass(/is-selected/);
|
||||
const ptVal = await page.locator('input.column-filter[data-column="pt"]').inputValue();
|
||||
expect(ptVal).toBe('Green');
|
||||
});
|
||||
});
|
||||
|
|
@ -15,8 +15,9 @@ test.describe('Markdown Editor', () => {
|
|||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#app', { timeout: 15000 });
|
||||
|
||||
// Welcome screen is shown before any directory is opened
|
||||
await expect(page.locator('#welcome-screen')).toBeVisible();
|
||||
// Scratchpad opens by default with welcome content seeded into the editor.
|
||||
await expect(page.locator(`.file-item[data-path="__scratchpad__"]`)).toBeVisible();
|
||||
await expect(page.locator('#content-container')).toBeVisible();
|
||||
|
||||
// Select Directory button is present and enabled
|
||||
const selectDirBtn = page.locator('#select-directory');
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ escape_js_close_tags "$readme_file" "$md_temp"
|
|||
|
||||
compute_build_label "transmittal" "${1:-}" "${2:-}"
|
||||
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v md_file="$md_temp" -v build_label="$build_label" -v is_red="$is_red" '
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v md_file="$md_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" '
|
||||
BEGIN {
|
||||
css_inserted = 0
|
||||
js_inserted = 0
|
||||
|
|
@ -140,6 +140,11 @@ awk -v css_file="$css_temp" -v js_file="$js_temp" -v md_file="$md_temp" -v build
|
|||
print
|
||||
next
|
||||
}
|
||||
/\{\{FAVICON\}\}/ {
|
||||
gsub(/\{\{FAVICON\}\}/, favicon_uri)
|
||||
print
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!css_inserted) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Transmittal</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/forms.css">
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Classifier</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
|
||||
<style>
|
||||
/* ==========================================================================
|
||||
ZDDC Shared Base — single source of truth for tokens and primitives
|
||||
|
|
@ -1357,7 +1358,7 @@ body.help-open .app-header {
|
|||
<div class="header-left">
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">Built: 2026-04-28 23:40:19 BETA</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:13 · cf4101b-dirty</span></span>
|
||||
</div>
|
||||
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
||||
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Markdown</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
|
||||
<!-- Toast UI Editor v3.2.2 -->
|
||||
<style>
|
||||
/*!
|
||||
|
|
@ -1649,7 +1650,7 @@ body.help-open .app-header {
|
|||
<div class="header-left">
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Markdown</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">Built: 2026-04-28 23:40:19 BETA</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:13 · cf4101b-dirty</span></span>
|
||||
</div>
|
||||
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2179,6 +2179,7 @@ dialog.modal--narrow {
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Transmittal</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
|
||||
</head>
|
||||
|
||||
<body class="font-sans text-gray-900">
|
||||
|
|
@ -2191,7 +2192,7 @@ dialog.modal--narrow {
|
|||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">Built: 2026-04-28 23:40:18 BETA</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:12 · cf4101b-dirty</span></span>
|
||||
</div>
|
||||
<div class="app-header__spacer"></div>
|
||||
<div class="app-header__icons">
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in a new issue