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:
ZDDC 2026-04-29 12:52:27 -05:00
parent e44ccc3500
commit c95f07966d
50 changed files with 4329 additions and 1569 deletions

View file

@ -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.

View file

@ -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 }

View file

@ -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;

View file

@ -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();
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;
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)) {
return window.app.enabledFolderTypes.has(folderType);
}
// Transmittal is directly under the party (no folder type level) — always show
return true;
}
// Party not selected
return false;
}
@ -693,15 +764,29 @@
// Collect all unique modifiers from files
function collectModifiers() {
window.app.availableModifiers.clear();
window.app.files.forEach(file => {
const modType = getModifierType(file.revision);
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,

View file

@ -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);
}
});

View file

@ -8,11 +8,17 @@ function applyFilters() {
if (window.app.groupingFolders.length > 0 && window.app.selectedGroupingFolders.size === 0) {
return false;
}
// Must have at least one transmittal folder selected
if (window.app.selectedTransmittalFolders.size === 0) {
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)) {
@ -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;

View file

@ -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: {}
};

View file

@ -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>'
);
}).join('');
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));
}
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
);
dropdown.innerHTML =
'<div class="preset-section-bottom">'
+ '<div class="preset-section-label">Projects:</div>'
+ '<div class="preset-projects-list">' + projectsHtml + '</div>'
+ '</div>';
}
// Toggle dropdown visibility
function toggleDropdown() {
var dropdown = document.getElementById('presetDropdown');
if (isOpen) {
closeDropdown();
} else {
isOpen = true;
if (dropdown) dropdown.classList.remove('hidden');
renderDropdown();
}
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() {}
};
})();

View file

@ -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;

View file

@ -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.
@ -92,7 +140,12 @@
});
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;
}

View file

@ -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();
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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"
}

View file

@ -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 }

View file

@ -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}}

View file

@ -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 }

View file

@ -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);
}

View file

