2001 lines
62 KiB
HTML
2001 lines
62 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;
|
|
}
|
|
|
|
.toolbar__path {
|
|
font-family: Consolas, Monaco, monospace;
|
|
font-size: 0.9rem;
|
|
color: var(--text-muted);
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.toolbar__filter {
|
|
width: 22rem;
|
|
max-width: 100%;
|
|
padding: 0.3rem 0.6rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.toolbar__count {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Table — folders + files in a tree */
|
|
|
|
.browse-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.9rem;
|
|
background: var(--bg);
|
|
/* 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.13</span>
|
|
</div>
|
|
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
|
</div>
|
|
<div class="header-right">
|
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="appMain">
|
|
<div id="emptyState" class="empty-state">
|
|
<div class="empty-state__inner">
|
|
<h2>ZDDC Browse</h2>
|
|
<p>A simple directory listing for ZDDC archives — and any directory.
|
|
Pick how you want to browse:</p>
|
|
<ul>
|
|
<li><b>Online</b> — when this page is served by zddc-server, the
|
|
listing for the current directory loads automatically.</li>
|
|
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder
|
|
on your computer (Chromium-based browsers).</li>
|
|
</ul>
|
|
<p>Once loaded: click 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">
|
|
<span class="toolbar__path" id="currentPath"></span>
|
|
<input type="search" id="filterInput" class="toolbar__filter"
|
|
placeholder="Filter by name (substring)..." />
|
|
<span class="toolbar__count" id="entryCount"></span>
|
|
</div>
|
|
<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();
|
|
}
|
|
}());
|
|
|
|
// Bootstrap window.app for the browse tool. Mirrors the convention
|
|
// used by every other ZDDC tool — ./build's CSS/JS concat order means
|
|
// this file runs FIRST inside the IIFE-of-IIFEs.
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (!window.app) {
|
|
window.app = { modules: {}, state: {} };
|
|
}
|
|
|
|
window.app.state = {
|
|
// Source: 'server' | 'fs' | null. Determines how the loader
|
|
// resolves entries.
|
|
source: null,
|
|
|
|
// For server-source: the URL path of the directory currently
|
|
// being viewed. Always starts with '/' and ends with '/'.
|
|
// For fs-source: the displayed path string (no semantic
|
|
// meaning — just for the toolbar).
|
|
currentPath: '/',
|
|
|
|
// FileSystemAccessAPI root handle (null in server mode).
|
|
rootHandle: null,
|
|
|
|
// Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1.
|
|
sort: { key: 'name', dir: 1 },
|
|
|
|
// Current filter substring (lowercase).
|
|
filterText: '',
|
|
|
|
// The tree's in-memory representation. Each node:
|
|
// { id, name, isDir, size, modTime, ext, url, depth,
|
|
// parentId, expanded, loaded, childIds }
|
|
// Stored flat in a Map keyed by id; render order derived
|
|
// from a depth-first walk.
|
|
nodes: new Map(),
|
|
rootIds: [],
|
|
nextId: 1
|
|
};
|
|
})();
|
|
|
|
// loader.js — fetches directory entries for either source mode.
|
|
//
|
|
// Server mode: GET <urlPath> with Accept: application/json. zddc-server
|
|
// (and Caddy's built-in browse, which we mirror) returns an array of
|
|
// FileInfo {name, size, url, mod_time, mode, is_dir, is_symlink}.
|
|
//
|
|
// FS-API mode: enumerate a FileSystemDirectoryHandle's children. No
|
|
// network involved; works on local folders the user picked.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
|
|
function splitExt(name) {
|
|
var i = name.lastIndexOf('.');
|
|
if (i <= 0 || i === name.length - 1) return '';
|
|
return name.substring(i + 1).toLowerCase();
|
|
}
|
|
|
|
// Build a raw entry from the server's FileInfo shape.
|
|
function fromServerEntry(e) {
|
|
// Server returns directory names with a trailing "/". Strip
|
|
// it for display; the is_dir flag is the canonical signal.
|
|
var displayName = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
|
|
return {
|
|
name: displayName,
|
|
isDir: e.is_dir,
|
|
size: e.size || 0,
|
|
modTime: e.mod_time ? new Date(e.mod_time) : null,
|
|
ext: e.is_dir ? '' : splitExt(displayName),
|
|
url: e.url || null,
|
|
// FS-API specific (null in server mode):
|
|
handle: null
|
|
};
|
|
}
|
|
|
|
// Build a raw entry from a FileSystemHandle.
|
|
async function fromHandle(handle) {
|
|
var name = handle.name;
|
|
var isDir = handle.kind === 'directory';
|
|
var size = 0;
|
|
var modTime = null;
|
|
if (!isDir) {
|
|
try {
|
|
var f = await handle.getFile();
|
|
size = f.size;
|
|
modTime = new Date(f.lastModified);
|
|
} catch (_e) {
|
|
// permission lost; leave size/modTime defaults
|
|
}
|
|
}
|
|
return {
|
|
name: name,
|
|
isDir: isDir,
|
|
size: size,
|
|
modTime: modTime,
|
|
ext: isDir ? '' : splitExt(name),
|
|
url: null,
|
|
handle: handle
|
|
};
|
|
}
|
|
|
|
// Fetch children of a directory in server mode.
|
|
// path must end with '/' so the request hits the directory route.
|
|
async function fetchServerChildren(path) {
|
|
if (!path.endsWith('/')) path += '/';
|
|
var resp = await fetch(path, {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error('HTTP ' + resp.status + ' fetching ' + path);
|
|
}
|
|
var data = await resp.json();
|
|
if (!Array.isArray(data)) {
|
|
throw new Error('Unexpected response shape from ' + path);
|
|
}
|
|
return data.map(fromServerEntry);
|
|
}
|
|
|
|
// Enumerate a FileSystemDirectoryHandle's immediate children.
|
|
async function fetchFsChildren(dirHandle) {
|
|
var entries = [];
|
|
for await (var [_name, handle] of dirHandle.entries()) {
|
|
entries.push(await fromHandle(handle));
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
// Probe whether THIS page is being served by zddc-server (or any
|
|
// server that responds to JSON listing requests). If so, switch to
|
|
// server mode automatically and load the current directory.
|
|
async function autoDetectServerMode() {
|
|
// Only attempt when running over http(s) and the location's
|
|
// path looks like a directory. Probing on file:// is pointless.
|
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
return false;
|
|
}
|
|
// Strip any /<tool>.html from the path to get the directory.
|
|
var path = location.pathname;
|
|
// If the URL points at the browse.html itself, the directory
|
|
// is the parent. If it's a directory ending in '/', use it.
|
|
var dirPath;
|
|
if (path.endsWith('/')) {
|
|
dirPath = path;
|
|
} else {
|
|
// e.g. '/some/dir/browse.html' → '/some/dir/'
|
|
var slash = path.lastIndexOf('/');
|
|
dirPath = slash >= 0 ? path.substring(0, slash + 1) : '/';
|
|
}
|
|
|
|
try {
|
|
var entries = await fetchServerChildren(dirPath);
|
|
state.source = 'server';
|
|
state.currentPath = dirPath;
|
|
return { entries: entries, path: dirPath };
|
|
} catch (_e) {
|
|
// Not a server-backed page (e.g. opened via file://).
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.loader = {
|
|
fetchServerChildren: fetchServerChildren,
|
|
fetchFsChildren: fetchFsChildren,
|
|
autoDetectServerMode: autoDetectServerMode,
|
|
splitExt: splitExt
|
|
};
|
|
})();
|
|
|
|
// tree.js — in-memory tree model + DOM rendering.
|
|
//
|
|
// Nodes are stored flat in state.nodes (Map by id). The visible
|
|
// render is a depth-first walk starting from state.rootIds, skipping
|
|
// children of unexpanded folders. This decouples model from DOM and
|
|
// keeps re-renders linear in the visible-row count.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
var loader = window.app.modules.loader;
|
|
|
|
// ── Model helpers ────────────────────────────────────────────────────
|
|
|
|
function newNode(raw, parentId, depth) {
|
|
var id = state.nextId++;
|
|
var node = {
|
|
id: id,
|
|
name: raw.name,
|
|
isDir: raw.isDir,
|
|
size: raw.size,
|
|
modTime: raw.modTime,
|
|
ext: raw.ext,
|
|
url: raw.url,
|
|
handle: raw.handle,
|
|
depth: depth,
|
|
parentId: parentId,
|
|
expanded: false,
|
|
loaded: false,
|
|
childIds: []
|
|
};
|
|
state.nodes.set(id, node);
|
|
return node;
|
|
}
|
|
|
|
function clearTree() {
|
|
state.nodes.clear();
|
|
state.rootIds = [];
|
|
state.nextId = 1;
|
|
}
|
|
|
|
// Sort an array of nodes by current sort key. Folders always come
|
|
// first within a level (mimics common file managers).
|
|
function sortNodes(ids) {
|
|
var key = state.sort.key;
|
|
var dir = state.sort.dir;
|
|
ids.sort(function (a, b) {
|
|
var na = state.nodes.get(a);
|
|
var nb = state.nodes.get(b);
|
|
// Folders before files
|
|
if (na.isDir !== nb.isDir) return na.isDir ? -1 : 1;
|
|
var av, bv;
|
|
switch (key) {
|
|
case 'size':
|
|
av = na.size; bv = nb.size; break;
|
|
case 'ext':
|
|
av = na.ext; bv = nb.ext; break;
|
|
case 'date':
|
|
av = na.modTime ? na.modTime.getTime() : 0;
|
|
bv = nb.modTime ? nb.modTime.getTime() : 0;
|
|
break;
|
|
default:
|
|
av = na.name.toLowerCase();
|
|
bv = nb.name.toLowerCase();
|
|
}
|
|
if (av < bv) return -1 * dir;
|
|
if (av > bv) return 1 * dir;
|
|
return na.name.toLowerCase().localeCompare(nb.name.toLowerCase());
|
|
});
|
|
}
|
|
|
|
// Populate state with the root listing.
|
|
function setRoot(rawEntries) {
|
|
clearTree();
|
|
rawEntries.forEach(function (raw) {
|
|
var n = newNode(raw, null, 0);
|
|
state.rootIds.push(n.id);
|
|
});
|
|
sortNodes(state.rootIds);
|
|
}
|
|
|
|
// Populate a folder's children. Caller passes raw entries in any order.
|
|
function setChildren(parentId, rawEntries) {
|
|
var parent = state.nodes.get(parentId);
|
|
if (!parent) return;
|
|
// Drop any existing children first (re-load case).
|
|
parent.childIds.forEach(function (id) { state.nodes.delete(id); });
|
|
parent.childIds = [];
|
|
rawEntries.forEach(function (raw) {
|
|
var n = newNode(raw, parentId, parent.depth + 1);
|
|
parent.childIds.push(n.id);
|
|
});
|
|
sortNodes(parent.childIds);
|
|
parent.loaded = true;
|
|
}
|
|
|
|
// Walk visible nodes in render order.
|
|
function visibleIds() {
|
|
var out = [];
|
|
function walk(ids) {
|
|
for (var i = 0; i < ids.length; i++) {
|
|
out.push(ids[i]);
|
|
var n = state.nodes.get(ids[i]);
|
|
if (n.isDir && n.expanded) walk(n.childIds);
|
|
}
|
|
}
|
|
// Re-sort everything at all levels so a sort change reorders
|
|
// already-loaded children consistently.
|
|
sortNodes(state.rootIds);
|
|
state.nodes.forEach(function (n) {
|
|
if (n.isDir && n.loaded) sortNodes(n.childIds);
|
|
});
|
|
walk(state.rootIds);
|
|
return out;
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────
|
|
|
|
function fmtSize(bytes) {
|
|
if (bytes == null) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function fmtDate(d) {
|
|
if (!d) return '';
|
|
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function rowHtml(node) {
|
|
var indent = node.depth * 1.2;
|
|
var iconChar = node.isDir ? '📁' : '📄';
|
|
var labelClass = node.isDir ? 'is-folder' : 'is-file';
|
|
var chevronClass = 'tree-name__chevron' + (node.isDir ? '' : ' tree-name__chevron--leaf');
|
|
var nameInner;
|
|
if (node.isDir) {
|
|
nameInner = '<span class="tree-name__label is-folder">'
|
|
+ escapeHtml(node.name) + '</span>';
|
|
} else {
|
|
// File: clickable link. In server mode, href is a real URL
|
|
// that opens the file. In FS mode, click handler reads the
|
|
// file via the handle and triggers a download (Phase 2).
|
|
var href = node.url || '#';
|
|
nameInner = '<a class="tree-name__label is-file"'
|
|
+ ' href="' + escapeHtml(href) + '"'
|
|
+ ' target="_blank" rel="noopener">' + escapeHtml(node.name) + '</a>';
|
|
}
|
|
return ''
|
|
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
|
|
+ '" data-id="' + node.id + '" data-isdir="' + node.isDir + '">'
|
|
+ '<td class="col-name">'
|
|
+ '<span class="tree-name">'
|
|
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
|
|
+ '<span class="' + chevronClass + '"></span>'
|
|
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
|
+ nameInner
|
|
+ '</span>'
|
|
+ '</td>'
|
|
+ '<td class="col-size">' + (node.isDir ? '' : fmtSize(node.size)) + '</td>'
|
|
+ '<td class="col-ext">' + (node.isDir ? '' : escapeHtml(node.ext)) + '</td>'
|
|
+ '<td class="col-date">' + fmtDate(node.modTime) + '</td>'
|
|
+ '</tr>';
|
|
}
|
|
|
|
function render() {
|
|
var tbody = document.getElementById('browseTbody');
|
|
if (!tbody) return;
|
|
var ids = visibleIds();
|
|
var html = '';
|
|
for (var i = 0; i < ids.length; i++) {
|
|
html += rowHtml(state.nodes.get(ids[i]));
|
|
}
|
|
tbody.innerHTML = html;
|
|
applyFilter();
|
|
updateCount();
|
|
updateSortHeaders();
|
|
}
|
|
|
|
// Filter is purely DOM-level: hide rows whose name doesn't match.
|
|
// Cheap, immediate, no model rebuild.
|
|
function applyFilter() {
|
|
var f = state.filterText;
|
|
var rows = document.querySelectorAll('#browseTbody tr.tree-row');
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var row = rows[i];
|
|
var n = state.nodes.get(parseInt(row.dataset.id, 10));
|
|
if (!n) continue;
|
|
var match = !f || n.name.toLowerCase().indexOf(f) !== -1;
|
|
row.classList.toggle('tree-row--filtered', !match);
|
|
}
|
|
}
|
|
|
|
function updateCount() {
|
|
var el = document.getElementById('entryCount');
|
|
if (!el) return;
|
|
var rows = document.querySelectorAll('#browseTbody tr.tree-row:not(.tree-row--filtered)');
|
|
var total = document.querySelectorAll('#browseTbody tr.tree-row').length;
|
|
el.textContent = state.filterText
|
|
? rows.length + ' of ' + total + ' shown'
|
|
: total + ' item' + (total === 1 ? '' : 's');
|
|
}
|
|
|
|
function updateSortHeaders() {
|
|
var ths = document.querySelectorAll('#browseTable thead th.sortable');
|
|
for (var i = 0; i < ths.length; i++) {
|
|
ths[i].classList.remove('sort-asc', 'sort-desc');
|
|
if (ths[i].dataset.sort === state.sort.key) {
|
|
ths[i].classList.add(state.sort.dir > 0 ? 'sort-asc' : 'sort-desc');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load a folder's children (lazy; idempotent re-loads).
|
|
async function loadChildren(node) {
|
|
if (node.loaded) return;
|
|
try {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Toggle a folder's expanded state. Loads children on first expand.
|
|
async function toggleFolder(nodeId) {
|
|
var n = state.nodes.get(nodeId);
|
|
if (!n || !n.isDir) return;
|
|
if (!n.expanded && !n.loaded) {
|
|
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
|
|
// folder is loaded and marked expanded. Status bar shows progress
|
|
// because deeply-nested trees can take a while.
|
|
//
|
|
// Parallelism: kept conservative (per-level fan-out) to avoid
|
|
// hammering zddc-server with hundreds of concurrent listing
|
|
// fetches. Browsers also throttle per-origin concurrency, but
|
|
// queuing politely is friendlier than fighting that.
|
|
async function expandSubtree(nodeId) {
|
|
var root = state.nodes.get(nodeId);
|
|
if (!root || !root.isDir) return;
|
|
var status = window.app.modules.events.statusInfo;
|
|
status('Expanding subtree…');
|
|
var processed = 0;
|
|
var queue = [root];
|
|
while (queue.length) {
|
|
var batch = queue;
|
|
queue = [];
|
|
// Load this level's children in parallel (Promise.all).
|
|
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
|
|
for (var i = 0; i < batch.length; i++) {
|
|
var n = batch[i];
|
|
n.expanded = true;
|
|
processed++;
|
|
for (var j = 0; j < n.childIds.length; j++) {
|
|
var c = state.nodes.get(n.childIds[j]);
|
|
if (c && c.isDir) queue.push(c);
|
|
}
|
|
}
|
|
// Re-render after each level so the user sees progress
|
|
// rather than a long pause then a sudden full-tree dump.
|
|
render();
|
|
status('Expanding subtree… (' + processed + ' folders loaded)');
|
|
}
|
|
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
|
|
}
|
|
|
|
// Recursive collapse: mark this node and every descendant as
|
|
// collapsed. Doesn't unload — if the user re-expands later, the
|
|
// children are still in memory and re-render is instant.
|
|
function collapseSubtree(nodeId) {
|
|
var root = state.nodes.get(nodeId);
|
|
if (!root || !root.isDir) return;
|
|
function walk(n) {
|
|
n.expanded = false;
|
|
for (var i = 0; i < n.childIds.length; i++) {
|
|
var c = state.nodes.get(n.childIds[i]);
|
|
if (c && c.isDir) walk(c);
|
|
}
|
|
}
|
|
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();
|
|
},
|
|
pathFor: pathFor
|
|
};
|
|
})();
|
|
|
|
// events.js — wires up DOM listeners. Idempotent so app.js can call
|
|
// init() once on load.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
var tree = window.app.modules.tree;
|
|
var loader = window.app.modules.loader;
|
|
|
|
function status(msg, kind) {
|
|
var el = document.getElementById('statusBar');
|
|
if (!el) return;
|
|
el.textContent = msg || '';
|
|
el.classList.remove('status-bar--error', 'status-bar--info');
|
|
if (kind === 'error') el.classList.add('status-bar--error');
|
|
if (kind === 'info') el.classList.add('status-bar--info');
|
|
}
|
|
|
|
function statusError(msg) { status(msg, 'error'); }
|
|
function statusInfo(msg) { status(msg, 'info'); }
|
|
function statusClear() { status('', null); }
|
|
|
|
async function pickLocalDir() {
|
|
if (typeof window.showDirectoryPicker !== 'function') {
|
|
statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.');
|
|
return;
|
|
}
|
|
var handle;
|
|
try {
|
|
handle = await window.showDirectoryPicker({ mode: 'read' });
|
|
} catch (e) {
|
|
// User cancelled — silent
|
|
return;
|
|
}
|
|
state.source = 'fs';
|
|
state.rootHandle = handle;
|
|
state.currentPath = handle.name + '/';
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchFsChildren(handle);
|
|
} catch (e) {
|
|
statusError('Failed to read directory: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
showBrowseRoot();
|
|
document.getElementById('currentPath').textContent = state.currentPath;
|
|
tree.render();
|
|
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
|
|
}
|
|
|
|
function showBrowseRoot() {
|
|
var empty = document.getElementById('emptyState');
|
|
var root = document.getElementById('browseRoot');
|
|
if (empty) empty.classList.add('hidden');
|
|
if (root) root.classList.remove('hidden');
|
|
}
|
|
|
|
function init() {
|
|
// Header buttons
|
|
var btn = document.getElementById('addDirectoryBtn');
|
|
if (btn) btn.addEventListener('click', pickLocalDir);
|
|
|
|
// Filter input
|
|
var filter = document.getElementById('filterInput');
|
|
if (filter) {
|
|
filter.addEventListener('input', function () {
|
|
tree.setFilter(filter.value);
|
|
});
|
|
}
|
|
|
|
// Sort headers
|
|
var ths = document.querySelectorAll('#browseTable thead th.sortable');
|
|
for (var i = 0; i < ths.length; i++) {
|
|
(function (th) {
|
|
th.addEventListener('click', function () {
|
|
tree.setSort(th.dataset.sort);
|
|
});
|
|
})(ths[i]);
|
|
}
|
|
|
|
// Tree-row clicks (event delegation on tbody).
|
|
// 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 isDir = row.dataset.isdir === 'true';
|
|
if (!isDir) return;
|
|
e.preventDefault();
|
|
var id = parseInt(row.dataset.id, 10);
|
|
if (e.shiftKey || e.altKey) {
|
|
var node = state.nodes.get(id);
|
|
if (node && node.expanded) {
|
|
tree.collapseSubtree(id);
|
|
} else {
|
|
tree.expandSubtree(id);
|
|
}
|
|
} else {
|
|
tree.toggleFolder(id);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
document.getElementById('currentPath').textContent = detected.path;
|
|
tree.render();
|
|
events.statusInfo('Loaded ' + detected.entries.length + ' item'
|
|
+ (detected.entries.length === 1 ? '' : 's')
|
|
+ ' from ' + detected.path);
|
|
}
|
|
// Else: empty state stays visible; user can click Select Directory.
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', bootstrap);
|
|
} else {
|
|
bootstrap();
|
|
}
|
|
})();
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|