feat(browse): generic directory listing tool — default at folder URLs
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.
Modes (auto-detected at page load):
- Online: when served by zddc-server at a folder URL, queries
the same URL with Accept: application/json to load the listing
and renders it. Auto-served as the default at any directory
under ZDDC_ROOT without an index.html (replacing the previous
minimal-HTML stub from directory.go).
- Local: 'Select Directory' button uses FileSystemAccessAPI to
pick any folder on disk; works in Chromium-based browsers.
Features (Phase 1 — what's in this commit):
- Tree view with lazy-loaded folders (children fetched on first
expand).
- Sort by name / size / extension / date (column header click).
- Filter by name substring (toolbar input).
- File click opens in a new tab — for server-backed pages,
routes through zddc-server's normal handler so .archive
redirects + apps cascade overrides + ACL all apply.
Phase 2 deferred:
- ZIP files inline expansion (treat archive entries as virtual
children).
- File preview popup (reuse shared/preview-lib.js).
- Extension multi-select filter.
Wiring:
- browse/ added to top-level ./build's per-tool list, embed
block, versions.txt, and the lockstep release commit + tag set.
All seven tools (archive, transmittal, classifier, mdedit,
landing, form, browse) advance together on stable cuts.
- shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
verify_channel_links's per-tool loop.
- zddc/internal/apps/embed.go: //go:embed browse.html +
EmbeddedBytes("browse") case.
- zddc/internal/apps/availability.go: browse available at every
directory (same as archive).
- zddc/internal/apps/handler.go: MatchAppHTML routes
/<dir>/browse.html → 'browse'.
- zddc/internal/handler/directory.go: when a directory request
arrives with Accept: text/html and no index.html exists,
serve the embedded browse.html bytes (with a JSON-fallback
if the embedded slot is empty during bootstrap).
This commit is contained in:
parent
1033d30ad9
commit
fb13ff4fd8
17 changed files with 1097 additions and 16 deletions
53
browse/README.md
Normal file
53
browse/README.md
Normal file
|
|
@ -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.
|
||||
69
browse/build.sh
Executable file
69
browse/build.sh
Executable file
|
|
@ -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 `</` inside JS string/template literals so the
|
||||
# inlined <script> 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"
|
||||
70
browse/css/base.css
Normal file
70
browse/css/base.css
Normal file
|
|
@ -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); }
|
||||
181
browse/css/tree.css
Normal file
181
browse/css/tree.css
Normal file
|
|
@ -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; }
|
||||
36
browse/js/app.js
Normal file
36
browse/js/app.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
})();
|
||||
110
browse/js/events.js
Normal file
110
browse/js/events.js
Normal file
|
|
@ -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 <a> 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
|
||||
};
|
||||
})();
|
||||
40
browse/js/init.js
Normal file
40
browse/js/init.js
Normal file
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
130
browse/js/loader.js
Normal file
130
browse/js/loader.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// loader.js — fetches directory entries for either source mode.
|
||||
//
|
||||
// Server mode: GET <urlPath> 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 /<tool>.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
|
||||
};
|
||||
})();
|
||||
288
browse/js/tree.js
Normal file
288
browse/js/tree.js
Normal file
|
|
@ -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, '>').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 = '<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).
|
||||
var href = node.url || '#';
|
||||
nameInner = '<a class="tree-name__label is-file"'
|
||||
+ ' href="' + escapeHtml(href) + '"'
|
||||
+ ' target="_blank" rel="noopener">' + escapeHtml(node.name) + '</a>';
|
||||
}
|
||||
return ''
|
||||
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
|
||||
+ '" data-id="' + node.id + '" data-isdir="' + node.isDir + '">'
|
||||
+ '<td class="col-name">'
|
||||
+ '<span class="tree-name">'
|
||||
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
|
||||
+ '<span class="' + chevronClass + '"></span>'
|
||||
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
||||
+ nameInner
|
||||
+ '</span>'
|
||||
+ '</td>'
|
||||
+ '<td class="col-size">' + (node.isDir ? '' : fmtSize(node.size)) + '</td>'
|
||||
+ '<td class="col-ext">' + (node.isDir ? '' : escapeHtml(node.ext)) + '</td>'
|
||||
+ '<td class="col-date">' + fmtDate(node.modTime) + '</td>'
|
||||
+ '</tr>';
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
})();
|
||||
78
browse/template.html
Normal file
78
browse/template.html
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Browse</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="appMain">
|
||||
<div id="emptyState" class="empty-state">
|
||||
<div class="empty-state__inner">
|
||||
<h2>ZDDC Browse</h2>
|
||||
<p>A simple directory listing for ZDDC archives — and any directory.
|
||||
Pick how you want to browse:</p>
|
||||
<ul>
|
||||
<li><b>Online</b> — when this page is served by zddc-server, the
|
||||
listing for the current directory loads automatically.</li>
|
||||
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder
|
||||
on your computer (Chromium-based browsers).</li>
|
||||
</ul>
|
||||
<p>Once loaded: click folders to expand, click headers to sort, type
|
||||
in the filter to narrow by name. Click any file to open it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="browseRoot" class="browse-root hidden">
|
||||
<div class="toolbar">
|
||||
<span class="toolbar__path" id="currentPath"></span>
|
||||
<input type="search" id="filterInput" class="toolbar__filter"
|
||||
placeholder="Filter by name (substring)..." />
|
||||
<span class="toolbar__count" id="entryCount"></span>
|
||||
</div>
|
||||
<table class="browse-table" id="browseTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
|
||||
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
|
||||
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
|
||||
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="browseTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="statusBar" class="status-bar"></div>
|
||||
|
||||
<script>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
11
build
11
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 <app>=<build label> 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"
|
||||
|
|
|
|||
|
|
@ -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 <tool>-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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
0
zddc/internal/apps/embedded/browse.html
Normal file
0
zddc/internal/apps/embedded/browse.html
Normal file
|
|
@ -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 "", ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w,
|
||||
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Index of %s</title></head>`+
|
||||
`<body><h1>Index of %s</h1>`+
|
||||
`<p>This server is designed to be used with the ZDDC Archive Browser. `+
|
||||
`Directory listings are available as JSON (Accept: application/json).</p>`+
|
||||
`</body></html>`,
|
||||
urlPath, urlPath,
|
||||
)
|
||||
// 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 /<dir>/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")
|
||||
w.Header().Set("X-ZDDC-Source", "embedded:browse")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue