User feedback: the Grid toggle button was on every page and showed an
explanatory empty state when classifier wasn't available — adding UI
to explain why UI didn't work. Cleaner approach: drop the button,
make the URL the source of truth, and default grid mode automatically
inside the only context where it's meaningful.
Behavior:
- Inside any incoming/ path (case-insensitive segment match):
→ grid mode by default
- Everywhere else:
→ browse mode
- Explicit overrides via query string:
?view=grid forces grid (only honored where classifier is
available; otherwise falls back to browse)
?view=browse forces browse (always)
UI changes:
- The Browse/Grid pill toggle is gone.
- grid.js drops both empty-state messages; outside an incoming/
path it just does nothing.
- events.js owns resolveViewMode() / applyResolvedViewMode(),
called on initial mount and after every client-side rescope
(dblclick + popstate).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
411 lines
17 KiB
JavaScript
411 lines
17 KiB
JavaScript
// events.js — wires up DOM listeners. Idempotent so app.js can call
|
|
// init() once on load.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
var tree = window.app.modules.tree;
|
|
var loader = window.app.modules.loader;
|
|
// preview module is loaded later (concat order); look it up at
|
|
// call time, not at IIFE-eval time.
|
|
function previewMod() { return window.app.modules.preview; }
|
|
|
|
function status(msg, kind) {
|
|
var el = document.getElementById('statusBar');
|
|
if (!el) return;
|
|
el.textContent = msg || '';
|
|
el.classList.remove('status-bar--error', 'status-bar--info');
|
|
if (kind === 'error') el.classList.add('status-bar--error');
|
|
if (kind === 'info') el.classList.add('status-bar--info');
|
|
}
|
|
|
|
function statusError(msg) { status(msg, 'error'); }
|
|
function statusInfo(msg) { status(msg, 'info'); }
|
|
function statusClear() { status('', null); }
|
|
|
|
async function pickLocalDir() {
|
|
if (typeof window.showDirectoryPicker !== 'function') {
|
|
statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.');
|
|
return;
|
|
}
|
|
var handle;
|
|
try {
|
|
handle = await window.showDirectoryPicker({ mode: 'read' });
|
|
} catch (e) {
|
|
// User cancelled — silent
|
|
return;
|
|
}
|
|
state.source = 'fs';
|
|
state.rootHandle = handle;
|
|
state.currentPath = handle.name + '/';
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchFsChildren(handle);
|
|
} catch (e) {
|
|
statusError('Failed to read directory: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
showBrowseRoot();
|
|
tree.render();
|
|
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
|
|
}
|
|
|
|
function showBrowseRoot() {
|
|
var empty = document.getElementById('emptyState');
|
|
var root = document.getElementById('browseRoot');
|
|
if (empty) empty.classList.add('hidden');
|
|
if (root) root.classList.remove('hidden');
|
|
applySourceUI();
|
|
}
|
|
|
|
// Visual state of the "Select Directory" button + the refresh
|
|
// button depends on the source. In server mode the user is
|
|
// already viewing a server-backed listing — Select Directory
|
|
// becomes a quiet "switch to local" affordance (subtle styling),
|
|
// and the refresh button is shown. In FS mode the button is
|
|
// primary (it's how you got here) and refresh is hidden (the
|
|
// listing was already a fresh enumeration).
|
|
function applySourceUI() {
|
|
var add = document.getElementById('addDirectoryBtn');
|
|
var refresh = document.getElementById('refreshHeaderBtn');
|
|
if (add) {
|
|
if (state.source === 'server') {
|
|
add.classList.remove('btn-primary');
|
|
add.classList.add('btn--subtle');
|
|
} else {
|
|
add.classList.add('btn-primary');
|
|
add.classList.remove('btn--subtle');
|
|
}
|
|
}
|
|
if (refresh) {
|
|
if (state.source) {
|
|
refresh.classList.remove('hidden');
|
|
} else {
|
|
refresh.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshListing() {
|
|
if (state.source === 'server') {
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchServerChildren(state.currentPath);
|
|
} catch (e) {
|
|
statusError('Refresh failed: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
tree.render();
|
|
statusInfo('Refreshed (' + raw.length + ' item'
|
|
+ (raw.length === 1 ? '' : 's') + ')');
|
|
} else if (state.source === 'fs' && state.rootHandle) {
|
|
var raw2;
|
|
try {
|
|
raw2 = await loader.fetchFsChildren(state.rootHandle);
|
|
} catch (e) {
|
|
statusError('Refresh failed: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw2);
|
|
tree.render();
|
|
statusInfo('Refreshed');
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
// Header buttons
|
|
var btn = document.getElementById('addDirectoryBtn');
|
|
if (btn) btn.addEventListener('click', pickLocalDir);
|
|
|
|
var refresh = document.getElementById('refreshHeaderBtn');
|
|
if (refresh) refresh.addEventListener('click', refreshListing);
|
|
|
|
// Sort dropdown — change → tree re-renders with the new sort.
|
|
// Format of option value: "<key>:<asc|desc>". Defaults match
|
|
// state.sort initial values (name:asc).
|
|
var sortSel = document.getElementById('sortBy');
|
|
if (sortSel) {
|
|
sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc');
|
|
sortSel.addEventListener('change', function () {
|
|
var parts = sortSel.value.split(':');
|
|
var key = parts[0];
|
|
var dir = parts[1] === 'desc' ? -1 : 1;
|
|
tree.setSortExplicit(key, dir);
|
|
});
|
|
}
|
|
|
|
// No view-mode buttons; mode is derived from the URL on every
|
|
// scope change (resolveViewMode below). Pass-through for the
|
|
// initial path.
|
|
applyResolvedViewMode();
|
|
|
|
// Pop-out preview button — opens the current preview in a separate window.
|
|
var popout = document.getElementById('previewPopout');
|
|
if (popout) popout.addEventListener('click', function () {
|
|
var p = previewMod();
|
|
if (p && state.lastPreviewedNodeId != null) {
|
|
var n = state.nodes.get(state.lastPreviewedNodeId);
|
|
if (n) p.showFilePreview(n, { popup: true });
|
|
}
|
|
});
|
|
|
|
// Pane resizer (tree pane width). Drag horizontally; clamps to
|
|
// [180, 60% of viewport]. State stays in-memory only — refresh
|
|
// resets to the default 360px.
|
|
var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]');
|
|
var treePane = document.getElementById('treePane');
|
|
if (resizer && treePane) {
|
|
var dragging = false;
|
|
var startX = 0;
|
|
var startWidth = 0;
|
|
resizer.addEventListener('mousedown', function (e) {
|
|
dragging = true;
|
|
resizer.classList.add('is-dragging');
|
|
startX = e.clientX;
|
|
startWidth = treePane.getBoundingClientRect().width;
|
|
e.preventDefault();
|
|
});
|
|
document.addEventListener('mousemove', function (e) {
|
|
if (!dragging) return;
|
|
var dx = e.clientX - startX;
|
|
var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx));
|
|
treePane.style.width = w + 'px';
|
|
});
|
|
document.addEventListener('mouseup', function () {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
resizer.classList.remove('is-dragging');
|
|
});
|
|
}
|
|
|
|
// Tree-row clicks (event delegation on the tree body).
|
|
// Click semantics on a folder row:
|
|
// - plain click → toggle expand (deferred so dblclick wins)
|
|
// - shift-click → recursive expand/collapse of the subtree
|
|
// - alt-click → ALSO recursive
|
|
// - dblclick → navigate into the folder
|
|
// File rows: plain click → preview in right pane; modifier-click
|
|
// and middle-click open in new tab.
|
|
//
|
|
// The plain-click toggle for folders is intentionally deferred
|
|
// via setTimeout. Reason: toggling re-renders the tree, which
|
|
// replaces the clicked row element. The browser detects a
|
|
// double-click only when the second click lands on the same
|
|
// target element as the first; replacing the row breaks that
|
|
// continuity and the dblclick event never fires. The deferred
|
|
// toggle lets a pending dblclick cancel it.
|
|
var pendingFolderToggle = null;
|
|
var treeBody = document.getElementById('treeBody');
|
|
if (treeBody) {
|
|
treeBody.addEventListener('click', function (e) {
|
|
var row = e.target.closest('.tree-row');
|
|
if (!row) return;
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
|
|
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
|
|
|
|
if (isExpandable) {
|
|
e.preventDefault();
|
|
if (e.shiftKey || e.altKey) {
|
|
// Modifier-click skips the dblclick race — it's
|
|
// an explicit recursive toggle, never followed
|
|
// by a dblclick.
|
|
if (node.expanded) tree.collapseSubtree(id);
|
|
else tree.expandSubtree(id);
|
|
return;
|
|
}
|
|
// ZIPs don't navigate-into; toggle immediately.
|
|
if (row.dataset.iszip === 'true') {
|
|
tree.toggleFolder(id);
|
|
return;
|
|
}
|
|
// Folder: defer the toggle so a pending dblclick
|
|
// can pre-empt it.
|
|
if (pendingFolderToggle) {
|
|
clearTimeout(pendingFolderToggle.timer);
|
|
}
|
|
pendingFolderToggle = {
|
|
id: id,
|
|
timer: setTimeout(function () {
|
|
pendingFolderToggle = null;
|
|
tree.toggleFolder(id);
|
|
}, 220)
|
|
};
|
|
return;
|
|
}
|
|
|
|
// File row: modifier-click → open URL in new tab if
|
|
// available (server mode preserves the original URL,
|
|
// useful for direct download / sharing).
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
|
|
if (node.url) window.open(node.url, '_blank', 'noopener');
|
|
return;
|
|
}
|
|
// Plain click → preview in the right pane.
|
|
e.preventDefault();
|
|
state.selectedId = id;
|
|
state.lastPreviewedNodeId = id;
|
|
tree.render(); // refresh selection highlight
|
|
var p = previewMod();
|
|
if (p) p.showFilePreview(node);
|
|
});
|
|
|
|
// Double-click on a folder → "navigate into" it. Distinct
|
|
// from single-click (which expands inline) so users keep
|
|
// both UX models. Server mode jumps to the folder URL —
|
|
// zddc-server returns a fresh browse instance scoped to
|
|
// that directory. FS-API mode swaps state.rootHandle to
|
|
// the folder's handle and re-loads, so the user sees
|
|
// only that subtree at the root level.
|
|
//
|
|
// Files: dblclick is left alone — the single-click preview
|
|
// is already a "look at this file" action; a separate
|
|
// navigate-into doesn't apply.
|
|
// ZIPs: skipped too — they're inspected via inline
|
|
// expansion (JSZip), not navigated into.
|
|
treeBody.addEventListener('dblclick', function (e) {
|
|
var row = e.target.closest('.tree-row');
|
|
if (!row) return;
|
|
if (row.dataset.isdir !== 'true') return;
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
e.preventDefault();
|
|
// Pre-empt the deferred single-click toggle so the user
|
|
// doesn't see a flicker of expand/collapse before nav.
|
|
if (pendingFolderToggle) {
|
|
clearTimeout(pendingFolderToggle.timer);
|
|
pendingFolderToggle = null;
|
|
}
|
|
navigateIntoFolder(node);
|
|
});
|
|
}
|
|
}
|
|
|
|
// View mode is URL-driven, not UI-driven.
|
|
//
|
|
// ?view=grid → grid mode (only honored where classifier is
|
|
// available; otherwise falls back to browse)
|
|
// ?view=browse → browse mode (always)
|
|
// default → path-based: grid when inside an incoming/
|
|
// subtree, browse everywhere else
|
|
//
|
|
// resolveViewMode reads the current location and returns the mode
|
|
// to render; applyResolvedViewMode toggles the panes accordingly.
|
|
// Called on initial load and on every client-side rescope.
|
|
function resolveViewMode() {
|
|
var qs = new URLSearchParams(window.location.search);
|
|
var explicit = (qs.get('view') || '').toLowerCase();
|
|
var grid = window.app.modules.grid;
|
|
var classifierHere = !!(grid && grid.availableHere && grid.availableHere());
|
|
if (explicit === 'grid') return classifierHere ? 'grid' : 'browse';
|
|
if (explicit === 'browse') return 'browse';
|
|
return classifierHere ? 'grid' : 'browse';
|
|
}
|
|
|
|
function applyResolvedViewMode() {
|
|
var mode = resolveViewMode();
|
|
state.viewMode = mode;
|
|
var browseView = document.getElementById('browseView');
|
|
var gridView = document.getElementById('gridView');
|
|
if (mode === 'grid') {
|
|
if (browseView) browseView.classList.add('hidden');
|
|
if (gridView) gridView.classList.remove('hidden');
|
|
var grid = window.app.modules.grid;
|
|
if (grid) {
|
|
if (grid.reset) grid.reset();
|
|
if (grid.activate) grid.activate();
|
|
}
|
|
} else {
|
|
if (browseView) browseView.classList.remove('hidden');
|
|
if (gridView) gridView.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
async function navigateIntoFolder(node) {
|
|
if (state.source === 'server') {
|
|
// Rescope client-side rather than hard-navigating. A hard
|
|
// nav would let zddc-server's auto-serve kick in and swap
|
|
// us out of browse for canonical folders (e.g. /archive/
|
|
// → archive tool, /staging/ → transmittal). Staying in
|
|
// browse is what the user asked for; pushState keeps the
|
|
// URL bar accurate so a reload would re-load browse at the
|
|
// new scope.
|
|
var url = window.app.modules.tree.pathFor(node);
|
|
if (!url.endsWith('/')) url += '/';
|
|
await rescopeServer(url, node.name);
|
|
return;
|
|
}
|
|
if (state.source === 'fs') {
|
|
if (!node.handle || node.handle.kind !== 'directory') return;
|
|
state.rootHandle = node.handle;
|
|
state.currentPath = node.handle.name + '/';
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchFsChildren(node.handle);
|
|
} catch (e) {
|
|
statusError('Failed to enter ' + node.name + ': ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
tree.render();
|
|
statusInfo('Entered ' + node.name);
|
|
}
|
|
}
|
|
|
|
// Client-side rescope for server mode. Updates the URL via
|
|
// history.pushState, fetches the new directory listing, and
|
|
// re-renders the tree from scratch. Page DOES NOT reload.
|
|
async function rescopeServer(url, displayName) {
|
|
var entries;
|
|
try {
|
|
entries = await loader.fetchServerChildren(url);
|
|
} catch (e) {
|
|
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
|
|
return;
|
|
}
|
|
state.currentPath = url;
|
|
// Selection / preview belong to the old scope; clear them so
|
|
// the new root doesn't carry stale highlight state.
|
|
state.selectedId = null;
|
|
state.lastPreviewedNodeId = null;
|
|
// Virtual canonical folders are emitted by zddc-server itself
|
|
// (so .zddc display: overrides apply uniformly); no client-side
|
|
// merge needed.
|
|
tree.setRoot(entries);
|
|
tree.render();
|
|
// Reset the preview pane so the user sees an "empty selection"
|
|
// state at the new scope instead of the previous file.
|
|
var previewBody = document.getElementById('previewBody');
|
|
if (previewBody) previewBody.innerHTML = '';
|
|
var previewTitle = document.getElementById('previewTitle');
|
|
if (previewTitle) previewTitle.textContent = 'No file selected';
|
|
var previewMeta = document.getElementById('previewMeta');
|
|
if (previewMeta) previewMeta.textContent = '';
|
|
// pushState so the URL bar reflects the new scope. A real
|
|
// reload would re-load browse at this URL (trailing slash →
|
|
// ServeDirectory → embedded browse SPA).
|
|
try {
|
|
history.pushState({ zddcBrowse: true, path: url }, '', url);
|
|
} catch (_e) { /* private browsing edge cases */ }
|
|
statusInfo('Entered ' + displayName);
|
|
// The new scope may have a different default view (grid inside
|
|
// incoming/, browse elsewhere). Re-resolve from the URL now
|
|
// that pushState has updated it.
|
|
applyResolvedViewMode();
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.events = {
|
|
init: init,
|
|
statusError: statusError,
|
|
statusInfo: statusInfo,
|
|
statusClear: statusClear,
|
|
showBrowseRoot: showBrowseRoot,
|
|
applyResolvedViewMode: applyResolvedViewMode
|
|
};
|
|
})();
|