Compare commits
2 commits
127163dfa2
...
d874643af5
| Author | SHA1 | Date | |
|---|---|---|---|
| d874643af5 | |||
| 424bf8e769 |
18 changed files with 2035 additions and 141 deletions
|
|
@ -30,9 +30,11 @@ concat_files \
|
|||
concat_files \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"js/init.js" \
|
||||
"js/loader.js" \
|
||||
"js/tree.js" \
|
||||
"js/preview.js" \
|
||||
"js/events.js" \
|
||||
"js/app.js" \
|
||||
> "$js_raw"
|
||||
|
|
|
|||
|
|
@ -25,15 +25,55 @@
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar__path {
|
||||
/* Breadcrumb path. The root node is a 🏠 link to "/" (online) or
|
||||
the FS handle name (offline). Each segment is a clickable link in
|
||||
server mode that re-navigates the browser; in FS-API mode they
|
||||
render as plain spans because we don't keep ancestor handles. */
|
||||
.breadcrumbs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0.1rem 0;
|
||||
/* Hide the scrollbar but keep horizontal scroll for very deep paths */
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-link:hover {
|
||||
background: var(--bg-hover, rgba(0,0,0,0.05));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-link--current {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-link--current:hover {
|
||||
background: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-sep {
|
||||
color: var(--text-muted);
|
||||
margin: 0 0.05rem;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-root {
|
||||
font-size: 1rem; /* the 🏠 emoji renders a hair bigger */
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toolbar__filter {
|
||||
|
|
@ -47,12 +87,45 @@
|
|||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbar__ext {
|
||||
/* Multi-select extension filter. Native <select multiple> is
|
||||
intentionally compact — most folders have a small set of
|
||||
extensions, and we surface the list dynamically from the
|
||||
loaded view. */
|
||||
min-width: 8rem;
|
||||
max-width: 14rem;
|
||||
height: auto;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
|
||||
.toolbar__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Subtle button variant — used for "Select Directory" when the page
|
||||
is server-backed (the user usually doesn't need to switch to a
|
||||
local folder; we keep the option visible but quiet). */
|
||||
.btn.btn--subtle {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border);
|
||||
box-shadow: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.btn.btn--subtle:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover, rgba(0,0,0,0.04));
|
||||
}
|
||||
|
||||
/* Table — folders + files in a tree */
|
||||
|
||||
.browse-table {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
if (detected) {
|
||||
tree.setRoot(detected.entries);
|
||||
events.showBrowseRoot();
|
||||
document.getElementById('currentPath').textContent = detected.path;
|
||||
tree.render();
|
||||
events.statusInfo('Loaded ' + detected.entries.length + ' item'
|
||||
+ (detected.entries.length === 1 ? '' : 's')
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
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');
|
||||
|
|
@ -44,7 +47,6 @@
|
|||
}
|
||||
tree.setRoot(raw);
|
||||
showBrowseRoot();
|
||||
document.getElementById('currentPath').textContent = state.currentPath;
|
||||
tree.render();
|
||||
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
|
||||
}
|
||||
|
|
@ -54,6 +56,62 @@
|
|||
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() {
|
||||
|
|
@ -61,6 +119,9 @@
|
|||
var btn = document.getElementById('addDirectoryBtn');
|
||||
if (btn) btn.addEventListener('click', pickLocalDir);
|
||||
|
||||
var refresh = document.getElementById('refreshHeaderBtn');
|
||||
if (refresh) refresh.addEventListener('click', refreshListing);
|
||||
|
||||
// Filter input
|
||||
var filter = document.getElementById('filterInput');
|
||||
if (filter) {
|
||||
|
|
@ -69,6 +130,18 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Extension multi-select
|
||||
var extSel = document.getElementById('extFilter');
|
||||
if (extSel) {
|
||||
extSel.addEventListener('change', function () {
|
||||
var picked = [];
|
||||
for (var i = 0; i < extSel.options.length; i++) {
|
||||
if (extSel.options[i].selected) picked.push(extSel.options[i].value);
|
||||
}
|
||||
tree.setExtFilter(picked);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort headers
|
||||
var ths = document.querySelectorAll('#browseTable thead th.sortable');
|
||||
for (var i = 0; i < ths.length; i++) {
|
||||
|
|
@ -96,20 +169,45 @@
|
|||
tbody.addEventListener('click', function (e) {
|
||||
var row = e.target.closest('tr.tree-row');
|
||||
if (!row) return;
|
||||
var isDir = row.dataset.isdir === 'true';
|
||||
if (!isDir) return;
|
||||
e.preventDefault();
|
||||
var id = parseInt(row.dataset.id, 10);
|
||||
if (e.shiftKey || e.altKey) {
|
||||
var node = state.nodes.get(id);
|
||||
if (node && node.expanded) {
|
||||
tree.collapseSubtree(id);
|
||||
} else {
|
||||
tree.expandSubtree(id);
|
||||
}
|
||||
if (!node) return;
|
||||
|
||||
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
|
||||
var clickedChevron = !!e.target.closest('.tree-name__chevron');
|
||||
|
||||
if (isExpandable) {
|
||||
// For folders + zips: click anywhere on the row
|
||||
// toggles. Modifier-click → recursive expand.
|
||||
e.preventDefault();
|
||||
if (e.shiftKey || e.altKey) {
|
||||
if (node.expanded) tree.collapseSubtree(id);
|
||||
else tree.expandSubtree(id);
|
||||
} else {
|
||||
tree.toggleFolder(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain file row.
|
||||
// Modifier-click (ctrl/cmd) and middle-click → fall
|
||||
// through to the <a> tag's natural target=_blank
|
||||
// behavior (open in new tab). For server-backed
|
||||
// files, that opens the real URL via zddc-server.
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
// Plain click → preview popup. Intercept default nav.
|
||||
e.preventDefault();
|
||||
var p = previewMod();
|
||||
if (p) p.showFilePreview(node);
|
||||
});
|
||||
|
||||
// Middle-click (auxclick) — same fall-through logic.
|
||||
tbody.addEventListener('auxclick', function (e) {
|
||||
if (e.button !== 1) return; // middle only
|
||||
// Browser handles target=_blank natively for middle
|
||||
// click; don't preventDefault, just don't intercept.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,28 @@
|
|||
// Current filter substring (lowercase).
|
||||
filterText: '',
|
||||
|
||||
// Selected extensions (Set of lowercase strings, no leading
|
||||
// dot). Empty set = no extension filtering.
|
||||
extFilter: new Set(),
|
||||
|
||||
// The tree's in-memory representation. Each node:
|
||||
// { id, name, isDir, size, modTime, ext, url, depth,
|
||||
// parentId, expanded, loaded, childIds }
|
||||
// parentId, expanded, loaded, childIds, isZip, zipFile,
|
||||
// zipPath }
|
||||
// - isZip: set when the node IS a .zip file we know how to
|
||||
// expand inline (server file or FS handle).
|
||||
// - zipFile: cached JSZip instance for this archive (set
|
||||
// after first expand).
|
||||
// - zipPath: relative path WITHIN a zip (set on virtual
|
||||
// children of an expanded zip; null otherwise).
|
||||
// Stored flat in a Map keyed by id; render order derived
|
||||
// from a depth-first walk.
|
||||
nodes: new Map(),
|
||||
rootIds: [],
|
||||
nextId: 1
|
||||
nextId: 1,
|
||||
|
||||
// Single shared popup window for file preview (across
|
||||
// multiple file clicks). Same pattern as archive's preview.
|
||||
previewWindow: null
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -120,11 +120,35 @@
|
|||
}
|
||||
}
|
||||
|
||||
// CDN library loader. Idempotent — multiple callers share the
|
||||
// same in-flight Promise. Used by ZIP expansion + the file
|
||||
// preview popup.
|
||||
var libCache = new Map();
|
||||
function loadScript(url) {
|
||||
if (libCache.has(url)) return libCache.get(url);
|
||||
var p = new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = function () { resolve(); };
|
||||
s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
libCache.set(url, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function ensureJSZip() {
|
||||
if (window.JSZip) return Promise.resolve();
|
||||
return loadScript('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
|
||||
}
|
||||
|
||||
// Public API
|
||||
window.app.modules.loader = {
|
||||
fetchServerChildren: fetchServerChildren,
|
||||
fetchFsChildren: fetchFsChildren,
|
||||
autoDetectServerMode: autoDetectServerMode,
|
||||
splitExt: splitExt
|
||||
splitExt: splitExt,
|
||||
ensureJSZip: ensureJSZip,
|
||||
loadScript: loadScript
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
239
browse/js/preview.js
Normal file
239
browse/js/preview.js
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// preview.js — file preview popup. Reuses shared/preview-lib.js for
|
||||
// TIFF, ZIP listing, and image-rendering helpers; native iframe for
|
||||
// PDF and HTML; <pre> for text; download button for everything else.
|
||||
//
|
||||
// Lifecycle: a single popup window is reused across multiple file
|
||||
// clicks (state.previewWindow). Subsequent clicks rewrite its
|
||||
// contents instead of spawning a new window — same UX as the archive
|
||||
// tool.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var state = window.app.state;
|
||||
var loader = window.app.modules.loader;
|
||||
var preview = window.zddc && window.zddc.preview;
|
||||
if (!preview) {
|
||||
// shared/preview-lib.js wasn't concatenated in. Bail loudly so
|
||||
// the bug shows up in console rather than mysteriously failing.
|
||||
console.error('[browse] zddc.preview not loaded — preview popup disabled.');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
var MIME = {
|
||||
'pdf': 'application/pdf',
|
||||
'html': 'text/html', 'htm': 'text/html',
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
||||
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
|
||||
'tif': 'image/tiff', 'tiff': 'image/tiff',
|
||||
'zip': 'application/zip',
|
||||
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
|
||||
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
|
||||
'js': 'text/javascript', 'css': 'text/css'
|
||||
};
|
||||
|
||||
// Pull bytes for a file node. Three sources:
|
||||
// - server URL (zddc-server-backed file, including downloads
|
||||
// of archived files served at real paths)
|
||||
// - FS-API handle (local folder)
|
||||
// - JSZip entry (file inside an expanded zip; reads from
|
||||
// parent's cached JSZip instance)
|
||||
async function getArrayBuffer(node) {
|
||||
if (node.zipParentId != null) {
|
||||
var owner = state.nodes.get(node.zipParentId);
|
||||
if (!owner || !owner.zipFile) {
|
||||
throw new Error('parent zip not loaded');
|
||||
}
|
||||
return await owner.zipFile.file(node.zipPath).async('arraybuffer');
|
||||
}
|
||||
if (state.source === 'server' && node.url) {
|
||||
var resp = await fetch(node.url);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return await resp.arrayBuffer();
|
||||
}
|
||||
if (node.handle) {
|
||||
var f = await node.handle.getFile();
|
||||
return await f.arrayBuffer();
|
||||
}
|
||||
throw new Error('no source for file');
|
||||
}
|
||||
|
||||
function getMime(ext) {
|
||||
return MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// Build a blob URL for the file's bytes. For server-mode regular
|
||||
// files (not in a zip), prefer the live URL — relative links and
|
||||
// server-side interception (e.g. .archive resolution) work then.
|
||||
async function getBlobUrl(node) {
|
||||
if (state.source === 'server' && node.url && node.zipParentId == null) {
|
||||
return { url: node.url, fromServer: true };
|
||||
}
|
||||
var buf = await getArrayBuffer(node);
|
||||
var blob = new Blob([buf], { type: getMime(node.ext) });
|
||||
return { url: URL.createObjectURL(blob), fromServer: false };
|
||||
}
|
||||
|
||||
function popupShell(node, primaryUrl) {
|
||||
var safeName = escapeHtml(node.name);
|
||||
var safeHref = escapeHtml(primaryUrl);
|
||||
var ext = (node.ext || '').toLowerCase();
|
||||
// Inline PDF and HTML previews load in iframes. HTML uses
|
||||
// sandbox="allow-same-origin allow-popups
|
||||
// allow-popups-to-escape-sandbox" — same posture as archive's
|
||||
// preview: links navigate, scripts blocked, popups allowed.
|
||||
var contentHtml;
|
||||
if (ext === 'pdf') {
|
||||
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
|
||||
} else if (ext === 'html' || ext === 'htm') {
|
||||
contentHtml = '<iframe src="' + safeHref + '" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>';
|
||||
} else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
||||
contentHtml = '<img class="preview-image" src="' + safeHref + '" alt="' + safeName + '">';
|
||||
} else {
|
||||
contentHtml = '<div id="previewContent"><div class="loading">Loading preview…</div></div>';
|
||||
}
|
||||
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
|
||||
+ '<title>' + safeName + ' — preview</title><style>'
|
||||
+ '*{margin:0;padding:0;box-sizing:border-box;}'
|
||||
+ 'body{display:flex;flex-direction:column;height:100vh;'
|
||||
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}'
|
||||
+ '.toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;'
|
||||
+ 'background:#f5f5f5;border-bottom:1px solid #ddd;}'
|
||||
+ '.toolbar h1{flex:1;font-size:.95rem;font-weight:500;overflow:hidden;'
|
||||
+ 'text-overflow:ellipsis;white-space:nowrap;}'
|
||||
+ '.btn{padding:.4rem .8rem;font-size:.85rem;border:1px solid #ccc;'
|
||||
+ 'border-radius:4px;background:white;cursor:pointer;}'
|
||||
+ '.btn:hover{background:#e8e8e8;}'
|
||||
+ 'iframe{flex:1;width:100%;border:none;}'
|
||||
+ '#previewContent{flex:1;overflow:auto;display:flex;flex-direction:column;}'
|
||||
+ '.loading{display:flex;align-items:center;justify-content:center;height:100%;'
|
||||
+ 'color:#666;font-size:1.1rem;}'
|
||||
+ 'img.preview-image{max-width:100%;max-height:100%;object-fit:contain;'
|
||||
+ 'margin:auto;display:block;}'
|
||||
+ 'pre.preview-text{padding:1rem;font-family:Consolas,Monaco,monospace;'
|
||||
+ 'font-size:.85rem;white-space:pre-wrap;word-wrap:break-word;}'
|
||||
+ '</style></head><body>'
|
||||
+ '<div class="toolbar"><h1>' + safeName + '</h1>'
|
||||
+ '<button class="btn" onclick="downloadFile()">Download</button></div>'
|
||||
+ contentHtml
|
||||
+ '<script>'
|
||||
+ 'var blobUrl=' + JSON.stringify(primaryUrl) + ';'
|
||||
+ 'var fileName=' + JSON.stringify(node.name) + ';'
|
||||
+ 'function downloadFile(){var a=document.createElement("a");'
|
||||
+ 'a.href=blobUrl;a.download=fileName;document.body.appendChild(a);'
|
||||
+ 'a.click();document.body.removeChild(a);}'
|
||||
+ '</' + 'script></body></html>';
|
||||
}
|
||||
|
||||
async function renderTextInWindow(node, win) {
|
||||
var c = win.document.getElementById('previewContent');
|
||||
if (!c) return;
|
||||
try {
|
||||
var buf = await getArrayBuffer(node);
|
||||
var text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||
var MAX = 200000;
|
||||
if (text.length > MAX) {
|
||||
text = text.substring(0, MAX) + '\n\n... (truncated, '
|
||||
+ (text.length - MAX) + ' more chars — Download for full file)';
|
||||
}
|
||||
var pre = win.document.createElement('pre');
|
||||
pre.className = 'preview-text';
|
||||
pre.textContent = text;
|
||||
c.innerHTML = '';
|
||||
c.appendChild(pre);
|
||||
} catch (e) {
|
||||
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function renderTiffInWindow(node, win) {
|
||||
var c = win.document.getElementById('previewContent');
|
||||
if (!c || !preview) return;
|
||||
try {
|
||||
var buf = await getArrayBuffer(node);
|
||||
await preview.renderTiff(win.document, c, buf, { fileName: node.name });
|
||||
} catch (e) {
|
||||
c.innerHTML = '<div class="loading">Error rendering TIFF: '
|
||||
+ escapeHtml(e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function renderZipInWindow(node, win) {
|
||||
var c = win.document.getElementById('previewContent');
|
||||
if (!c || !preview) return;
|
||||
try {
|
||||
var buf = await getArrayBuffer(node);
|
||||
await preview.renderZipListing(win.document, c, buf, { fileName: node.name });
|
||||
} catch (e) {
|
||||
c.innerHTML = '<div class="loading">Error reading ZIP: '
|
||||
+ escapeHtml(e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function showFilePreview(node) {
|
||||
if (node.isDir) return;
|
||||
|
||||
var ext = (node.ext || '').toLowerCase();
|
||||
var info;
|
||||
try {
|
||||
info = await getBlobUrl(node);
|
||||
} catch (e) {
|
||||
window.app.modules.events.statusError('Preview failed: ' + e.message);
|
||||
return;
|
||||
}
|
||||
var html = popupShell(node, info.url);
|
||||
|
||||
var win = state.previewWindow;
|
||||
if (win && !win.closed) {
|
||||
win.document.open();
|
||||
win.document.write(html);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
} else {
|
||||
var w = Math.round(screen.width * 0.6);
|
||||
var h = Math.round(screen.height * 0.8);
|
||||
var left = Math.round((screen.width - w) / 2);
|
||||
var top = Math.round((screen.height - h) / 2);
|
||||
win = window.open('', 'browseFilePreview',
|
||||
'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top
|
||||
+ ',resizable=yes,scrollbars=yes');
|
||||
if (!win) {
|
||||
// Popup blocked — fall back to opening the file directly.
|
||||
window.open(info.url, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
win.document.write(html);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
state.previewWindow = win;
|
||||
}
|
||||
|
||||
// Async content rendering for the non-iframe types.
|
||||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
||||
return; // iframe wired in popupShell
|
||||
}
|
||||
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
||||
return; // <img> wired in popupShell
|
||||
}
|
||||
if (preview && preview.isTiff(ext)) {
|
||||
await renderTiffInWindow(node, win);
|
||||
} else if (preview && preview.isZip(ext)) {
|
||||
await renderZipInWindow(node, win);
|
||||
} else if (preview && preview.isText(ext)) {
|
||||
await renderTextInWindow(node, win);
|
||||
} else {
|
||||
// Unknown type — show a friendly "no preview, click
|
||||
// download" placeholder.
|
||||
var c = win.document.getElementById('previewContent');
|
||||
if (c) {
|
||||
c.innerHTML = '<div class="loading">No inline preview for .'
|
||||
+ escapeHtml(ext) + ' — click Download.</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.preview = { showFilePreview: showFilePreview };
|
||||
})();
|
||||
|
|
@ -14,6 +14,10 @@
|
|||
|
||||
function newNode(raw, parentId, depth) {
|
||||
var id = state.nextId++;
|
||||
// ZIP files are treated as folders for tree purposes — the
|
||||
// chevron lets the user expand them inline. The actual
|
||||
// contents are loaded on first expand via JSZip.
|
||||
var isZip = !raw.isDir && raw.ext === 'zip';
|
||||
var node = {
|
||||
id: id,
|
||||
name: raw.name,
|
||||
|
|
@ -27,7 +31,11 @@
|
|||
parentId: parentId,
|
||||
expanded: false,
|
||||
loaded: false,
|
||||
childIds: []
|
||||
childIds: [],
|
||||
isZip: isZip,
|
||||
zipFile: null, // cached JSZip instance
|
||||
zipPath: raw.zipPath || null, // path within zip (for virtual children)
|
||||
zipParentId: raw.zipParentId || null // ancestor zip's node id (for nested entries)
|
||||
};
|
||||
state.nodes.set(id, node);
|
||||
return node;
|
||||
|
|
@ -101,14 +109,14 @@
|
|||
for (var i = 0; i < ids.length; i++) {
|
||||
out.push(ids[i]);
|
||||
var n = state.nodes.get(ids[i]);
|
||||
if (n.isDir && n.expanded) walk(n.childIds);
|
||||
if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
|
||||
}
|
||||
}
|
||||
// Re-sort everything at all levels so a sort change reorders
|
||||
// already-loaded children consistently.
|
||||
sortNodes(state.rootIds);
|
||||
state.nodes.forEach(function (n) {
|
||||
if (n.isDir && n.loaded) sortNodes(n.childIds);
|
||||
if ((n.isDir || n.isZip) && n.loaded) sortNodes(n.childIds);
|
||||
});
|
||||
walk(state.rootIds);
|
||||
return out;
|
||||
|
|
@ -138,17 +146,20 @@
|
|||
|
||||
function rowHtml(node) {
|
||||
var indent = node.depth * 1.2;
|
||||
var iconChar = node.isDir ? '📁' : '📄';
|
||||
var labelClass = node.isDir ? 'is-folder' : 'is-file';
|
||||
var chevronClass = 'tree-name__chevron' + (node.isDir ? '' : ' tree-name__chevron--leaf');
|
||||
var expandable = node.isDir || node.isZip;
|
||||
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
|
||||
var chevronClass = 'tree-name__chevron'
|
||||
+ (expandable ? '' : ' tree-name__chevron--leaf');
|
||||
var nameInner;
|
||||
if (node.isDir) {
|
||||
nameInner = '<span class="tree-name__label is-folder">'
|
||||
+ escapeHtml(node.name) + '</span>';
|
||||
} else {
|
||||
// File: clickable link. In server mode, href is a real URL
|
||||
// that opens the file. In FS mode, click handler reads the
|
||||
// file via the handle and triggers a download (Phase 2).
|
||||
// File / zip: clickable. Plain click → preview popup.
|
||||
// Modifier-click (ctrl/cmd) and middle-click → open in
|
||||
// new tab (browser default for the href). Server mode
|
||||
// gets the real URL (so right-click → save-link-as also
|
||||
// works); FS mode and zip-virtual children get '#'.
|
||||
var href = node.url || '#';
|
||||
nameInner = '<a class="tree-name__label is-file"'
|
||||
+ ' href="' + escapeHtml(href) + '"'
|
||||
|
|
@ -156,7 +167,9 @@
|
|||
}
|
||||
return ''
|
||||
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
|
||||
+ '" data-id="' + node.id + '" data-isdir="' + node.isDir + '">'
|
||||
+ '" data-id="' + node.id
|
||||
+ '" data-isdir="' + node.isDir
|
||||
+ '" data-iszip="' + node.isZip + '">'
|
||||
+ '<td class="col-name">'
|
||||
+ '<span class="tree-name">'
|
||||
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
|
||||
|
|
@ -183,19 +196,24 @@
|
|||
applyFilter();
|
||||
updateCount();
|
||||
updateSortHeaders();
|
||||
renderBreadcrumbs();
|
||||
renderExtFilter();
|
||||
}
|
||||
|
||||
// Filter is purely DOM-level: hide rows whose name doesn't match.
|
||||
// Filter is purely DOM-level: hide rows whose name doesn't match
|
||||
// and (if any extensions are selected) whose ext isn't in the set.
|
||||
// Cheap, immediate, no model rebuild.
|
||||
function applyFilter() {
|
||||
var f = state.filterText;
|
||||
var ef = state.extFilter;
|
||||
var rows = document.querySelectorAll('#browseTbody tr.tree-row');
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var row = rows[i];
|
||||
var n = state.nodes.get(parseInt(row.dataset.id, 10));
|
||||
if (!n) continue;
|
||||
var match = !f || n.name.toLowerCase().indexOf(f) !== -1;
|
||||
row.classList.toggle('tree-row--filtered', !match);
|
||||
var nameMatch = !f || n.name.toLowerCase().indexOf(f) !== -1;
|
||||
var extMatch = !ef.size || n.isDir || ef.has(n.ext);
|
||||
row.classList.toggle('tree-row--filtered', !(nameMatch && extMatch));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -204,11 +222,81 @@
|
|||
if (!el) return;
|
||||
var rows = document.querySelectorAll('#browseTbody tr.tree-row:not(.tree-row--filtered)');
|
||||
var total = document.querySelectorAll('#browseTbody tr.tree-row').length;
|
||||
el.textContent = state.filterText
|
||||
var anyFilter = state.filterText || state.extFilter.size;
|
||||
el.textContent = anyFilter
|
||||
? rows.length + ' of ' + total + ' shown'
|
||||
: total + ' item' + (total === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
// ── Breadcrumbs ──────────────────────────────────────────────────────
|
||||
|
||||
function renderBreadcrumbs() {
|
||||
var el = document.getElementById('breadcrumbs');
|
||||
if (!el) return;
|
||||
var html = '';
|
||||
if (state.source === 'server') {
|
||||
// Server mode: every segment links to its directory URL.
|
||||
// The browser navigates → server returns embedded browse →
|
||||
// the new instance auto-loads that directory's listing.
|
||||
var path = state.currentPath || '/';
|
||||
var parts = path.split('/').filter(Boolean);
|
||||
html += '<a class="bc-link bc-root" href="/" title="Site root">🏠</a>';
|
||||
var sofar = '';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
sofar += '/' + parts[i];
|
||||
var isLast = i === parts.length - 1;
|
||||
html += '<span class="bc-sep">/</span>';
|
||||
if (isLast) {
|
||||
html += '<span class="bc-link bc-link--current">'
|
||||
+ escapeHtml(parts[i]) + '</span>';
|
||||
} else {
|
||||
html += '<a class="bc-link" href="' + escapeHtml(sofar + '/') + '">'
|
||||
+ escapeHtml(parts[i]) + '</a>';
|
||||
}
|
||||
}
|
||||
html += '<span class="bc-sep">/</span>';
|
||||
} else if (state.source === 'fs') {
|
||||
// FS-API mode: ancestor handles weren't retained when the
|
||||
// user picked the root, so we can't navigate up. Show the
|
||||
// root as 🏠 + handle name without links.
|
||||
var name = state.rootHandle ? state.rootHandle.name : '';
|
||||
html += '<span class="bc-link bc-root" title="Local directory">🏠</span>';
|
||||
if (name) {
|
||||
html += '<span class="bc-sep">/</span>';
|
||||
html += '<span class="bc-link bc-link--current">' + escapeHtml(name) + '</span>';
|
||||
}
|
||||
html += '<span class="bc-sep">/</span>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Extension filter ─────────────────────────────────────────────────
|
||||
|
||||
function renderExtFilter() {
|
||||
var sel = document.getElementById('extFilter');
|
||||
if (!sel) return;
|
||||
// Collect unique extensions from currently-loaded nodes (any
|
||||
// depth). Folders excluded. Empty-string ext omitted (no-ext
|
||||
// files would be filtered out by selecting any other ext).
|
||||
var exts = new Set();
|
||||
state.nodes.forEach(function (n) {
|
||||
if (!n.isDir && n.ext) exts.add(n.ext);
|
||||
});
|
||||
var sorted = Array.from(exts).sort();
|
||||
// Preserve current selection when re-rendering after expand.
|
||||
var selected = state.extFilter;
|
||||
var html = '';
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
var e = sorted[i];
|
||||
var isSel = selected.has(e) ? ' selected' : '';
|
||||
html += '<option value="' + escapeHtml(e) + '"' + isSel + '>'
|
||||
+ escapeHtml(e) + '</option>';
|
||||
}
|
||||
sel.innerHTML = html;
|
||||
// Size to fit content — multi-selects can be cramped otherwise.
|
||||
sel.size = Math.min(Math.max(sorted.length, 2), 6);
|
||||
}
|
||||
|
||||
function updateSortHeaders() {
|
||||
var ths = document.querySelectorAll('#browseTable thead th.sortable');
|
||||
for (var i = 0; i < ths.length; i++) {
|
||||
|
|
@ -219,10 +307,29 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Load a folder's children (lazy; idempotent re-loads).
|
||||
// Load a folder's children (lazy; idempotent re-loads). Dispatches
|
||||
// by node kind:
|
||||
// - regular folder → server JSON listing OR FS-API enumeration
|
||||
// - zip file → fetch+JSZip; entries become virtual children
|
||||
// - zip child dir → already-listed entries from the parent zip
|
||||
// (zips are enumerated whole, so child dirs
|
||||
// are pre-populated when the zip expands)
|
||||
async function loadChildren(node) {
|
||||
if (node.loaded) return;
|
||||
try {
|
||||
if (node.isZip) {
|
||||
await loadZipChildren(node);
|
||||
} else if (node._zipSyntheticDir) {
|
||||
// Synthetic dir node materialized when a zip's entry
|
||||
// list referenced "a/b/file" but had no "a/" entry.
|
||||
// Re-walk the owning zip's flat entry list with the
|
||||
// dir's full prefix.
|
||||
var owner = state.nodes.get(node.zipParentId);
|
||||
if (!owner || !owner.zipEntries) {
|
||||
throw new Error('zip parent not loaded');
|
||||
}
|
||||
setZipDirChildren(node, owner, node.zipPath + '/');
|
||||
} else if (node.isDir) {
|
||||
var raw;
|
||||
if (state.source === 'server') {
|
||||
raw = await loader.fetchServerChildren(pathFor(node) + '/');
|
||||
|
|
@ -232,16 +339,131 @@
|
|||
return;
|
||||
}
|
||||
setChildren(node.id, raw);
|
||||
}
|
||||
} catch (e) {
|
||||
window.app.modules.events.statusError(
|
||||
'Failed to load ' + node.name + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch a zip's bytes, parse with JSZip, and materialize its
|
||||
// entries as a tree of virtual nodes. JSZip's entry list is flat
|
||||
// (full paths); we reconstruct the directory hierarchy on top.
|
||||
async function loadZipChildren(zipNode) {
|
||||
await loader.ensureJSZip();
|
||||
var arrayBuffer;
|
||||
if (state.source === 'server' && zipNode.url) {
|
||||
var resp = await fetch(zipNode.url);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
|
||||
arrayBuffer = await resp.arrayBuffer();
|
||||
} else if (zipNode.handle) {
|
||||
// FS-API: top-level zip in a local folder.
|
||||
var f = await zipNode.handle.getFile();
|
||||
arrayBuffer = await f.arrayBuffer();
|
||||
} else if (zipNode.zipParentId != null) {
|
||||
// Nested zip inside another zip — read from parent JSZip.
|
||||
var parent = state.nodes.get(zipNode.zipParentId);
|
||||
if (!parent || !parent.zipFile) {
|
||||
throw new Error('parent zip not loaded');
|
||||
}
|
||||
arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
|
||||
} else {
|
||||
throw new Error('cannot fetch zip bytes (no source)');
|
||||
}
|
||||
var zip = await window.JSZip.loadAsync(arrayBuffer);
|
||||
zipNode.zipFile = zip;
|
||||
|
||||
// Build a path → raw-entry map. Entry paths are
|
||||
// "dir/sub/file.ext" or "dir/" for directories. We slice
|
||||
// to immediate children of zipNode (i.e. zero slashes after
|
||||
// a leading prefix). For nested directories, we synthesize
|
||||
// folder nodes that lazy-expand to the next level via the
|
||||
// same raw-entry list — keep it on the zipNode for replay.
|
||||
zipNode.zipEntries = []; // for re-walk on expand of subdirs
|
||||
zip.forEach(function (relPath, entry) {
|
||||
zipNode.zipEntries.push({
|
||||
path: relPath.replace(/\/$/, ''),
|
||||
isDir: entry.dir,
|
||||
size: (entry._data && entry._data.uncompressedSize) || 0,
|
||||
modTime: entry.date instanceof Date ? entry.date : null,
|
||||
rawPath: relPath
|
||||
});
|
||||
});
|
||||
|
||||
// Now seed top-level children of the zip itself.
|
||||
setZipDirChildren(zipNode, zipNode, '');
|
||||
}
|
||||
|
||||
// Populate node's childIds with the entries directly under
|
||||
// pathPrefix (relative to the owning zip). Directory entries
|
||||
// become folder nodes whose own children are seeded on first
|
||||
// expand by this same function (recursively descending zipPath).
|
||||
function setZipDirChildren(node, zipOwner, pathPrefix) {
|
||||
var seen = new Map(); // immediate child name → raw entry
|
||||
zipOwner.zipEntries.forEach(function (e) {
|
||||
if (!e.path.startsWith(pathPrefix)) return;
|
||||
var rest = e.path.substring(pathPrefix.length);
|
||||
if (rest === '') return;
|
||||
// Take the FIRST segment of the remaining path
|
||||
var slash = rest.indexOf('/');
|
||||
var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
|
||||
var isImmediateFile = !e.isDir && slash === -1;
|
||||
var isImmediateDir = e.isDir && slash === -1;
|
||||
// For deeply-nested entries (rest contains a slash), we
|
||||
// surface only the first segment as a synthetic folder
|
||||
// entry. For immediate entries, we emit the entry as-is.
|
||||
if (isImmediateFile || isImmediateDir) {
|
||||
// Immediate entry — use the real metadata.
|
||||
seen.set(firstSeg, {
|
||||
name: firstSeg,
|
||||
isDir: e.isDir,
|
||||
size: e.size,
|
||||
modTime: e.modTime,
|
||||
ext: e.isDir ? '' : loader.splitExt(firstSeg),
|
||||
url: null,
|
||||
handle: null,
|
||||
zipPath: e.path,
|
||||
zipParentId: zipOwner.id
|
||||
});
|
||||
} else if (slash !== -1 && !seen.has(firstSeg)) {
|
||||
// Deeper entry, no explicit dir entry yet — synthesize.
|
||||
seen.set(firstSeg, {
|
||||
name: firstSeg,
|
||||
isDir: true,
|
||||
size: 0,
|
||||
modTime: null,
|
||||
ext: '',
|
||||
url: null,
|
||||
handle: null,
|
||||
zipPath: pathPrefix + firstSeg,
|
||||
zipParentId: zipOwner.id
|
||||
});
|
||||
}
|
||||
});
|
||||
// Drop existing children (re-load case)
|
||||
node.childIds.forEach(function (id) { state.nodes.delete(id); });
|
||||
node.childIds = [];
|
||||
seen.forEach(function (raw) {
|
||||
var n = newNode(raw, node.id, node.depth + 1);
|
||||
// Synthetic dir nodes inside zip don't have a dedicated
|
||||
// load path — they re-walk zipEntries on expand. Mark
|
||||
// them so the dispatcher knows.
|
||||
if (raw.isDir && !n.isZip) {
|
||||
n._zipSyntheticDir = true;
|
||||
}
|
||||
node.childIds.push(n.id);
|
||||
});
|
||||
sortNodes(node.childIds);
|
||||
node.loaded = true;
|
||||
}
|
||||
|
||||
// Toggle a folder's expanded state. Loads children on first expand.
|
||||
// Treats "expandable" as either a real directory OR a zip file
|
||||
// (zip files act like folders for tree purposes — the chevron
|
||||
// expands them and the contents come from JSZip).
|
||||
async function toggleFolder(nodeId) {
|
||||
var n = state.nodes.get(nodeId);
|
||||
if (!n || !n.isDir) return;
|
||||
if (!n || !(n.isDir || n.isZip)) return;
|
||||
if (!n.expanded && !n.loaded) {
|
||||
await loadChildren(n);
|
||||
if (!n.loaded) return; // load failed
|
||||
|
|
@ -253,16 +475,12 @@
|
|||
// Recursive expand: load + expand all descendants of nodeId. Used
|
||||
// for Shift-click on a folder. Walks breadth-first, fanning out
|
||||
// through children, grand-children, etc. until every reachable
|
||||
// folder is loaded and marked expanded. Status bar shows progress
|
||||
// because deeply-nested trees can take a while.
|
||||
//
|
||||
// Parallelism: kept conservative (per-level fan-out) to avoid
|
||||
// hammering zddc-server with hundreds of concurrent listing
|
||||
// fetches. Browsers also throttle per-origin concurrency, but
|
||||
// queuing politely is friendlier than fighting that.
|
||||
// expandable node (folder OR zip) is loaded and marked expanded.
|
||||
// Skips zip-EXPANSION recursion to avoid auto-loading every
|
||||
// archive in the tree (those can be huge); plain folders only.
|
||||
async function expandSubtree(nodeId) {
|
||||
var root = state.nodes.get(nodeId);
|
||||
if (!root || !root.isDir) return;
|
||||
if (!root || !(root.isDir || root.isZip)) return;
|
||||
var status = window.app.modules.events.statusInfo;
|
||||
status('Expanding subtree…');
|
||||
var processed = 0;
|
||||
|
|
@ -270,7 +488,6 @@
|
|||
while (queue.length) {
|
||||
var batch = queue;
|
||||
queue = [];
|
||||
// Load this level's children in parallel (Promise.all).
|
||||
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
|
||||
for (var i = 0; i < batch.length; i++) {
|
||||
var n = batch[i];
|
||||
|
|
@ -278,28 +495,26 @@
|
|||
processed++;
|
||||
for (var j = 0; j < n.childIds.length; j++) {
|
||||
var c = state.nodes.get(n.childIds[j]);
|
||||
if (c && c.isDir) queue.push(c);
|
||||
// Recurse into plain folders only — don't auto-
|
||||
// expand zip archives during a subtree expand
|
||||
// (they can be very large).
|
||||
if (c && c.isDir && !c.isZip) queue.push(c);
|
||||
}
|
||||
}
|
||||
// Re-render after each level so the user sees progress
|
||||
// rather than a long pause then a sudden full-tree dump.
|
||||
render();
|
||||
status('Expanding subtree… (' + processed + ' folders loaded)');
|
||||
}
|
||||
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
|
||||
}
|
||||
|
||||
// Recursive collapse: mark this node and every descendant as
|
||||
// collapsed. Doesn't unload — if the user re-expands later, the
|
||||
// children are still in memory and re-render is instant.
|
||||
function collapseSubtree(nodeId) {
|
||||
var root = state.nodes.get(nodeId);
|
||||
if (!root || !root.isDir) return;
|
||||
if (!root || !(root.isDir || root.isZip)) return;
|
||||
function walk(n) {
|
||||
n.expanded = false;
|
||||
for (var i = 0; i < n.childIds.length; i++) {
|
||||
var c = state.nodes.get(n.childIds[i]);
|
||||
if (c && c.isDir) walk(c);
|
||||
if (c && (c.isDir || c.isZip)) walk(c);
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
|
|
@ -344,6 +559,13 @@
|
|||
applyFilter();
|
||||
updateCount();
|
||||
},
|
||||
setExtFilter: function (extArr) {
|
||||
state.extFilter = new Set((extArr || []).map(function (e) {
|
||||
return String(e).toLowerCase().replace(/^\./, '');
|
||||
}));
|
||||
applyFilter();
|
||||
updateCount();
|
||||
},
|
||||
pathFor: pathFor
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@
|
|||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -52,9 +54,10 @@
|
|||
|
||||
<div id="browseRoot" class="browse-root hidden">
|
||||
<div class="toolbar">
|
||||
<span class="toolbar__path" id="currentPath"></span>
|
||||
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
|
||||
<input type="search" id="filterInput" class="toolbar__filter"
|
||||
placeholder="Filter by name (substring)..." />
|
||||
<select id="extFilter" class="toolbar__ext" multiple aria-label="Filter by extension"></select>
|
||||
<span class="toolbar__count" id="entryCount"></span>
|
||||
</div>
|
||||
<div class="browse-table-wrap">
|
||||
|
|
|
|||
2
mdedit/dist/mdedit.html
vendored
2
mdedit/dist/mdedit.html
vendored
|
|
@ -1774,7 +1774,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Markdown</span>
|
||||
<span class="build-timestamp">v0.0.13</span>
|
||||
<span class="build-timestamp">v0.0.14</span>
|
||||
</div>
|
||||
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp">v0.0.13</span>
|
||||
<span class="build-timestamp">v0.0.14</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1376,7 +1376,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp">v0.0.13</span>
|
||||
<span class="build-timestamp">v0.0.14</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>
|
||||
|
|
|
|||
|
|
@ -866,7 +866,7 @@ body {
|
|||
</g>
|
||||
</svg>
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp">v0.0.13</span>
|
||||
<span class="build-timestamp">v0.0.14</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
|
|
|
|||
|
|
@ -1774,7 +1774,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Markdown</span>
|
||||
<span class="build-timestamp">v0.0.13</span>
|
||||
<span class="build-timestamp">v0.0.14</span>
|
||||
</div>
|
||||
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2210,7 +2210,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp">v0.0.13</span>
|
||||
<span class="build-timestamp">v0.0.14</span>
|
||||
</div>
|
||||
<div class="app-header__spacer"></div>
|
||||
<div class="app-header__icons">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.13
|
||||
transmittal=v0.0.13
|
||||
classifier=v0.0.13
|
||||
mdedit=v0.0.13
|
||||
landing=v0.0.13
|
||||
form=v0.0.13
|
||||
browse=v0.0.13
|
||||
archive=v0.0.14
|
||||
transmittal=v0.0.14
|
||||
classifier=v0.0.14
|
||||
mdedit=v0.0.14
|
||||
landing=v0.0.14
|
||||
form=v0.0.14
|
||||
browse=v0.0.14
|
||||
|
|
|
|||
|
|
@ -722,7 +722,7 @@ body.help-open .app-header {
|
|||
</g>
|
||||
</svg>
|
||||
<span class="app-header__title" id="form-title">ZDDC Form</span>
|
||||
<span class="build-timestamp">v0.0.13</span>
|
||||
<span class="build-timestamp">v0.0.14</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue