diff --git a/browse/README.md b/browse/README.md new file mode 100644 index 0000000..24abd86 --- /dev/null +++ b/browse/README.md @@ -0,0 +1,53 @@ +# browse — directory listing tool + +Generic file browser for any directory. Designed to work with ZDDC +archives but useful for any folder. Single-file HTML, no install. + +## How it's used + +Two modes, auto-detected at page load: + +1. **Online (zddc-server backed).** When this HTML is served by + zddc-server at a folder URL — which it is by default for any + directory under `ZDDC_ROOT` that doesn't have an `index.html` — + the JS queries the same URL with `Accept: application/json` to + load the directory's listing and renders it as a sortable, + filterable table. + +2. **Local (FileSystemAccessAPI).** Click "Select Directory" in the + header to pick any folder on your computer. Works in + Chromium-based browsers (Chrome, Edge, Brave, etc.). No server + required; the directory is read directly from disk. + +## What it does + +- Lists files and folders with name, size, type (extension), and + modified date. +- Click a folder to expand inline. Children load lazily on first + expand. +- Click a column header to sort by that column. Click again to + reverse. +- Type in the filter to narrow to entries whose name contains the + substring. +- Click any file to open it in a new tab — for server-backed pages, + this routes through zddc-server's normal handler (so an `.archive` + redirect, an apps cascade override, etc. all work as expected). + +## Design notes + +- **No ZDDC-specific filtering.** This tool is intentionally + domain-agnostic. The companion `archive` tool layers ZDDC + parsing (project / status / revision filters, tracking-number + resolution) on top of the same listing API. Use `archive` when + you want ZDDC semantics; use `browse` when you just want to see + what's in a folder. +- **Default at directory URLs.** zddc-server's `directory.go` + serves the embedded browse.html bytes for any directory request + with `Accept: text/html` and no `index.html` present. This + means a user navigating to any folder under `ZDDC_ROOT` gets a + usable browser without anyone having to drop a file into the + archive. +- **Apps cascade override.** Like every other ZDDC tool, the + served `browse.html` can be overridden per-folder via a `.zddc + apps:` entry. The default is the embedded copy from the binary; + operators can pin a specific version or URL if they want. diff --git a/browse/build.sh b/browse/build.sh new file mode 100755 index 0000000..2d13692 --- /dev/null +++ b/browse/build.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -eu + +root_dir=$(cd "$(dirname "$0")" && pwd) +. "$root_dir/../shared/build-lib.sh" + +src_html="$root_dir/template.html" +output_dir="$root_dir/dist" +output_html="$output_dir/browse.html" + +mkdir -p "$output_dir" +ensure_exists "$src_html" + +css_temp=$(mktemp) +js_raw=$(mktemp) +js_temp=$(mktemp) +cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; } +trap cleanup EXIT + +# CSS files: shared base first, then browse-specific. +concat_files \ + "../shared/base.css" \ + "css/base.css" \ + "css/tree.css" \ + > "$css_temp" + +# JS files: shared canonical helpers, then browse modules. +# init.js must come first so window.app exists when later modules +# attach to it. +concat_files \ + "../shared/zddc.js" \ + "../shared/theme.js" \ + "js/init.js" \ + "js/loader.js" \ + "js/tree.js" \ + "js/events.js" \ + "js/app.js" \ + > "$js_raw" + +# Escape any literal ` block doesn't get terminated prematurely. +escape_js_close_tags "$js_raw" "$js_temp" + +tool=browse +compute_build_label "$tool" "$@" + +# Replace template placeholders with concatenated CSS/JS + label. +awk -v css_file="$css_temp" -v js_file="$js_temp" \ + -v build_label="$build_label" -v favicon="$favicon_data_uri" ' + /\{\{CSS_PLACEHOLDER\}\}/ { + while ((getline line < css_file) > 0) print line + close(css_file); next + } + /\{\{JS_PLACEHOLDER\}\}/ { + while ((getline line < js_file) > 0) print line + close(js_file); next + } + { + gsub(/\{\{BUILD_LABEL\}\}/, build_label) + gsub(/\{\{FAVICON\}\}/, favicon) + print + } +' "$src_html" > "$output_html" + +echo "Wrote $output_html" + +# Promote AFTER the dist file exists so promote_release can copy from +# $output_html. (The order matters — _promote_stable does cp $output_html ...) +promote_release "$tool" diff --git a/browse/css/base.css b/browse/css/base.css new file mode 100644 index 0000000..17a96fd --- /dev/null +++ b/browse/css/base.css @@ -0,0 +1,70 @@ +/* browse-specific layout on top of shared/base.css */ + +html, body { + height: 100%; + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +#appMain { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* Empty / first-paint state */ +.empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.empty-state__inner { + max-width: 640px; + color: var(--text-muted); + line-height: 1.5; +} + +.empty-state__inner h2 { + color: var(--text); + margin: 0 0 1rem 0; + font-size: 1.5rem; +} + +.empty-state__inner ul { + margin: 1rem 0; + padding-left: 1.5rem; +} + +.empty-state__inner li { + margin: 0.4rem 0; +} + +.hidden { display: none !important; } + +/* Status bar — shows transient errors/info */ +.status-bar { + padding: 0.4rem 1rem; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + font-size: 0.85rem; + color: var(--text-muted); + min-height: 1.6rem; + line-height: 1.6rem; + flex-shrink: 0; +} + +.status-bar--error { color: #b00020; } +.status-bar--info { color: var(--primary); } diff --git a/browse/css/tree.css b/browse/css/tree.css new file mode 100644 index 0000000..5cab6f9 --- /dev/null +++ b/browse/css/tree.css @@ -0,0 +1,181 @@ +/* Toolbar above the listing */ + +.browse-root { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.6rem 1rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + flex-wrap: wrap; +} + +.toolbar__path { + 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; +} + +.toolbar__filter { + width: 22rem; + max-width: 100%; + padding: 0.3rem 0.6rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-size: 0.9rem; +} + +.toolbar__count { + font-size: 0.8rem; + color: var(--text-muted); + white-space: nowrap; +} + +/* Table — folders + files in a tree */ + +.browse-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + background: var(--bg); + flex: 1; +} + +.browse-table thead th { + position: sticky; + top: 0; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + text-align: left; + padding: 0.5rem 0.75rem; + font-weight: 600; + color: var(--text); + user-select: none; + z-index: 1; +} + +.browse-table th.sortable { + cursor: pointer; +} + +.browse-table th.sortable:hover { + background: var(--bg-hover, #e8e8e8); +} + +.sort-arrow { + display: inline-block; + width: 0.7rem; + color: var(--text-muted); + font-size: 0.7rem; + margin-left: 0.2rem; +} + +.browse-table th.sort-asc .sort-arrow::after { content: "▲"; color: var(--text); } +.browse-table th.sort-desc .sort-arrow::after { content: "▼"; color: var(--text); } + +.browse-table tbody td { + padding: 0.3rem 0.75rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.browse-table tbody tr:hover { + background: var(--bg-hover, #f6faff); +} + +/* Tree-row — name cell with indent + chevron */ + +.tree-name { + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; +} + +.tree-name__indent { + flex: 0 0 auto; +} + +.tree-name__chevron { + width: 1rem; + text-align: center; + color: var(--text-muted); + cursor: pointer; + user-select: none; + flex: 0 0 1rem; + line-height: 1; +} + +.tree-name__chevron--leaf { visibility: hidden; } +.tree-name__chevron::before { content: "▶"; font-size: 0.65rem; } +.tree-row.expanded > td .tree-name__chevron::before { content: "▼"; } + +.tree-name__icon { + flex: 0 0 1.1rem; + text-align: center; + color: var(--text-muted); + font-size: 1rem; + line-height: 1; +} + +.tree-name__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +.tree-name__label.is-folder { + font-weight: 500; +} + +.tree-name__label.is-file { + cursor: pointer; + color: var(--primary); + text-decoration: none; +} + +.tree-name__label.is-file:hover { + text-decoration: underline; +} + +/* Numeric columns right-aligned */ +.col-size, .col-date { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--text-muted); +} + +.col-ext { + color: var(--text-muted); + font-family: Consolas, Monaco, monospace; + font-size: 0.85rem; +} + +/* Loading row */ +.tree-row--loading td { + color: var(--text-muted); + font-style: italic; + padding: 0.5rem 1rem 0.5rem calc(0.75rem + 2.4rem); +} + +/* When filter hides a row */ +.tree-row--filtered { display: none; } diff --git a/browse/js/app.js b/browse/js/app.js new file mode 100644 index 0000000..8d76be2 --- /dev/null +++ b/browse/js/app.js @@ -0,0 +1,36 @@ +// app.js — bootstrap. Runs after every other module's IIFE has +// registered its functions on window.app.modules. +(function () { + 'use strict'; + + var state = window.app.state; + var loader = window.app.modules.loader; + var tree = window.app.modules.tree; + var events = window.app.modules.events; + + async function bootstrap() { + events.init(); + + // Try server auto-detect. If this page is served by zddc-server + // (or any server with a Caddy-shaped JSON listing), load the + // current directory automatically. Otherwise show the empty + // state with the "Select Directory" button. + var detected = await loader.autoDetectServerMode(); + 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') + + ' from ' + detected.path); + } + // Else: empty state stays visible; user can click Select Directory. + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootstrap); + } else { + bootstrap(); + } +})(); diff --git a/browse/js/events.js b/browse/js/events.js new file mode 100644 index 0000000..49a5644 --- /dev/null +++ b/browse/js/events.js @@ -0,0 +1,110 @@ +// 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; + + 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(); + document.getElementById('currentPath').textContent = state.currentPath; + 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'); + } + + function init() { + // Header buttons + var btn = document.getElementById('addDirectoryBtn'); + if (btn) btn.addEventListener('click', pickLocalDir); + + // Filter input + var filter = document.getElementById('filterInput'); + if (filter) { + filter.addEventListener('input', function () { + tree.setFilter(filter.value); + }); + } + + // Sort headers + var ths = document.querySelectorAll('#browseTable thead th.sortable'); + for (var i = 0; i < ths.length; i++) { + (function (th) { + th.addEventListener('click', function () { + tree.setSort(th.dataset.sort); + }); + })(ths[i]); + } + + // Tree-row clicks (event delegation on tbody). + var tbody = document.getElementById('browseTbody'); + if (tbody) { + tbody.addEventListener('click', function (e) { + var row = e.target.closest('tr.tree-row'); + if (!row) return; + var isDir = row.dataset.isdir === 'true'; + if (!isDir) { + // Let the tag's natural target=_blank handle file + // clicks. Don't intercept. + return; + } + // Folder: toggle on chevron OR anywhere on the row except + // the file link (no link in folder rows). + e.preventDefault(); + tree.toggleFolder(parseInt(row.dataset.id, 10)); + }); + } + } + + // Public API + window.app.modules.events = { + init: init, + statusError: statusError, + statusInfo: statusInfo, + statusClear: statusClear, + showBrowseRoot: showBrowseRoot + }; +})(); diff --git a/browse/js/init.js b/browse/js/init.js new file mode 100644 index 0000000..6555e4b --- /dev/null +++ b/browse/js/init.js @@ -0,0 +1,40 @@ +// Bootstrap window.app for the browse tool. Mirrors the convention +// used by every other ZDDC tool — ./build's CSS/JS concat order means +// this file runs FIRST inside the IIFE-of-IIFEs. +(function () { + 'use strict'; + + if (!window.app) { + window.app = { modules: {}, state: {} }; + } + + window.app.state = { + // Source: 'server' | 'fs' | null. Determines how the loader + // resolves entries. + source: null, + + // For server-source: the URL path of the directory currently + // being viewed. Always starts with '/' and ends with '/'. + // For fs-source: the displayed path string (no semantic + // meaning — just for the toolbar). + currentPath: '/', + + // FileSystemAccessAPI root handle (null in server mode). + rootHandle: null, + + // Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1. + sort: { key: 'name', dir: 1 }, + + // Current filter substring (lowercase). + filterText: '', + + // The tree's in-memory representation. Each node: + // { id, name, isDir, size, modTime, ext, url, depth, + // parentId, expanded, loaded, childIds } + // Stored flat in a Map keyed by id; render order derived + // from a depth-first walk. + nodes: new Map(), + rootIds: [], + nextId: 1 + }; +})(); diff --git a/browse/js/loader.js b/browse/js/loader.js new file mode 100644 index 0000000..48adbbc --- /dev/null +++ b/browse/js/loader.js @@ -0,0 +1,130 @@ +// loader.js — fetches directory entries for either source mode. +// +// Server mode: GET with Accept: application/json. zddc-server +// (and Caddy's built-in browse, which we mirror) returns an array of +// FileInfo {name, size, url, mod_time, mode, is_dir, is_symlink}. +// +// FS-API mode: enumerate a FileSystemDirectoryHandle's children. No +// network involved; works on local folders the user picked. +(function () { + 'use strict'; + + var state = window.app.state; + + function splitExt(name) { + var i = name.lastIndexOf('.'); + if (i <= 0 || i === name.length - 1) return ''; + return name.substring(i + 1).toLowerCase(); + } + + // Build a raw entry from the server's FileInfo shape. + function fromServerEntry(e) { + // Server returns directory names with a trailing "/". Strip + // it for display; the is_dir flag is the canonical signal. + var displayName = e.is_dir ? e.name.replace(/\/$/, '') : e.name; + return { + name: displayName, + isDir: e.is_dir, + size: e.size || 0, + modTime: e.mod_time ? new Date(e.mod_time) : null, + ext: e.is_dir ? '' : splitExt(displayName), + url: e.url || null, + // FS-API specific (null in server mode): + handle: null + }; + } + + // Build a raw entry from a FileSystemHandle. + async function fromHandle(handle) { + var name = handle.name; + var isDir = handle.kind === 'directory'; + var size = 0; + var modTime = null; + if (!isDir) { + try { + var f = await handle.getFile(); + size = f.size; + modTime = new Date(f.lastModified); + } catch (_e) { + // permission lost; leave size/modTime defaults + } + } + return { + name: name, + isDir: isDir, + size: size, + modTime: modTime, + ext: isDir ? '' : splitExt(name), + url: null, + handle: handle + }; + } + + // Fetch children of a directory in server mode. + // path must end with '/' so the request hits the directory route. + async function fetchServerChildren(path) { + if (!path.endsWith('/')) path += '/'; + var resp = await fetch(path, { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status + ' fetching ' + path); + } + var data = await resp.json(); + if (!Array.isArray(data)) { + throw new Error('Unexpected response shape from ' + path); + } + return data.map(fromServerEntry); + } + + // Enumerate a FileSystemDirectoryHandle's immediate children. + async function fetchFsChildren(dirHandle) { + var entries = []; + for await (var [_name, handle] of dirHandle.entries()) { + entries.push(await fromHandle(handle)); + } + return entries; + } + + // Probe whether THIS page is being served by zddc-server (or any + // server that responds to JSON listing requests). If so, switch to + // server mode automatically and load the current directory. + async function autoDetectServerMode() { + // Only attempt when running over http(s) and the location's + // path looks like a directory. Probing on file:// is pointless. + if (location.protocol !== 'http:' && location.protocol !== 'https:') { + return false; + } + // Strip any /.html from the path to get the directory. + var path = location.pathname; + // If the URL points at the browse.html itself, the directory + // is the parent. If it's a directory ending in '/', use it. + var dirPath; + if (path.endsWith('/')) { + dirPath = path; + } else { + // e.g. '/some/dir/browse.html' → '/some/dir/' + var slash = path.lastIndexOf('/'); + dirPath = slash >= 0 ? path.substring(0, slash + 1) : '/'; + } + + try { + var entries = await fetchServerChildren(dirPath); + state.source = 'server'; + state.currentPath = dirPath; + return { entries: entries, path: dirPath }; + } catch (_e) { + // Not a server-backed page (e.g. opened via file://). + return null; + } + } + + // Public API + window.app.modules.loader = { + fetchServerChildren: fetchServerChildren, + fetchFsChildren: fetchFsChildren, + autoDetectServerMode: autoDetectServerMode, + splitExt: splitExt + }; +})(); diff --git a/browse/js/tree.js b/browse/js/tree.js new file mode 100644 index 0000000..dddf6ac --- /dev/null +++ b/browse/js/tree.js @@ -0,0 +1,288 @@ +// tree.js — in-memory tree model + DOM rendering. +// +// Nodes are stored flat in state.nodes (Map by id). The visible +// render is a depth-first walk starting from state.rootIds, skipping +// children of unexpanded folders. This decouples model from DOM and +// keeps re-renders linear in the visible-row count. +(function () { + 'use strict'; + + var state = window.app.state; + var loader = window.app.modules.loader; + + // ── Model helpers ──────────────────────────────────────────────────── + + function newNode(raw, parentId, depth) { + var id = state.nextId++; + var node = { + id: id, + name: raw.name, + isDir: raw.isDir, + size: raw.size, + modTime: raw.modTime, + ext: raw.ext, + url: raw.url, + handle: raw.handle, + depth: depth, + parentId: parentId, + expanded: false, + loaded: false, + childIds: [] + }; + state.nodes.set(id, node); + return node; + } + + function clearTree() { + state.nodes.clear(); + state.rootIds = []; + state.nextId = 1; + } + + // Sort an array of nodes by current sort key. Folders always come + // first within a level (mimics common file managers). + function sortNodes(ids) { + var key = state.sort.key; + var dir = state.sort.dir; + ids.sort(function (a, b) { + var na = state.nodes.get(a); + var nb = state.nodes.get(b); + // Folders before files + if (na.isDir !== nb.isDir) return na.isDir ? -1 : 1; + var av, bv; + switch (key) { + case 'size': + av = na.size; bv = nb.size; break; + case 'ext': + av = na.ext; bv = nb.ext; break; + case 'date': + av = na.modTime ? na.modTime.getTime() : 0; + bv = nb.modTime ? nb.modTime.getTime() : 0; + break; + default: + av = na.name.toLowerCase(); + bv = nb.name.toLowerCase(); + } + if (av < bv) return -1 * dir; + if (av > bv) return 1 * dir; + return na.name.toLowerCase().localeCompare(nb.name.toLowerCase()); + }); + } + + // Populate state with the root listing. + function setRoot(rawEntries) { + clearTree(); + rawEntries.forEach(function (raw) { + var n = newNode(raw, null, 0); + state.rootIds.push(n.id); + }); + sortNodes(state.rootIds); + } + + // Populate a folder's children. Caller passes raw entries in any order. + function setChildren(parentId, rawEntries) { + var parent = state.nodes.get(parentId); + if (!parent) return; + // Drop any existing children first (re-load case). + parent.childIds.forEach(function (id) { state.nodes.delete(id); }); + parent.childIds = []; + rawEntries.forEach(function (raw) { + var n = newNode(raw, parentId, parent.depth + 1); + parent.childIds.push(n.id); + }); + sortNodes(parent.childIds); + parent.loaded = true; + } + + // Walk visible nodes in render order. + function visibleIds() { + var out = []; + function walk(ids) { + 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); + } + } + // 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); + }); + walk(state.rootIds); + return out; + } + + // ── Rendering ──────────────────────────────────────────────────────── + + function fmtSize(bytes) { + if (bytes == null) return ''; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + } + + function fmtDate(d) { + if (!d) return ''; + var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); + } + + function escapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + 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 nameInner; + if (node.isDir) { + nameInner = '' + + escapeHtml(node.name) + ''; + } 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). + var href = node.url || '#'; + nameInner = '' + escapeHtml(node.name) + ''; + } + return '' + + '' + + '' + + '' + + '' + + '' + + '' + iconChar + '' + + nameInner + + '' + + '' + + '' + (node.isDir ? '' : fmtSize(node.size)) + '' + + '' + (node.isDir ? '' : escapeHtml(node.ext)) + '' + + '' + fmtDate(node.modTime) + '' + + ''; + } + + function render() { + var tbody = document.getElementById('browseTbody'); + if (!tbody) return; + var ids = visibleIds(); + var html = ''; + for (var i = 0; i < ids.length; i++) { + html += rowHtml(state.nodes.get(ids[i])); + } + tbody.innerHTML = html; + applyFilter(); + updateCount(); + updateSortHeaders(); + } + + // Filter is purely DOM-level: hide rows whose name doesn't match. + // Cheap, immediate, no model rebuild. + function applyFilter() { + var f = state.filterText; + 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); + } + } + + function updateCount() { + var el = document.getElementById('entryCount'); + 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 + ? rows.length + ' of ' + total + ' shown' + : total + ' item' + (total === 1 ? '' : 's'); + } + + function updateSortHeaders() { + var ths = document.querySelectorAll('#browseTable thead th.sortable'); + for (var i = 0; i < ths.length; i++) { + ths[i].classList.remove('sort-asc', 'sort-desc'); + if (ths[i].dataset.sort === state.sort.key) { + ths[i].classList.add(state.sort.dir > 0 ? 'sort-asc' : 'sort-desc'); + } + } + } + + // Toggle a folder's expanded state. Loads children on first expand. + async function toggleFolder(nodeId) { + var n = state.nodes.get(nodeId); + if (!n || !n.isDir) return; + if (!n.expanded && !n.loaded) { + try { + var raw; + if (state.source === 'server') { + var childPath = state.currentPath + + n.name + '/'; // server URLs are relative paths + // Walk up the parent chain to build the full path. + childPath = pathFor(n) + '/'; + raw = await loader.fetchServerChildren(childPath); + } else if (state.source === 'fs') { + raw = await loader.fetchFsChildren(n.handle); + } else { + return; + } + window.app.modules.tree.setChildren(nodeId, raw); + } catch (e) { + window.app.modules.events.statusError('Failed to load folder: ' + e.message); + return; + } + } + n.expanded = !n.expanded; + render(); + } + + // Compute the URL/path for a node by walking parents. + function pathFor(node) { + var parts = []; + var cur = node; + while (cur) { + parts.unshift(cur.name); + cur = cur.parentId == null ? null : state.nodes.get(cur.parentId); + } + if (state.source === 'server') { + // currentPath is the dir containing rootIds — root nodes + // sit DIRECTLY under it. + return state.currentPath.replace(/\/$/, '') + '/' + parts.join('/'); + } + return parts.join('/'); + } + + // Public API + window.app.modules.tree = { + setRoot: setRoot, + setChildren: setChildren, + render: render, + toggleFolder: toggleFolder, + setSort: function (key) { + if (state.sort.key === key) { + state.sort.dir = -state.sort.dir; + } else { + state.sort.key = key; + state.sort.dir = 1; + } + render(); + }, + setFilter: function (s) { + state.filterText = (s || '').toLowerCase(); + applyFilter(); + updateCount(); + }, + pathFor: pathFor + }; +})(); diff --git a/browse/template.html b/browse/template.html new file mode 100644 index 0000000..fd878ce --- /dev/null +++ b/browse/template.html @@ -0,0 +1,78 @@ + + + + + + ZDDC Browse + + + + +
+
+ +
+ ZDDC Browse + {{BUILD_LABEL}} +
+ +
+
+ +
+
+ +
+
+
+

