3220 lines
115 KiB
HTML
3220 lines
115 KiB
HTML
<!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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ── 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, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
var MIME = {
|
||
'pdf': 'application/pdf',
|
||
'html': 'text/html', 'htm': 'text/html',
|
||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
||
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
|
||
'tif': 'image/tiff', 'tiff': 'image/tiff',
|
||
'zip': 'application/zip',
|
||
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
|
||
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
|
||
'js': 'text/javascript', 'css': 'text/css'
|
||
};
|
||
|
||
// Pull bytes for a file node. Three sources:
|
||
// - server URL (zddc-server-backed file, including downloads
|
||
// of archived files served at real paths)
|
||
// - FS-API handle (local folder)
|
||
// - JSZip entry (file inside an expanded zip; reads from
|
||
// parent's cached JSZip instance)
|
||
async function getArrayBuffer(node) {
|
||
if (node.zipParentId != null) {
|
||
var owner = state.nodes.get(node.zipParentId);
|
||
if (!owner || !owner.zipFile) {
|
||
throw new Error('parent zip not loaded');
|
||
}
|
||
return await owner.zipFile.file(node.zipPath).async('arraybuffer');
|
||
}
|
||
if (state.source === 'server' && node.url) {
|
||
var resp = await fetch(node.url);
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
return await resp.arrayBuffer();
|
||
}
|
||
if (node.handle) {
|
||
var f = await node.handle.getFile();
|
||
return await f.arrayBuffer();
|
||
}
|
||
throw new Error('no source for file');
|
||
}
|
||
|
||
function getMime(ext) {
|
||
return MIME[ext] || 'application/octet-stream';
|
||
}
|
||
|
||
// Build a blob URL for the file's bytes. For server-mode regular
|
||
// files (not in a zip), prefer the live URL — relative links and
|
||
// server-side interception (e.g. .archive resolution) work then.
|
||
async function getBlobUrl(node) {
|
||
if (state.source === 'server' && node.url && node.zipParentId == null) {
|
||
return { url: node.url, fromServer: true };
|
||
}
|
||
var buf = await getArrayBuffer(node);
|
||
var blob = new Blob([buf], { type: getMime(node.ext) });
|
||
return { url: URL.createObjectURL(blob), fromServer: false };
|
||
}
|
||
|
||
function popupShell(node, primaryUrl) {
|
||
var safeName = escapeHtml(node.name);
|
||
var safeHref = escapeHtml(primaryUrl);
|
||
var ext = (node.ext || '').toLowerCase();
|
||
// Inline PDF and HTML previews load in iframes. HTML uses
|
||
// sandbox="allow-same-origin allow-popups
|
||
// allow-popups-to-escape-sandbox" — same posture as archive's
|
||
// preview: links navigate, scripts blocked, popups allowed.
|
||
var contentHtml;
|
||
if (ext === 'pdf') {
|
||
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
|
||
} else if (ext === 'html' || ext === 'htm') {
|
||
contentHtml = '<iframe src="' + safeHref + '" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>';
|
||
} else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
||
contentHtml = '<img class="preview-image" src="' + safeHref + '" alt="' + safeName + '">';
|
||
} else {
|
||
contentHtml = '<div id="previewContent"><div class="loading">Loading preview…</div></div>';
|
||
}
|
||
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
|
||
+ '<title>' + safeName + ' — preview</title><style>'
|
||
+ '*{margin:0;padding:0;box-sizing:border-box;}'
|
||
+ 'body{display:flex;flex-direction:column;height:100vh;'
|
||
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}'
|
||
+ '.toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;'
|
||
+ 'background:#f5f5f5;border-bottom:1px solid #ddd;}'
|
||
+ '.toolbar h1{flex:1;font-size:.95rem;font-weight:500;overflow:hidden;'
|
||
+ 'text-overflow:ellipsis;white-space:nowrap;}'
|
||
+ '.btn{padding:.4rem .8rem;font-size:.85rem;border:1px solid #ccc;'
|
||
+ 'border-radius:4px;background:white;cursor:pointer;}'
|
||
+ '.btn:hover{background:#e8e8e8;}'
|
||
+ 'iframe{flex:1;width:100%;border:none;}'
|
||
+ '#previewContent{flex:1;overflow:auto;display:flex;flex-direction:column;}'
|
||
+ '.loading{display:flex;align-items:center;justify-content:center;height:100%;'
|
||
+ 'color:#666;font-size:1.1rem;}'
|
||
+ 'img.preview-image{max-width:100%;max-height:100%;object-fit:contain;'
|
||
+ 'margin:auto;display:block;}'
|
||
+ 'pre.preview-text{padding:1rem;font-family:Consolas,Monaco,monospace;'
|
||
+ 'font-size:.85rem;white-space:pre-wrap;word-wrap:break-word;}'
|
||
+ '</style></head><body>'
|
||
+ '<div class="toolbar"><h1>' + safeName + '</h1>'
|
||
+ '<button class="btn" onclick="downloadFile()">Download</button></div>'
|
||
+ contentHtml
|
||
+ '<script>'
|
||
+ 'var blobUrl=' + JSON.stringify(primaryUrl) + ';'
|
||
+ 'var fileName=' + JSON.stringify(node.name) + ';'
|
||
+ 'function downloadFile(){var a=document.createElement("a");'
|
||
+ 'a.href=blobUrl;a.download=fileName;document.body.appendChild(a);'
|
||
+ 'a.click();document.body.removeChild(a);}'
|
||
+ '</' + 'script></body></html>';
|
||
}
|
||
|
||
async function renderTextInWindow(node, win) {
|
||
var c = win.document.getElementById('previewContent');
|
||
if (!c) return;
|
||
try {
|
||
var buf = await getArrayBuffer(node);
|
||
var text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||
var MAX = 200000;
|
||
if (text.length > MAX) {
|
||
text = text.substring(0, MAX) + '\n\n... (truncated, '
|
||
+ (text.length - MAX) + ' more chars — Download for full file)';
|
||
}
|
||
var pre = win.document.createElement('pre');
|
||
pre.className = 'preview-text';
|
||
pre.textContent = text;
|
||
c.innerHTML = '';
|
||
c.appendChild(pre);
|
||
} catch (e) {
|
||
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function renderTiffInWindow(node, win) {
|
||
var c = win.document.getElementById('previewContent');
|
||
if (!c || !preview) return;
|
||
try {
|
||
var buf = await getArrayBuffer(node);
|
||
await preview.renderTiff(win.document, c, buf, { fileName: node.name });
|
||
} catch (e) {
|
||
c.innerHTML = '<div class="loading">Error rendering TIFF: '
|
||
+ escapeHtml(e.message || e) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function renderZipInWindow(node, win) {
|
||
var c = win.document.getElementById('previewContent');
|
||
if (!c || !preview) return;
|
||
try {
|
||
var buf = await getArrayBuffer(node);
|
||
await preview.renderZipListing(win.document, c, buf, { fileName: node.name });
|
||
} catch (e) {
|
||
c.innerHTML = '<div class="loading">Error reading ZIP: '
|
||
+ escapeHtml(e.message || e) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function showFilePreview(node) {
|
||
if (node.isDir) return;
|
||
|
||
var ext = (node.ext || '').toLowerCase();
|
||
var info;
|
||
try {
|
||
info = await getBlobUrl(node);
|
||
} catch (e) {
|
||
window.app.modules.events.statusError('Preview failed: ' + e.message);
|
||
return;
|
||
}
|
||
var html = popupShell(node, info.url);
|
||
|
||
var win = state.previewWindow;
|
||
if (win && !win.closed) {
|
||
win.document.open();
|
||
win.document.write(html);
|
||
win.document.close();
|
||
win.focus();
|
||
} else {
|
||
var w = Math.round(screen.width * 0.6);
|
||
var h = Math.round(screen.height * 0.8);
|
||
var left = Math.round((screen.width - w) / 2);
|
||
var top = Math.round((screen.height - h) / 2);
|
||
win = window.open('', 'browseFilePreview',
|
||
'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top
|
||
+ ',resizable=yes,scrollbars=yes');
|
||
if (!win) {
|
||
// Popup blocked — fall back to opening the file directly.
|
||
window.open(info.url, '_blank', 'noopener');
|
||
return;
|
||
}
|
||
win.document.write(html);
|
||
win.document.close();
|
||
win.focus();
|
||
state.previewWindow = win;
|
||
}
|
||
|
||
// Async content rendering for the non-iframe types.
|
||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
||
return; // iframe wired in popupShell
|
||
}
|
||
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
||
return; // <img> wired in popupShell
|
||
}
|
||
if (preview && preview.isTiff(ext)) {
|
||
await renderTiffInWindow(node, win);
|
||
} else if (preview && preview.isZip(ext)) {
|
||
await renderZipInWindow(node, win);
|
||
} else if (preview && preview.isText(ext)) {
|
||
await renderTextInWindow(node, win);
|
||
} else {
|
||
// Unknown type — show a friendly "no preview, click
|
||
// download" placeholder.
|
||
var c = win.document.getElementById('previewContent');
|
||
if (c) {
|
||
c.innerHTML = '<div class="loading">No inline preview for .'
|
||
+ escapeHtml(ext) + ' — click Download.</div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
window.app.modules.preview = { showFilePreview: showFilePreview };
|
||
})();
|
||
|
||
// 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>
|