ZDDC/zddc/internal/apps/embedded/browse.html
ZDDC 6e80e2bf12
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 4s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 1s
release: v0.0.15 lockstep
2026-05-03 20:43:41 -05:00

3220 lines
115 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
<style>
/* ==========================================================================
ZDDC Shared Base — single source of truth for tokens and primitives
Included first by every tool's build.sh via ../shared/base.css
========================================================================== */
/* ── CSS custom properties ────────────────────────────────────────────────── */
:root {
/* Brand / accent (matches zddc.varasys.io website --accent) */
--primary: #2a5a8a;
--primary-hover: #1d4060;
--primary-active: #163352;
--primary-light: #e8f0f7;
/* Semantic colours */
--success: #28a745;
--warning: #d97706;
--danger: #dc3545;
--info: #17a2b8;
/* Backgrounds */
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-hover: #f0f4f8;
--bg-selected: var(--primary-light);
/* Text */
--text: #212529;
--text-muted: #6c757d;
--text-light: #ffffff;
/* Borders */
--border: #dee2e6;
--border-dark: #adb5bd;
/* Shape */
--radius: 4px;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
}
/* Manual dark override — wins over media query */
[data-theme="dark"] {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base document ────────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background-color: var(--bg-secondary);
}
/* ── Typography ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.2;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ── Utility ──────────────────────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
/* ── Button primitive ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.85rem;
font-family: var(--font);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
text-align: center;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: var(--radius);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
background: var(--bg-secondary);
color: var(--text);
}
.btn:disabled,
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:not(:disabled):hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.btn:not(:disabled):active {
box-shadow: none;
}
/* Variants */
.btn-primary {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-primary:not(:disabled):hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--text-light);
}
.btn-primary:not(:disabled):active {
background: var(--primary-active);
border-color: var(--primary-active);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:not(:disabled):hover {
background: var(--bg-secondary);
}
.btn-success {
background: var(--success);
color: var(--text-light);
border-color: var(--success);
}
.btn-danger {
background: var(--danger);
color: var(--text-light);
border-color: var(--danger);
}
/* Sizes */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.6rem 1.4rem;
font-size: 1rem;
}
.btn-link {
background: transparent;
border-color: transparent;
color: var(--primary);
padding-left: 0;
padding-right: 0;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
box-shadow: none;
}
/* ── App header chrome ────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Tool name inside the header */
.app-header__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
white-space: nowrap;
}
/* Brand logo — sits left of the title in every tool's app-header.
Self-contained: the SVG provides its own dark blue rounded background,
so no extra wrapper styling is needed. */
.app-header__logo {
width: 26px;
height: 26px;
flex-shrink: 0;
display: block;
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;
color: var(--text-muted);
opacity: 0.7;
font-weight: 300;
white-space: nowrap;
padding-top: 0.15rem;
}
/* Title + timestamp stacked vertically on the left side of the header */
.header-title-group {
display: flex;
flex-direction: column;
gap: 0;
line-height: 1;
}
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
/* Square, centered — overrides the asymmetric text-button padding/line-height */
#help-btn,
#theme-btn,
#refreshHeaderBtn {
width: 2rem;
height: 2rem;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
#help-btn {
font-size: 1rem;
}
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
/* Used by all four tools. Toggle open/close via shared/help.js. */
.help-panel {
position: fixed;
top: 0;
right: 0;
width: min(420px, 85vw);
height: 100vh;
z-index: 1000;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.help-panel:not([hidden]) {
transform: translateX(0);
}
.help-panel[hidden] {
display: flex;
transform: translateX(100%);
pointer-events: none;
}
.help-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.help-panel__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.help-panel__close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.35rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.help-panel__close:hover {
color: var(--text);
background: var(--bg-secondary);
}
.help-panel__body {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 2rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.help-panel__body h3 {
font-size: 0.95rem;
font-weight: 700;
margin: 1.25rem 0 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.15rem;
}
.help-panel__body h3:first-child {
margin-top: 0;
}
.help-panel__body h4 {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.3rem;
padding-left: 0.5rem;
border-left: 3px solid var(--border-dark);
color: var(--text-muted);
}
.help-panel__body p {
margin: 0 0 0.5rem;
}
.help-panel__body ol,
.help-panel__body ul {
padding-left: 1.5rem;
margin: 0.3rem 0 0.5rem;
}
.help-panel__body li {
margin-bottom: 0.3rem;
}
.help-panel__body dl {
margin: 0.3rem 0;
}
.help-panel__body dt {
font-weight: 600;
color: var(--text);
}
.help-panel__body dd {
margin: 0 0 0.5rem 1rem;
color: var(--text-muted);
}
.help-panel__body code {
font-family: var(--font-mono);
font-size: 0.8em;
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.help-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: var(--radius);
vertical-align: middle;
letter-spacing: 0.02em;
}
.help-badge--draft {
color: #2563eb;
background: #eff6ff;
}
.help-badge--published {
color: #7c3aed;
background: #f5f3ff;
}
/* Shrink main content when help panel is open */
body.help-open .app-header {
margin-right: min(420px, 85vw);
}
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
.column-filter {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.column-filter:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
}
.column-filter::placeholder {
color: var(--text-muted);
}
/* 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); }
/* Toolbar above the listing */
.browse-root {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.browse-table-wrap {
flex: 1;
overflow: auto;
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;
}
/* 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);
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 {
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__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 {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
background: var(--bg);
/* No flex:1 — tables don't reliably distribute extra height across
rows the way flex columns do. With few rows we'd get tall rows
that shrink as more children are loaded. The wrap div handles
scrolling instead. */
}
.browse-table tbody tr {
/* Pin rows to a deterministic height so table layout never
redistributes vertical space across them. */
line-height: 1.4;
}
.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; }
</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">v0.0.15</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>
<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 a folder to expand it, <b>shift-click</b>
to expand its entire subtree (or collapse it again),
click column 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">
<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">
<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>
</div>
</main>
<div id="statusBar" class="status-bar"></div>
<script>
/**
* ZDDC — shared naming convention library
*
* Canonical implementation of all ZDDC filename, folder name, tracking number,
* revision, and status logic. Included in every tool's build via shared/zddc.js.
*
* Exposed as window.zddc (plain global) so it works with every tool's module
* pattern (archive globals, classifier IIFE, transmittal IIFE, mdedit globals).
*
* Public API
* ----------
* zddc.parseFilename(str) → ParsedFile | null
* zddc.parseFolder(str) → ParsedFolder | null
* zddc.parseRevision(str) → ParsedRevision
* zddc.formatFilename(parts) → string
* zddc.formatFolder(parts) → string
* zddc.compareRevisions(a, b) → number (-1 | 0 | 1)
* zddc.isValidStatus(str) → boolean
* zddc.STATUSES → string[]
*
* ParsedFile { trackingNumber, revision, status, title, extension }
* ParsedFolder { date, trackingNumber, status, title }
* ParsedRevision { base, modifier, modifierType, modifierNumber, isDraft, full }
*/
(function (root) {
'use strict';
// ── Valid status codes ───────────────────────────────────────────────────
/**
* Complete list of valid ZDDC document status codes.
* '---' denotes an unknown or not-yet-assigned status.
*/
var STATUSES = [
'---',
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
];
var STATUS_SET = {};
for (var _i = 0; _i < STATUSES.length; _i++) {
STATUS_SET[STATUSES[_i]] = true;
}
function isValidStatus(str) {
return !!STATUS_SET[str];
}
// ── Filename parsing ─────────────────────────────────────────────────────
/**
* Canonical file regex.
* Matches: TRACKING_REVISION (STATUS) - TITLE.EXT
*
* Tracking number: no underscores, no whitespace.
* Revision: no whitespace, no parentheses.
* Status: anything inside parentheses (validated separately).
* Title: everything up to the last dot.
* Extension: after the last dot (lowercased by parseFilename).
*/
var FILE_RE = /^([^_\s]+)_([^\s()_]+)\s*\(([^)]+)\)\s*-\s*(\S.*\S|\S)\.\s*([^\s.]+)$/;
/**
* Parse a ZDDC filename.
*
* @param {string} filename
* @returns {{ trackingNumber: string, revision: string, status: string,
* title: string, extension: string, valid: boolean } | null}
* null only if filename is falsy.
* `valid` is true when all fields matched the ZDDC pattern.
*/
function parseFilename(filename) {
if (!filename) { return null; }
var match = filename.match(FILE_RE);
if (!match) {
var lastDot = filename.lastIndexOf('.');
return {
trackingNumber: '',
revision: '',
status: '',
title: lastDot > 0 ? filename.substring(0, lastDot) : filename,
extension: lastDot > 0 ? filename.substring(lastDot + 1).toLowerCase() : '',
valid: false,
};
}
return {
trackingNumber: match[1].trim(),
revision: match[2].trim(),
status: match[3].trim(),
title: match[4].trim(),
extension: match[5].toLowerCase(),
valid: true,
};
}
// ── Folder name parsing ──────────────────────────────────────────────────
/**
* Transmittal folder regex.
* Matches: YYYY-MM-DD_TRACKING (STATUS) - TITLE
*/
var FOLDER_RE = /^(\d{4}-\d{2}-\d{2})_([^_\s(]+)\s*\(([^)]+)\)\s*-\s*(.+)$/;
/**
* Parse a ZDDC transmittal folder name.
*
* @param {string} foldername
* @returns {{ date: string, trackingNumber: string, status: string,
* title: string, valid: boolean } | null}
* null only if foldername is falsy.
*/
function parseFolder(foldername) {
if (!foldername) { return null; }
var match = foldername.match(FOLDER_RE);
if (!match) {
return {
date: '',
trackingNumber: '',
status: '',
title: foldername,
valid: false,
};
}
return {
date: match[1],
trackingNumber: match[2].trim(),
status: match[3].trim(),
title: match[4].trim(),
valid: true,
};
}
// ── Revision parsing ─────────────────────────────────────────────────────
/**
* Modifier sub-regex: +LETTER DIGITS e.g. +C1, +B2, +N1, +Q1
* The draft prefix (~) may appear inside the modifier: A+~C1
*/
var MODIFIER_RE = /^\+(~?)([A-Za-z])(\d+)$/;
/**
* Parse a ZDDC revision string.
*
* Revision grammar:
* revision = ['~'] base ['+' ['~'] modifier_letter modifier_number]
* base = letter(s) | digit(s) | date(YYYY-MM-DD)
* modifier = letter + digits e.g. C1, B2, N1, Q1
*
* @param {string} revision
* @returns {{
* base: string,
* modifier: string, full modifier string e.g. '+C1', '' if none
* modifierType: string, modifier letter e.g. 'C', '' if none
* modifierNumber: number, modifier number e.g. 1, 0 if none
* modifierIsDraft: boolean,
* isDraft: boolean, true if base revision starts with ~
* full: string, original input
* }}
*/
function parseRevision(revision) {
var raw = (revision || '').toString();
// Split on '+' to separate base from optional modifier
var plusIdx = raw.indexOf('+');
var basePart = plusIdx === -1 ? raw : raw.substring(0, plusIdx);
var modifierPart = plusIdx === -1 ? '' : raw.substring(plusIdx);
// Draft flag on the base part
var isDraft = basePart.startsWith('~');
var base = isDraft ? basePart.substring(1) : basePart;
// Parse modifier
var modifier = '';
var modifierType = '';
var modifierNumber = 0;
var modifierIsDraft = false;
if (modifierPart) {
var mMatch = modifierPart.match(MODIFIER_RE);
if (mMatch) {
modifierIsDraft = mMatch[1] === '~';
modifierType = mMatch[2].toUpperCase();
modifierNumber = parseInt(mMatch[3], 10);
modifier = modifierPart;
} else {
// Unrecognised modifier — preserve as-is
modifier = modifierPart;
}
}
return {
base: base,
modifier: modifier,
modifierType: modifierType,
modifierNumber: modifierNumber,
modifierIsDraft: modifierIsDraft,
isDraft: isDraft,
full: raw,
};
}
// ── Revision comparison ──────────────────────────────────────────────────
/**
* Classify a base revision string into a sort tier:
* 0 = date (YYYY-MM-DD)
* 1 = letter(s) A, B, AA …
* 2 = number(s) 0, 1, 2, 1.5 …
* 3 = other
*/
function _baseTier(base) {
if (/^\d{4}-\d{2}-\d{2}$/.test(base)) { return 0; }
if (/^[A-Za-z]+$/.test(base)) { return 1; }
if (/^\d+(\.\d+)?$/.test(base)) { return 2; }
return 3;
}
/**
* Compare two base revision strings.
* Sort order: dates < letters < numbers < other.
*/
function _compareBase(a, b) {
var ta = _baseTier(a);
var tb = _baseTier(b);
if (ta !== tb) { return ta - tb; }
if (ta === 0) { return a < b ? -1 : a > b ? 1 : 0; } // date lexicographic = chronological
if (ta === 1) { return a.toUpperCase() < b.toUpperCase() ? -1 : a.toUpperCase() > b.toUpperCase() ? 1 : 0; }
if (ta === 2) { return parseFloat(a) - parseFloat(b); }
return a.localeCompare(b);
}
/**
* Compare two ZDDC revision strings for sort ordering.
*
* Canonical order (ascending = older → newer):
* ~A < A < A+B1 < A+C1 < A+~C2 < A+C2 < A+N1 < A+Q1
* < ~B < B < … < 0 < 1 < 2
*
* Rules:
* 1. Compare base revisions first (dates < letters < numbers).
* 2. For equal bases, draft (isDraft=true) comes before final.
* 3. For equal base+draft, no-modifier < has-modifier.
* 4. For equal base+draft+modifier presence:
* a. modifier draft comes before modifier final (modifierIsDraft).
* b. Sort modifier by type letter then by number.
*
* @param {string} a
* @param {string} b
* @returns {number} negative if a < b, 0 if equal, positive if a > b
*/
function compareRevisions(a, b) {
var pa = parseRevision(a);
var pb = parseRevision(b);
// 1. Base revision
var baseCmp = _compareBase(pa.base, pb.base);
if (baseCmp !== 0) { return baseCmp; }
// 2. Draft before final (for same base)
if (pa.isDraft !== pb.isDraft) { return pa.isDraft ? -1 : 1; }
// 3. No modifier before any modifier
var aHasMod = pa.modifier !== '';
var bHasMod = pb.modifier !== '';
if (aHasMod !== bHasMod) { return aHasMod ? 1 : -1; }
if (!aHasMod) { return 0; } // both have no modifier
// 4. Compare modifiers: type → number → draft (draft is a tie-breaker only)
// 4a. Modifier type letter (B < C < N < Q …)
if (pa.modifierType !== pb.modifierType) {
return pa.modifierType < pb.modifierType ? -1 : 1;
}
// 4b. Modifier number (1 < 2 …)
if (pa.modifierNumber !== pb.modifierNumber) {
return pa.modifierNumber - pb.modifierNumber;
}
// 4c. Draft of a modifier comes before the final modifier (same type+number)
if (pa.modifierIsDraft !== pb.modifierIsDraft) {
return pa.modifierIsDraft ? -1 : 1;
}
return 0;
}
// ── Filename / folder formatting ─────────────────────────────────────────
/**
* Build a ZDDC filename from its components.
*
* @param {{ trackingNumber: string, revision: string, status: string,
* title: string, extension: string }} parts
* @returns {string} e.g. "123456-EL-SPC-2623_A (IFR) - Specification.pdf"
*/
function formatFilename(parts) {
var tn = (parts.trackingNumber || '').trim();
var rev = (parts.revision || '').trim();
var st = (parts.status || '').trim();
var ttl = (parts.title || '').trim();
var ext = (parts.extension || '').replace(/^\./, '');
if (!tn || !rev || !st || !ttl) { return ''; }
var name = tn + '_' + rev + ' (' + st + ') - ' + ttl;
return ext ? name + '.' + ext : name;
}
/**
* Build a ZDDC transmittal folder name from its components.
*
* @param {{ date: string, trackingNumber: string, status: string,
* title: string }} parts
* @returns {string} e.g. "2025-10-31_123456-EM-SUB-0001 (IFR) - Title"
*/
function formatFolder(parts) {
var dt = (parts.date || '').trim();
var tn = (parts.trackingNumber || '').trim();
var st = (parts.status || '').trim();
var ttl = (parts.title || '').trim();
if (!dt || !tn || !st || !ttl) { return ''; }
return dt + '_' + tn + ' (' + st + ') - ' + ttl;
}
// ── Filename / extension splitting ───────────────────────────────────────
/**
* Split a filename into its base name and extension (no leading dot).
* Treats leading dot ('.gitignore') as no extension.
*
* @param {string} filename
* @returns {{ name: string, extension: string }}
*/
function splitExtension(filename) {
if (!filename) { return { name: '', extension: '' }; }
var lastDot = filename.lastIndexOf('.');
if (lastDot <= 0) { return { name: filename, extension: '' }; }
return {
name: filename.substring(0, lastDot),
extension: filename.substring(lastDot + 1).toLowerCase(),
};
}
/**
* Join a base name and extension. Tolerant of either form ('pdf' or '.pdf').
* Returns just the name when extension is empty.
*/
function joinExtension(name, extension) {
var ext = (extension || '').replace(/^\./, '');
return ext ? name + '.' + ext : name;
}
// ── Public API ───────────────────────────────────────────────────────────
root.zddc = {
STATUSES: STATUSES,
isValidStatus: isValidStatus,
parseFilename: parseFilename,
parseFolder: parseFolder,
parseRevision: parseRevision,
formatFilename: formatFilename,
formatFolder: formatFolder,
compareRevisions: compareRevisions,
splitExtension: splitExtension,
joinExtension: joinExtension,
};
}(typeof window !== 'undefined' ? window : this));
/**
* ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'.
* Works with all four tools regardless of their module pattern.
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
*
* Theme cycle: auto → light → dark → auto …
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
* 'light' sets data-theme="light" on <html> (overrides dark media query).
* 'dark' sets data-theme="dark" on <html>.
*/
(function () {
'use strict';
var STORAGE_KEY = 'zddc-theme';
var THEMES = ['auto', 'light', 'dark'];
var LABELS = {
auto: '◐',
light: '☀',
dark: '☾'
};
var TITLES = {
auto: 'Theme: auto (follows OS)',
light: 'Theme: light',
dark: 'Theme: dark'
};
function load() {
var stored = localStorage.getItem(STORAGE_KEY);
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
}
function apply(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function save(theme) {
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
}
function updateButton(btn, theme) {
btn.textContent = LABELS[theme];
btn.title = TITLES[theme];
btn.setAttribute('aria-label', TITLES[theme]);
}
function next(theme) {
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
}
function init() {
var current = load();
apply(current);
var btn = document.getElementById('theme-btn');
if (!btn) { return; }
updateButton(btn, current);
btn.addEventListener('click', function () {
current = next(current);
apply(current);
save(current);
updateButton(btn, current);
});
}
/* Apply theme immediately (before DOM ready) to avoid flash */
apply(load());
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
/**
* ZDDC — shared preview helpers
*
* Cross-tool helpers for previewing file types that need a decoder:
* - TIFF (UTIF.js) — multi-page, browser-PDF-viewer-style toolbar
* - ZIP listing (JSZip) — sortable file-list view
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
*
* Public API on window.zddc.preview:
* loadLibrary(url) → Promise<void>
* renderTiff(doc, container, arrayBuffer, opts) → Promise<void>
* renderZipListing(doc, container, arrayBuffer, opts) → Promise<void>
* TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS
* isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext)
*
* Each tool keeps its own dispatcher; this lib only owns the heavy renderers.
*/
(function (root) {
'use strict';
var TIFF_EXTENSIONS = ['tif', 'tiff'];
var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
var TEXT_EXTENSIONS = [
'txt', 'md', 'markdown', 'json', 'xml', 'csv', 'tsv', 'log',
'html', 'htm', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx',
'py', 'rb', 'sh', 'bash', 'zsh', 'bat', 'ps1',
'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml',
'c', 'cc', 'cpp', 'h', 'hpp', 'go', 'rs', 'java', 'kt',
'sql', 'env'
];
var OFFICE_EXTENSIONS = ['docx', 'xlsx', 'xls'];
function lowerExt(ext) { return (ext || '').toLowerCase(); }
function isTiff(ext) { return TIFF_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isImage(ext) { return IMAGE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isText(ext) { return TEXT_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isZip(ext) { return lowerExt(ext) === 'zip'; }
function isOffice(ext) { return OFFICE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
// ── CDN library loader (parent window cache) ─────────────────────────────
var _libCache = new Map();
function loadLibrary(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;
}
// ── Style injection (idempotent per-document) ────────────────────────────
function injectStyles(doc, id, css) {
if (doc.getElementById(id)) return;
var style = doc.createElement('style');
style.id = id;
style.textContent = css;
doc.head.appendChild(style);
}
// ── Helpers ──────────────────────────────────────────────────────────────
function formatSize(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 formatDate(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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── TIFF renderer ────────────────────────────────────────────────────────
var TIFF_CSS =
'.tiff-toolbar{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;' +
'background:#f5f5f5;border-bottom:1px solid #ddd;flex-wrap:wrap;font-size:.85rem;}' +
'.tiff-toolbar .tiff-btn{padding:.25rem .55rem;border:1px solid #ccc;border-radius:3px;' +
'background:#fff;cursor:pointer;font-size:.85rem;line-height:1;min-width:1.8rem;}' +
'.tiff-toolbar .tiff-btn:hover:not(:disabled){background:#e8e8e8;}' +
'.tiff-toolbar .tiff-btn:disabled{opacity:.4;cursor:default;}' +
'.tiff-toolbar .tiff-page-info{display:inline-flex;align-items:center;gap:.3rem;}' +
'.tiff-toolbar .tiff-page-input{width:3.2rem;padding:.2rem .3rem;border:1px solid #ccc;' +
'border-radius:3px;text-align:center;font-size:.85rem;}' +
'.tiff-toolbar .tiff-zoom-select{padding:.2rem .3rem;border:1px solid #ccc;border-radius:3px;' +
'background:#fff;font-size:.85rem;}' +
'.tiff-toolbar .tiff-spacer{flex:1;}' +
'.tiff-viewport{flex:1;overflow:auto;background:#525659;display:flex;align-items:flex-start;' +
'justify-content:center;padding:1rem;}' +
'.tiff-canvas{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.4);display:block;' +
'image-rendering:auto;}' +
'.tiff-error{flex:1;display:flex;align-items:center;justify-content:center;color:#900;' +
'padding:2rem;text-align:center;}';
function renderTiff(doc, container, arrayBuffer, opts) {
opts = opts || {};
injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () {
var ifds;
try {
ifds = window.UTIF.decode(arrayBuffer);
} catch (e) {
container.innerHTML = '<div class="tiff-error">Failed to parse TIFF: '
+ escapeHtml(e.message || e) + '</div>';
return;
}
if (!ifds || !ifds.length) {
container.innerHTML = '<div class="tiff-error">No images found in TIFF.</div>';
return;
}
// Reset container to a flex column
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.height = '100%';
container.style.overflow = 'hidden';
// Toolbar
var toolbar = doc.createElement('div');
toolbar.className = 'tiff-toolbar';
var btnPrev = doc.createElement('button');
btnPrev.className = 'tiff-btn'; btnPrev.type = 'button';
btnPrev.title = 'Previous page'; btnPrev.textContent = '◀';
var pageInfo = doc.createElement('span');
pageInfo.className = 'tiff-page-info';
var pageInput = doc.createElement('input');
pageInput.type = 'number'; pageInput.min = '1'; pageInput.value = '1';
pageInput.className = 'tiff-page-input';
var pageOf = doc.createElement('span');
pageOf.textContent = ' of ' + ifds.length;
pageInfo.appendChild(doc.createTextNode('Page '));
pageInfo.appendChild(pageInput);
pageInfo.appendChild(pageOf);
var btnNext = doc.createElement('button');
btnNext.className = 'tiff-btn'; btnNext.type = 'button';
btnNext.title = 'Next page'; btnNext.textContent = '▶';
var spacer = doc.createElement('span');
spacer.className = 'tiff-spacer';
var btnZoomOut = doc.createElement('button');
btnZoomOut.className = 'tiff-btn'; btnZoomOut.type = 'button';
btnZoomOut.title = 'Zoom out'; btnZoomOut.textContent = '';
var zoomSelect = doc.createElement('select');
zoomSelect.className = 'tiff-zoom-select';
var zoomOptions = [
['fit-width', 'Fit width'],
['fit-page', 'Fit page'],
['0.5', '50%'],
['0.75', '75%'],
['1', '100%'],
['1.25', '125%'],
['1.5', '150%'],
['2', '200%'],
['3', '300%'],
['4', '400%']
];
zoomOptions.forEach(function (z) {
var o = doc.createElement('option');
o.value = z[0]; o.textContent = z[1];
zoomSelect.appendChild(o);
});
zoomSelect.value = 'fit-width';
var btnZoomIn = doc.createElement('button');
btnZoomIn.className = 'tiff-btn'; btnZoomIn.type = 'button';
btnZoomIn.title = 'Zoom in'; btnZoomIn.textContent = '+';
toolbar.appendChild(btnPrev);
toolbar.appendChild(pageInfo);
toolbar.appendChild(btnNext);
toolbar.appendChild(spacer);
toolbar.appendChild(btnZoomOut);
toolbar.appendChild(zoomSelect);
toolbar.appendChild(btnZoomIn);
// Viewport with canvas
var viewport = doc.createElement('div');
viewport.className = 'tiff-viewport';
var canvas = doc.createElement('canvas');
canvas.className = 'tiff-canvas';
viewport.appendChild(canvas);
container.appendChild(toolbar);
container.appendChild(viewport);
// Render state
var currentPage = 0;
var zoom = 1;
var fitMode = 'width'; // 'width' | 'page' | null
var decoded = new Array(ifds.length);
function decodePage(i) {
if (decoded[i]) return decoded[i];
var ifd = ifds[i];
window.UTIF.decodeImage(arrayBuffer, ifd);
var rgba = window.UTIF.toRGBA8(ifd);
decoded[i] = { rgba: rgba, w: ifd.width, h: ifd.height };
return decoded[i];
}
function applyZoom() {
var page = decoded[currentPage];
if (!page) return;
var availW = viewport.clientWidth - 32; // padding
var availH = viewport.clientHeight - 32;
var scale;
if (fitMode === 'width') {
scale = availW / page.w;
} else if (fitMode === 'page') {
scale = Math.min(availW / page.w, availH / page.h);
} else {
scale = zoom;
}
if (!isFinite(scale) || scale <= 0) scale = 1;
canvas.style.width = (page.w * scale) + 'px';
canvas.style.height = (page.h * scale) + 'px';
}
function renderPage() {
var page;
try {
page = decodePage(currentPage);
} catch (e) {
container.innerHTML = '<div class="tiff-error">Failed to decode page '
+ (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '</div>';
return;
}
canvas.width = page.w;
canvas.height = page.h;
var ctx = canvas.getContext('2d');
var imgData = ctx.createImageData(page.w, page.h);
imgData.data.set(page.rgba);
ctx.putImageData(imgData, 0, 0);
applyZoom();
pageInput.value = String(currentPage + 1);
btnPrev.disabled = currentPage <= 0;
btnNext.disabled = currentPage >= ifds.length - 1;
}
function setZoomFromSelect() {
var v = zoomSelect.value;
if (v === 'fit-width') { fitMode = 'width'; }
else if (v === 'fit-page') { fitMode = 'page'; }
else { fitMode = null; zoom = parseFloat(v) || 1; }
applyZoom();
}
function nudgeZoom(factor) {
if (fitMode) {
// capture current effective scale before leaving fit mode
var page = decoded[currentPage];
if (page) {
var availW = viewport.clientWidth - 32;
var availH = viewport.clientHeight - 32;
zoom = fitMode === 'width'
? availW / page.w
: Math.min(availW / page.w, availH / page.h);
} else {
zoom = 1;
}
fitMode = null;
}
zoom = Math.max(0.1, Math.min(8, zoom * factor));
// Match select option if any are close, else show as percent
var matched = false;
for (var i = 0; i < zoomSelect.options.length; i++) {
var ov = zoomSelect.options[i].value;
if (ov !== 'fit-width' && ov !== 'fit-page' && Math.abs(parseFloat(ov) - zoom) < 0.001) {
zoomSelect.value = ov; matched = true; break;
}
}
if (!matched) {
// Nearest standard step
var best = '1', bestDiff = Infinity;
for (var j = 0; j < zoomSelect.options.length; j++) {
var v2 = zoomSelect.options[j].value;
if (v2 === 'fit-width' || v2 === 'fit-page') continue;
var diff = Math.abs(parseFloat(v2) - zoom);
if (diff < bestDiff) { bestDiff = diff; best = v2; }
}
zoom = parseFloat(best);
zoomSelect.value = best;
}
applyZoom();
}
btnPrev.addEventListener('click', function () {
if (currentPage > 0) { currentPage--; renderPage(); }
});
btnNext.addEventListener('click', function () {
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); }
});
pageInput.addEventListener('change', function () {
var n = parseInt(pageInput.value, 10);
if (!isNaN(n) && n >= 1 && n <= ifds.length) {
currentPage = n - 1;
renderPage();
} else {
pageInput.value = String(currentPage + 1);
}
});
zoomSelect.addEventListener('change', setZoomFromSelect);
btnZoomIn.addEventListener('click', function () { nudgeZoom(1.25); });
btnZoomOut.addEventListener('click', function () { nudgeZoom(1 / 1.25); });
// Keyboard nav (only when toolbar/viewport in focus path)
container.tabIndex = 0;
container.addEventListener('keydown', function (e) {
if (e.target === pageInput) return;
if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
if (currentPage > 0) { currentPage--; renderPage(); e.preventDefault(); }
} else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); e.preventDefault(); }
}
});
// Re-fit on viewport resize
if (typeof (doc.defaultView && doc.defaultView.ResizeObserver) === 'function') {
var ro = new doc.defaultView.ResizeObserver(function () { applyZoom(); });
ro.observe(viewport);
} else if (doc.defaultView) {
doc.defaultView.addEventListener('resize', function () { applyZoom(); });
}
renderPage();
});
}
// ── ZIP listing renderer ─────────────────────────────────────────────────
var ZIP_CSS =
'.zip-header{padding:.4rem .8rem;background:#f5f5f5;border-bottom:1px solid #ddd;' +
'font-size:.85rem;color:#444;}' +
'.zip-table-wrap{flex:1;overflow:auto;}' +
'.zip-table{width:100%;border-collapse:collapse;font-size:.85rem;font-family:' +
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
'.zip-table thead th{position:sticky;top:0;background:#f0f0f0;text-align:left;' +
'padding:.4rem .6rem;border-bottom:1px solid #ccc;cursor:pointer;user-select:none;' +
'font-weight:600;}' +
'.zip-table thead th:hover{background:#e6e6e6;}' +
'.zip-table thead th.zip-sort-asc::after{content:" ▲";font-size:.7rem;color:#888;}' +
'.zip-table thead th.zip-sort-desc::after{content:" ▼";font-size:.7rem;color:#888;}' +
'.zip-table tbody td{padding:.3rem .6rem;border-bottom:1px solid #eee;}' +
'.zip-table tbody tr:hover{background:#f6faff;}' +
'.zip-table .zip-folder{color:#888;}' +
'.zip-table .zip-name{color:#222;}' +
'.zip-table .zip-size,.zip-table .zip-date{font-variant-numeric:tabular-nums;' +
'white-space:nowrap;color:#555;}' +
'.zip-table .zip-col-size,.zip-table .zip-col-date{text-align:right;}' +
'.zip-empty{padding:2rem;text-align:center;color:#888;}';
function renderZipListing(doc, container, arrayBuffer, opts) {
opts = opts || {};
injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () {
return window.JSZip.loadAsync(arrayBuffer);
}).then(function (zip) {
var entries = [];
zip.forEach(function (relativePath, zipEntry) {
if (zipEntry.dir) return;
var size = (zipEntry._data && zipEntry._data.uncompressedSize) || 0;
entries.push({
path: relativePath,
name: relativePath.split('/').pop(),
size: size,
modified: zipEntry.date instanceof Date ? zipEntry.date : null
});
});
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.height = '100%';
container.style.overflow = 'hidden';
var totalSize = entries.reduce(function (s, e) { return s + e.size; }, 0);
var header = doc.createElement('div');
header.className = 'zip-header';
header.textContent = entries.length + ' file' + (entries.length === 1 ? '' : 's')
+ (totalSize ? ' · ' + formatSize(totalSize) + ' uncompressed' : '');
container.appendChild(header);
if (!entries.length) {
var empty = doc.createElement('div');
empty.className = 'zip-empty';
empty.textContent = '(empty archive)';
container.appendChild(empty);
return;
}
var wrap = doc.createElement('div');
wrap.className = 'zip-table-wrap';
var table = doc.createElement('table');
table.className = 'zip-table';
var thead = doc.createElement('thead');
var trh = doc.createElement('tr');
var cols = [
{ key: 'path', label: 'Name', cls: 'zip-col-name' },
{ key: 'size', label: 'Size', cls: 'zip-col-size' },
{ key: 'modified', label: 'Modified', cls: 'zip-col-date' }
];
cols.forEach(function (c) {
var th = doc.createElement('th');
th.className = c.cls;
th.dataset.key = c.key;
th.textContent = c.label;
trh.appendChild(th);
});
thead.appendChild(trh);
table.appendChild(thead);
var tbody = doc.createElement('tbody');
table.appendChild(tbody);
wrap.appendChild(table);
container.appendChild(wrap);
var sortKey = 'path';
var sortDir = 1;
function render() {
var sorted = entries.slice().sort(function (a, b) {
var av, bv;
if (sortKey === 'size') { av = a.size; bv = b.size; }
else if (sortKey === 'modified') {
av = a.modified ? a.modified.getTime() : 0;
bv = b.modified ? b.modified.getTime() : 0;
} else {
av = a.path.toLowerCase(); bv = b.path.toLowerCase();
}
if (av < bv) return -1 * sortDir;
if (av > bv) return 1 * sortDir;
return 0;
});
tbody.innerHTML = '';
sorted.forEach(function (e) {
var tr = doc.createElement('tr');
var td1 = doc.createElement('td');
var slash = e.path.lastIndexOf('/');
if (slash >= 0) {
var folder = doc.createElement('span');
folder.className = 'zip-folder';
folder.textContent = e.path.substring(0, slash + 1);
td1.appendChild(folder);
}
var name = doc.createElement('span');
name.className = 'zip-name';
name.textContent = e.name;
td1.appendChild(name);
var td2 = doc.createElement('td');
td2.className = 'zip-size';
td2.textContent = formatSize(e.size);
var td3 = doc.createElement('td');
td3.className = 'zip-date';
td3.textContent = formatDate(e.modified);
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tbody.appendChild(tr);
});
// Update sort arrows
var ths = thead.querySelectorAll('th');
for (var i = 0; i < ths.length; i++) {
ths[i].classList.remove('zip-sort-asc', 'zip-sort-desc');
if (ths[i].dataset.key === sortKey) {
ths[i].classList.add(sortDir > 0 ? 'zip-sort-asc' : 'zip-sort-desc');
}
}
}
thead.querySelectorAll('th').forEach(function (th) {
th.addEventListener('click', function () {
var k = th.dataset.key;
if (sortKey === k) sortDir = -sortDir;
else { sortKey = k; sortDir = 1; }
render();
});
});
render();
}).catch(function (err) {
container.innerHTML = '<div class="zip-empty">Failed to read ZIP: '
+ escapeHtml(err.message || err) + '</div>';
});
}
// ── Public API ───────────────────────────────────────────────────────────
if (!root.zddc) root.zddc = {};
root.zddc.preview = {
TIFF_EXTENSIONS: TIFF_EXTENSIONS,
IMAGE_EXTENSIONS: IMAGE_EXTENSIONS,
TEXT_EXTENSIONS: TEXT_EXTENSIONS,
OFFICE_EXTENSIONS: OFFICE_EXTENSIONS,
isTiff: isTiff,
isImage: isImage,
isText: isText,
isZip: isZip,
isOffice: isOffice,
loadLibrary: loadLibrary,
renderTiff: renderTiff,
renderZipListing: renderZipListing,
formatSize: formatSize,
formatDate: formatDate
};
})(typeof window !== 'undefined' ? window : this);
// 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: '',
// 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, 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,
// Single shared popup window for file preview (across
// multiple file clicks). Same pattern as archive's preview.
previewWindow: null
};
})();
// 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;
}
}
// 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,
ensureJSZip: ensureJSZip,
loadScript: loadScript
};
})();
// 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++;
// 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,
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: [],
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;
}
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.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.isZip) && 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function rowHtml(node) {
var indent = node.depth * 1.2;
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 / 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) + '"'
+ ' target="_blank" rel="noopener">' + escapeHtml(node.name) + '</a>';
}
return ''
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
+ '" 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>'
+ '<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();
renderBreadcrumbs();
renderExtFilter();
}
// 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 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));
}
}
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;
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++) {
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');
}
}
}
// 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) + '/');
} else if (state.source === 'fs') {
raw = await loader.fetchFsChildren(node.handle);
} else {
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 || n.isZip)) return;
if (!n.expanded && !n.loaded) {
await loadChildren(n);
if (!n.loaded) return; // load failed
}
n.expanded = !n.expanded;
render();
}
// 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
// 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 || root.isZip)) return;
var status = window.app.modules.events.statusInfo;
status('Expanding subtree…');
var processed = 0;
var queue = [root];
while (queue.length) {
var batch = queue;
queue = [];
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
for (var i = 0; i < batch.length; i++) {
var n = batch[i];
n.expanded = true;
processed++;
for (var j = 0; j < n.childIds.length; j++) {
var c = state.nodes.get(n.childIds[j]);
// 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);
}
}
render();
status('Expanding subtree… (' + processed + ' folders loaded)');
}
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
}
function collapseSubtree(nodeId) {
var root = state.nodes.get(nodeId);
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 || c.isZip)) walk(c);
}
}
walk(root);
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,
expandSubtree: expandSubtree,
collapseSubtree: collapseSubtree,
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();
},
setExtFilter: function (extArr) {
state.extFilter = new Set((extArr || []).map(function (e) {
return String(e).toLowerCase().replace(/^\./, '');
}));
applyFilter();
updateCount();
},
pathFor: pathFor
};
})();
// 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 };
})();
// events.js — wires up DOM listeners. Idempotent so app.js can call
// init() once on load.
(function () {
'use strict';
var state = window.app.state;
var tree = window.app.modules.tree;
var loader = window.app.modules.loader;
// preview module is loaded later (concat order); look it up at
// call time, not at IIFE-eval time.
function previewMod() { return window.app.modules.preview; }
function status(msg, kind) {
var el = document.getElementById('statusBar');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('status-bar--error', 'status-bar--info');
if (kind === 'error') el.classList.add('status-bar--error');
if (kind === 'info') el.classList.add('status-bar--info');
}
function statusError(msg) { status(msg, 'error'); }
function statusInfo(msg) { status(msg, 'info'); }
function statusClear() { status('', null); }
async function pickLocalDir() {
if (typeof window.showDirectoryPicker !== 'function') {
statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.');
return;
}
var handle;
try {
handle = await window.showDirectoryPicker({ mode: 'read' });
} catch (e) {
// User cancelled — silent
return;
}
state.source = 'fs';
state.rootHandle = handle;
state.currentPath = handle.name + '/';
var raw;
try {
raw = await loader.fetchFsChildren(handle);
} catch (e) {
statusError('Failed to read directory: ' + e.message);
return;
}
tree.setRoot(raw);
showBrowseRoot();
tree.render();
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
}
function showBrowseRoot() {
var empty = document.getElementById('emptyState');
var root = document.getElementById('browseRoot');
if (empty) empty.classList.add('hidden');
if (root) root.classList.remove('hidden');
applySourceUI();
}
// Visual state of the "Select Directory" button + the refresh
// button depends on the source. In server mode the user is
// already viewing a server-backed listing — Select Directory
// becomes a quiet "switch to local" affordance (subtle styling),
// and the refresh button is shown. In FS mode the button is
// primary (it's how you got here) and refresh is hidden (the
// listing was already a fresh enumeration).
function applySourceUI() {
var add = document.getElementById('addDirectoryBtn');
var refresh = document.getElementById('refreshHeaderBtn');
if (add) {
if (state.source === 'server') {
add.classList.remove('btn-primary');
add.classList.add('btn--subtle');
} else {
add.classList.add('btn-primary');
add.classList.remove('btn--subtle');
}
}
if (refresh) {
if (state.source) {
refresh.classList.remove('hidden');
} else {
refresh.classList.add('hidden');
}
}
}
async function refreshListing() {
if (state.source === 'server') {
var raw;
try {
raw = await loader.fetchServerChildren(state.currentPath);
} catch (e) {
statusError('Refresh failed: ' + e.message);
return;
}
tree.setRoot(raw);
tree.render();
statusInfo('Refreshed (' + raw.length + ' item'
+ (raw.length === 1 ? '' : 's') + ')');
} else if (state.source === 'fs' && state.rootHandle) {
var raw2;
try {
raw2 = await loader.fetchFsChildren(state.rootHandle);
} catch (e) {
statusError('Refresh failed: ' + e.message);
return;
}
tree.setRoot(raw2);
tree.render();
statusInfo('Refreshed');
}
}
function init() {
// Header buttons
var btn = document.getElementById('addDirectoryBtn');
if (btn) btn.addEventListener('click', pickLocalDir);
var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing);
// Filter input
var filter = document.getElementById('filterInput');
if (filter) {
filter.addEventListener('input', function () {
tree.setFilter(filter.value);
});
}
// 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++) {
(function (th) {
th.addEventListener('click', function () {
tree.setSort(th.dataset.sort);
});
})(ths[i]);
}
// Tree-row clicks (event delegation on tbody).
// Click semantics on a folder row:
// - plain click → toggle just this folder
// - shift-click → recursive expand/collapse of the whole
// subtree (matches common file-explorer
// convention; e.g. Finder, VSCode tree,
// Windows Explorer)
// - alt-click → ALSO recursive (alt is sometimes the
// expand-all key on Linux DEs; bind both
// so muscle memory works either way)
// File rows: let the <a> tag's natural target=_blank do its
// job — don't intercept.
var tbody = document.getElementById('browseTbody');
if (tbody) {
tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.tree-row');
if (!row) return;
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
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.
});
}
}
// Public API
window.app.modules.events = {
init: init,
statusError: statusError,
statusInfo: statusInfo,
statusClear: statusClear,
showBrowseRoot: showBrowseRoot
};
})();
// 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();
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();
}
})();
</script>
</body>
</html>