@ -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>';
try {
var resp = await fetch(location.origin + location.pathname.replace(/\/[^\/]*$/, '/'), {
headers: { 'Accept': 'application/json' }
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
accessibleProjects = await resp.json();
} catch (e) {
projectList.innerHTML = '<div class="project-list-empty">Could not load project list: ' + escapeHtml(e.message) + '<\/div>';
return;
function urlSerialize() {
var p = new URLSearchParams();
if (selected.size > 0) {
p.set('projects', Array.from(selected).sort().join(','));
}
// Warn about URL projects that are not accessible
if (urlProjects.size > 0) {
var missing = Array.from(urlProjects).filter(function(p) {
return !accessibleProjects.some(function(ap) { return ap.name === p; });
});
if (missing.length > 0) {
showWarning('This link includes projects you don\'t have access to: ' + missing.map(escapeHtml).join(', '));
}
}
renderProjects(urlProjects);
renderPresetMenu();
// Close preset menu on outside click
document.addEventListener('click', function(e) {
var menu = document.getElementById('presetMenu');
var btn = document.getElementById('presetMenuBtn');
if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
menu.classList.add('hidden');
}
});
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 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));
function urlPush() {
var qs = urlSerialize();
if (qs === location.search) return;
try {
history.replaceState(null, '', location.pathname + qs);
} catch (e) { /* file:// protocol restrictions */ }
}
function urlRestore() {
var p = new URLSearchParams(location.search);
if (p.has('projects')) {
var names = p.get('projects').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
selected = new Set(names);
}
if (p.has('pn')) {
columnFilters.pn = p.get('pn');
columnFilterASTs.pn = parseFilterAST(columnFilters.pn);
}
if (p.has('pt')) {
columnFilters.pt = p.get('pt');
columnFilterASTs.pt = parseFilterAST(columnFilters.pt);
}
if (p.has('sort')) {
var s = p.get('sort');
if (s === 'name' || s === 'title') sortField = s;
}
if (p.has('dir')) {
var d = p.get('dir');
if (d === 'asc' || d === 'desc') sortDirection = d;
}
}
function parseFilterAST(text) {
if (!text) return null;
try { return zddc.filter.parse(text); } catch (e) { return null; }
}
// ── Server fetch ─────────────────────────────────────────────────────────
async function fetchProjects() {
var base = location.origin + location.pathname.replace(/\/[^\/]*$/, '/');
try {
var resp = await fetch(base, {
headers: { 'Accept': 'application/json' },
cache: 'no-cache',
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
// Read the body as text first so we can give a useful error when
// the server returns HTML (zddc-server too old to honor
// Accept: application/json on /, or a proxy in the way that
// strips the header). resp.json() in that case throws an opaque
// "Unexpected token <" SyntaxError.
var ctype = resp.headers.get('Content-Type') || '';
var body = await resp.text();
var trimmed = body.trim();
var looksLikeJson = trimmed.startsWith('[') || trimmed.startsWith('{');
if (!ctype.toLowerCase().includes('json') && !looksLikeJson) {
console.warn('Project-list endpoint returned non-JSON', {
requested: base,
finalUrl: resp.url,
redirected: resp.redirected,
contentType: ctype,
bodyStart: trimmed.slice(0, 200)
});
if (resp.redirected) {
loadErrorKind = 'auth';
throw new Error("The request was redirected to " + resp.url + ' — likely to an auth/login page. Sign in and reload.');
}
// Heuristic: the level-2 bootstrap stub identifies itself with
// its loading title. When the server returns it for our JSON
// request, we're on a plain static deployment (no zddc-server
// backend with the project-list API).
if (/<title>\s*Loading\s+ZDDC/i.test(trimmed) || /<title>\s*Loading\s+Archive/i.test(trimmed)) {
loadErrorKind = 'static';
throw new Error("This deployment doesn't expose a project list. The server is serving static stubs without a zddc-server backend.");
}
loadErrorKind = 'non-json';
throw new Error("The server at " + base + " returned HTML where a JSON project list was expected. Its zddc-server may be too old (no Accept: application/json dispatch on /), a reverse proxy is stripping the header, or the static site at the root has shadowed the API endpoint.");
}
var data = JSON.parse(body);
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
allProjects = data.map(function(p) {
return {
name: String(p.name || ''),
title: String(p.title || ''),
url: String(p.url || '')
};
}).filter(function(p) { return p.name; });
return true;
} catch (e) {
loadError = e.message || String(e);
return false;
}
}
// ── Filter / sort ────────────────────────────────────────────────────────
function visibleProjects() {
var rows = allProjects.slice();
if (columnFilterASTs.pn && columnFilterASTs.pn.length > 0) {
rows = rows.filter(function(r) { return zddc.filter.matches(r.name, columnFilterASTs.pn); });
}
if (columnFilterASTs.pt && columnFilterASTs.pt.length > 0) {
rows = rows.filter(function(r) { return zddc.filter.matches(r.title || '', columnFilterASTs.pt); });
}
rows.sort(function(a, b) {
var av = (a[sortField] || '').toString();
var bv = (b[sortField] || '').toString();
// Empty titles sort last regardless of direction.
if (sortField === 'title') {
if (!av && bv) return 1;
if (av && !bv) return -1;
}
var cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' });
return cmp * (sortDirection === 'desc' ? -1 : 1);
});
return rows;
}
// ── Rendering ────────────────────────────────────────────────────────────
function 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>/&lt;project&gt;/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;
// 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">&times;</button>'
+ '</div>';
}).join('');
var footerHtml;
if (presetSavingMode) {
footerHtml = '<div class="preset-menu-saving">'
+ '<input type="text" id="presetNameInput" class="preset-name-input" placeholder="Preset name" autoFocus>'
+ '<button class="btn btn-primary btn-sm" data-action="confirm-save">Save</button>'
+ '<button class="btn btn-secondary btn-sm" data-action="cancel-save">Cancel</button>'
+ '</div>';
} else {
var anySelectedOrFiltered = selected.size > 0 || columnFilters.pn || columnFilters.pt
|| sortField !== DEFAULT_SORT_FIELD || sortDirection !== DEFAULT_SORT_DIRECTION;
footerHtml = '<div class="preset-menu-actions">'
+ '<button class="btn btn-primary btn-sm" '
+ (anySelectedOrFiltered ? '' : 'disabled ')
+ 'data-action="start-save">Save current as preset…</button>'
+ '</div>';
}
menu.innerHTML = presets.map(function(preset) {
return '<div class="preset-menu-item">' +
'<span class="preset-menu-item-name" onclick="LandingApp.applyPreset(' + JSON.stringify(preset.name) + ')">' +
escapeHtml(preset.name) + '<\/span>' +
'<button class="preset-delete-btn" onclick="LandingApp.deletePreset(' + JSON.stringify(preset.name) + ')" title="Delete preset">&times;<\/button>' +
'<\/div>';
}).join('');
menu.innerHTML = '<div class="preset-menu-header">Saved presets</div>'
+ '<div class="preset-menu-list">' + listHtml + '</div>'
+ '<div class="preset-menu-divider"></div>'
+ footerHtml;
}
// ── Actions ──────────────────────────────────────────────────────────────
function selectAll() {
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
cb.checked = true;
});
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 selectNone() {
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
cb.checked = false;
});
function renderProjectCount() {
var el = document.getElementById('projectCount');
if (!el) return;
if (loadError || allProjects.length === 0) { el.textContent = ''; return; }
var rows = visibleProjects();
if (rows.length === allProjects.length) {
el.textContent = '(' + allProjects.length + ')';
} else {
el.textContent = '(' + rows.length + ' of ' + allProjects.length + ')';
}
}
function toggleProject(row) {
var cb = row.querySelector('input[type="checkbox"]');
if (cb) cb.checked = !cb.checked;
// ── 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

View file

@ -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">&times;</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>

View file

@ -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

View file

@ -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; }

View file

@ -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 {

View file

@ -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');

View file

@ -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,29 +105,32 @@ 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');
headerText.textContent = 'YAML Front Matter';
frontMatterHeader.appendChild(headerText);
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 = '';
@ -142,12 +147,22 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
frontMatterTextarea.value = '';
}
}
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);
// Set up TOC container overflow when front matter is toggled
tocContainer.className = 'toc-container toc-content';
tocSection.appendChild(tocContainer);
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

View file

@ -25,19 +25,73 @@ function openScratchpad() {
// Hide welcome screen, show content container
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);
if (instance) {
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;

View file

@ -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.textContent = `📁 ${name}`;
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');
@ -200,7 +232,7 @@ function renderFileTree() {
fileElement.innerHTML = '';
fileElement.appendChild(fileLabel);
fileElement.appendChild(fileActions);
fileElement.appendChild(fileActions);
fileElement.addEventListener('click', (event) => {
event.stopPropagation();

View file

@ -14,8 +14,18 @@ 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);
});
}
});
/**

View file

@ -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
*/

View file

@ -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

View file

@ -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">

View file

@ -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',

View file

@ -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
View 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

View file

@ -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': {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
'123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF',
'Archive': {
'ACME': {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
'123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF',
},
},
},
},
'Project-B': {
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
'789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF',
'Archive': {
'Beta': {
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
'789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF',
},
},
},
},
'Project-C': {
'2025-03-01_345678-EM-TRN-0001 (IFC) - Third': {
'345678-EL-SPC-0003_A (IFC) - SpecC.pdf': '%PDF',
'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 });

View file

@ -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
View 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');
});
});

View file

@ -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');

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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.