ZDDC Browse

+

A simple directory listing for ZDDC archives — and any directory. + Pick how you want to browse:

+
    +
  • Online — when this page is served by zddc-server, the + listing for the current directory loads automatically.
  • +
  • Local — click Select Directory to pick any folder + on your computer (Chromium-based browsers).
  • +
+

Once loaded: click folders to expand, click headers to sort, type + in the filter to narrow by name. Click any file to open it.

+
+
+ + +
+ +
+ + + + diff --git a/build b/build index e2a5113..bfaeea8 100755 --- a/build +++ b/build @@ -157,6 +157,7 @@ sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS +sh "$SCRIPT_DIR/browse/build.sh" $TOOL_RELEASE_ARGS echo "" echo "=== Assembling zddc/dist/web/ ===" @@ -176,7 +177,8 @@ cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/ cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html" cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html" cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html" -echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form}.html" +cp "$SCRIPT_DIR/browse/dist/browse.html" "$SCRIPT_DIR/zddc/dist/web/browse.html" +echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,browse}.html" # Mirror the five cascade-served HTMLs into the apps embed source dir so the # next `go build` of zddc-server picks them up via //go:embed. ONLY happens @@ -193,6 +195,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.html" cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html" cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html" + cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html" echo "Populated $EMBED_DIR/ for //go:embed" # The form renderer lives next to its handler (no cascade needed — it's a @@ -207,7 +210,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then VERSIONS_FILE="$EMBED_DIR/versions.txt" { echo "# Generated by build.sh — do not edit. One = per line." - for _tool in archive transmittal classifier mdedit landing form; do + for _tool in archive transmittal classifier mdedit landing form browse; do _label_file="$BUILD_LABELS_DIR/${_tool}.label" if [ -f "$_label_file" ]; then _label=$(cat "$_label_file") @@ -743,7 +746,7 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then # Tag the seven artifacts at HEAD. Pre-flight already validated that # any pre-existing tag is in HEAD's history, so this is safe. _head=$(git -C "$SCRIPT_DIR" rev-parse HEAD) - for _t in archive transmittal classifier mdedit landing form zddc-server; do + for _t in archive transmittal classifier mdedit landing form browse zddc-server; do _tag="${_t}-v${RELEASE_VERSION}" if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then _existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag") @@ -777,7 +780,7 @@ else echo "Version: v$RELEASE_VERSION" echo "" echo "Tags created locally on main (push when ready):" - for _t in archive transmittal classifier mdedit landing form zddc-server; do + for _t in archive transmittal classifier mdedit landing form browse zddc-server; do echo " ${_t}-v${RELEASE_VERSION}" done echo " git push origin main && git push origin --tags" diff --git a/shared/build-lib.sh b/shared/build-lib.sh index 26259c3..5bb74e5 100755 --- a/shared/build-lib.sh +++ b/shared/build-lib.sh @@ -222,7 +222,7 @@ _emit_build_label_sidecar() { # Tools that participate in the lockstep release. Source of truth — used # by helpers that enumerate "all release artifacts" (matrix render, # coordinated next-stable, channel-link verifier). -ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form zddc-server" +ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form browse zddc-server" # Compute the next-stable target for a single tool — patch-bump of its own # latest -vX.Y.Z tag. Used by compute_build_label so a tool's @@ -663,7 +663,7 @@ verify_channel_links() { _missing=0 _verified=0 - for _t in archive transmittal classifier mdedit landing form; do + for _t in archive transmittal classifier mdedit landing form browse; do for _ch in stable beta alpha; do _f="$_rdir/${_t}_${_ch}.html" if [ -e "$_f" ]; then diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index ff69e23..62e8b55 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -18,6 +18,8 @@ var ( // requestDir. Rules: // // - archive: every directory (multi-project, project, archive, vendor) +// - browse: every directory (generic file listing — also the default +// served at folder URLs without an index.html; see directory.go) // - classifier: requestDir is, or descends from, a folder named // "Incoming", "Working", or "Staging" (the directories where // incoming/outgoing files get classified) @@ -37,6 +39,8 @@ func AppAvailableAt(root, requestDir, app string) bool { switch app { case "archive": return true + case "browse": + return true case "landing": return requestDir == root case "classifier": diff --git a/zddc/internal/apps/embed.go b/zddc/internal/apps/embed.go index 4338210..479026c 100644 --- a/zddc/internal/apps/embed.go +++ b/zddc/internal/apps/embed.go @@ -27,6 +27,9 @@ var embeddedMdedit []byte //go:embed embedded/index.html var embeddedLanding []byte +//go:embed embedded/browse.html +var embeddedBrowse []byte + // EmbeddedBytes returns the embedded HTML for app, or nil if either app is // not one of the canonical names or the embedded slot is empty (no build // has populated it). @@ -43,6 +46,8 @@ func EmbeddedBytes(app string) []byte { b = embeddedMdedit case "landing": b = embeddedLanding + case "browse": + b = embeddedBrowse default: return nil } diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html new file mode 100644 index 0000000..e69de29 diff --git a/zddc/internal/apps/handler.go b/zddc/internal/apps/handler.go index 285bf44..a4293b8 100644 --- a/zddc/internal/apps/handler.go +++ b/zddc/internal/apps/handler.go @@ -62,6 +62,8 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) { return "classifier", dir case "mdedit.html": return "mdedit", dir + case "browse.html": + return "browse", dir } return "", "" } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 57393a5..fdc584c 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -2,13 +2,13 @@ package handler import ( "encoding/json" - "fmt" "log/slog" "net/http" "os" "path/filepath" "strings" + "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" @@ -89,14 +89,26 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } - // Minimal HTML for accidental browser navigation + // Browser HTML fallback: serve the embedded `browse` tool. It's a + // single-file SPA whose autoDetectServerMode loads the JSON listing + // for the current directory and renders it as a sortable, filterable + // tree. Same bytes that get served at //browse.html — but at + // the bare directory URL too, so any zddc-served folder presents a + // usable file browser to anyone who navigates to it. + body := apps.EmbeddedBytes("browse") + if len(body) == 0 { + // Bootstrap state: a fresh build hasn't populated browse.html + // into the embed yet. Fall through to JSON for clients that + // will still parse it. + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + if err := json.NewEncoder(w).Encode(entries); err != nil { + slog.Error("encoding directory listing (no-embed fallback)", "err", err) + } + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, - `Index of %s`+ - `

Index of %s

`+ - `

This server is designed to be used with the ZDDC Archive Browser. `+ - `Directory listings are available as JSON (Accept: application/json).

`+ - ``, - urlPath, urlPath, - ) + w.Header().Set("X-ZDDC-Source", "embedded:browse") + w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") + _, _ = w.Write(body) }