3211 lines
109 KiB
HTML
3211 lines
109 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 — Projects</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);
|
||
}
|
||
|
||
/* Subdued / de-emphasized variant.
|
||
Used on the "Add Local Directory" button when a tool is operating
|
||
in server (online) mode — the local-dir affordance is still
|
||
available but visually quieter, since the typical user already
|
||
has the directory loaded from the server. */
|
||
.btn.btn--subtle {
|
||
background: transparent;
|
||
color: var(--text-muted);
|
||
border-color: var(--border);
|
||
box-shadow: none;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.btn.btn--subtle:not(:disabled):hover {
|
||
color: var(--text);
|
||
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;
|
||
}
|
||
|
||
/* Left and right groups inside .app-header. Both flex-row so their
|
||
children (logo, title, action button, theme icon, etc.) lay out
|
||
horizontally rather than stacking. Left side gets a slightly
|
||
larger gap because it carries the title group and an action
|
||
button; right side is just icon buttons. */
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
|
||
#refreshHeaderBtn {
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
/* Toast CSS lives in shared/toast.css; loaded by every tool's build. */
|
||
|
||
/* ── Empty state ──────────────────────────────────────────────────────────── */
|
||
/* The "nothing's loaded yet" screen. By default, centers its inner
|
||
content in whatever space the parent gives it (works inside a flex
|
||
column). Tools that need to overlay an existing layout (archive,
|
||
classifier) add .empty-state--overlay; the screen pins below the
|
||
app header and on top of whatever underlying layout already exists.
|
||
Inner content uses BEM-ish .empty-state__inner with two variants:
|
||
plain (left-aligned, doc-style) and --centered (centered card). */
|
||
|
||
.empty-state {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2rem;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.empty-state--overlay {
|
||
position: absolute;
|
||
top: 50px; /* clear the app-header */
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 10;
|
||
flex: none;
|
||
}
|
||
|
||
.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;
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
.empty-state__inner p {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.empty-state__inner ul,
|
||
.empty-state__inner ol {
|
||
margin: 1rem 0;
|
||
padding-left: 1.5rem;
|
||
}
|
||
|
||
.empty-state__inner li {
|
||
margin: 0.4rem 0;
|
||
}
|
||
|
||
.empty-state__inner .note {
|
||
font-size: 0.85rem;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Centered variant: tighter max-width + centered text. Used by tools
|
||
whose empty-state reads as a "welcome card" (archive, classifier)
|
||
rather than a doc-style page (browse). */
|
||
.empty-state__inner--centered {
|
||
max-width: 500px;
|
||
text-align: center;
|
||
padding: 2rem;
|
||
}
|
||
|
||
/* Bullet list inside an empty-state — keep the bullets left-aligned
|
||
even when the surrounding card is centered. */
|
||
.welcome-list {
|
||
text-align: left;
|
||
margin: 0.5rem auto;
|
||
max-width: 400px;
|
||
}
|
||
|
||
/* ── 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);
|
||
}
|
||
|
||
/* shared/toast.css — single-toast notification styles paired with
|
||
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
|
||
with tool-local .toast classes; the old classifier rules can stay
|
||
alongside until this file is concatenated above them in the build. */
|
||
|
||
.zddc-toast {
|
||
position: fixed;
|
||
bottom: 2rem;
|
||
right: 2rem;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
padding: 0.875rem 1.25rem;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
z-index: 9000;
|
||
max-width: 400px;
|
||
font-size: 0.875rem;
|
||
cursor: pointer;
|
||
animation: zddc-toast-in 0.3s ease-out;
|
||
}
|
||
|
||
.zddc-toast--success { border-left: 4px solid var(--success); }
|
||
.zddc-toast--error { border-left: 4px solid var(--danger); }
|
||
.zddc-toast--info { border-left: 4px solid var(--info); }
|
||
.zddc-toast--warning { border-left: 4px solid var(--warning); }
|
||
|
||
.zddc-toast--fade {
|
||
animation: zddc-toast-out 0.3s ease-out forwards;
|
||
}
|
||
|
||
@keyframes zddc-toast-in {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
|
||
@keyframes zddc-toast-out {
|
||
from { transform: translateX(0); opacity: 1; }
|
||
to { transform: translateX(100%); opacity: 0; }
|
||
}
|
||
|
||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
||
Sits as a sibling immediately under .app-header (mounted by JS).
|
||
Rendered only in online mode when a project segment is in the URL. */
|
||
|
||
.zddc-stage-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.3rem 1rem;
|
||
background: var(--bg);
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 0.8rem;
|
||
line-height: 1.3;
|
||
flex-shrink: 0;
|
||
overflow-x: auto;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.zddc-stage-strip__project {
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
margin-right: 0.15rem;
|
||
}
|
||
|
||
.zddc-stage-strip__divider,
|
||
.zddc-stage-strip__sep {
|
||
color: var(--text-muted);
|
||
user-select: none;
|
||
}
|
||
|
||
.zddc-stage-strip__divider {
|
||
margin-right: 0.35rem;
|
||
}
|
||
|
||
.zddc-stage {
|
||
color: var(--text-muted);
|
||
text-decoration: none;
|
||
padding: 0.1rem 0.25rem;
|
||
border-radius: var(--radius);
|
||
transition: color 0.15s, background 0.15s;
|
||
}
|
||
|
||
.zddc-stage:hover {
|
||
color: var(--text);
|
||
background: var(--bg-secondary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.zddc-stage--active {
|
||
color: var(--primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.zddc-stage--active:hover {
|
||
color: var(--primary);
|
||
}
|
||
|
||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||
inherits the logo's box and adds a subtle hover/focus affordance
|
||
so it reads as clickable without altering the logo's visual weight. */
|
||
|
||
.app-header__logo-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
text-decoration: none;
|
||
border-radius: var(--radius);
|
||
transition: opacity 0.15s, box-shadow 0.15s;
|
||
}
|
||
|
||
.app-header__logo-link:hover .app-header__logo,
|
||
.app-header__logo-link:focus-visible .app-header__logo {
|
||
opacity: 0.82;
|
||
}
|
||
|
||
.app-header__logo-link:focus-visible {
|
||
outline: 2px solid var(--primary);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* Landing page layout */
|
||
body {
|
||
margin: 0;
|
||
font-family: var(--font);
|
||
font-size: 14px;
|
||
background: var(--bg-secondary);
|
||
color: var(--text);
|
||
}
|
||
|
||
.landing-main {
|
||
max-width: 880px;
|
||
margin: 32px auto;
|
||
padding: 0 16px 64px;
|
||
}
|
||
|
||
/* Welcome / hero */
|
||
.landing-hero {
|
||
margin: 0 0 24px;
|
||
padding: 0 4px;
|
||
}
|
||
.landing-hero h1 {
|
||
margin: 0 0 8px;
|
||
font-size: 1.5rem;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
.landing-hero-sub {
|
||
margin: 0;
|
||
color: var(--text-muted);
|
||
font-size: 0.95rem;
|
||
line-height: 1.5;
|
||
max-width: 60ch;
|
||
}
|
||
|
||
/* Access warning banner */
|
||
.access-warning-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 16px;
|
||
background: #fff3cd;
|
||
border: 1px solid #ffc107;
|
||
border-radius: var(--radius);
|
||
color: #664d03;
|
||
font-size: 0.875rem;
|
||
margin-bottom: 16px;
|
||
gap: 12px;
|
||
}
|
||
.access-warning-banner.hidden { display: none; }
|
||
.warning-dismiss-btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: #664d03;
|
||
font-size: 1rem;
|
||
padding: 0 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Cards (groups + projects) */
|
||
.landing-card {
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: visible;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||
margin-bottom: 16px;
|
||
}
|
||
.landing-card:last-child { margin-bottom: 0; }
|
||
|
||
.landing-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.landing-card-title {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
}
|
||
.landing-card-header h2 {
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
.landing-count {
|
||
color: var(--text-muted);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.landing-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* ── Groups card ─────────────────────────────────────────────────────────── */
|
||
.groups-container { min-height: 0; }
|
||
.groups-empty {
|
||
padding: 16px 24px;
|
||
color: var(--text-muted);
|
||
font-size: 0.9rem;
|
||
line-height: 1.5;
|
||
}
|
||
.groups-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.9rem;
|
||
}
|
||
.groups-row {
|
||
cursor: pointer;
|
||
transition: background 0.08s;
|
||
}
|
||
.groups-row:hover { background: var(--bg-hover); }
|
||
.groups-row td {
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
vertical-align: middle;
|
||
}
|
||
.groups-row:last-child td { border-bottom: none; }
|
||
.groups-row-name {
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
}
|
||
.groups-row-count {
|
||
color: var(--text-muted);
|
||
font-size: 0.85rem;
|
||
width: 8em;
|
||
white-space: nowrap;
|
||
}
|
||
.groups-row-actions {
|
||
width: 4.5em;
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
.groups-btn-edit, .groups-btn-delete {
|
||
background: none;
|
||
border: 1px solid transparent;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
color: var(--text-muted);
|
||
font-size: 0.95rem;
|
||
padding: 2px 6px;
|
||
line-height: 1;
|
||
}
|
||
.groups-btn-edit:hover {
|
||
background: var(--bg-hover);
|
||
color: var(--text);
|
||
}
|
||
.groups-btn-delete:hover {
|
||
background: var(--bg-hover);
|
||
color: var(--danger, #c0392b);
|
||
}
|
||
|
||
/* ── Select-mode action bar ──────────────────────────────────────────────── */
|
||
.select-action-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 10px 16px;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-wrap: wrap;
|
||
}
|
||
.select-action-bar.hidden { display: none; }
|
||
.select-action-bar__label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1 1 240px;
|
||
min-width: 0;
|
||
}
|
||
.select-action-bar__label > span {
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
flex-shrink: 0;
|
||
}
|
||
.group-name-input {
|
||
flex: 1 1 0;
|
||
min-width: 120px;
|
||
padding: 5px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
font-size: 0.875rem;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
.group-name-input:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
}
|
||
.select-action-bar__buttons {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* ── Projects card ───────────────────────────────────────────────────────── */
|
||
.project-list-container {
|
||
min-height: 80px;
|
||
}
|
||
|
||
/* Empty / error states */
|
||
.project-list-empty {
|
||
padding: 32px 24px;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
}
|
||
.project-list-empty h3 {
|
||
margin: 0 0 8px;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
.project-list-empty p {
|
||
margin: 4px 0;
|
||
font-size: 0.9rem;
|
||
line-height: 1.5;
|
||
}
|
||
.landing-empty-help {
|
||
color: var(--text-muted);
|
||
font-size: 0.85rem !important;
|
||
margin-top: 12px !important;
|
||
max-width: 50ch;
|
||
margin-left: auto !important;
|
||
margin-right: auto !important;
|
||
}
|
||
|
||
/* Loading state */
|
||
.project-list-loading {
|
||
padding: 32px 16px;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
/* Project table */
|
||
.project-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.9rem;
|
||
}
|
||
.project-table thead {
|
||
background: var(--bg-secondary);
|
||
}
|
||
.project-table-headers th {
|
||
padding: 10px 12px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
border-bottom: 1px solid var(--border);
|
||
user-select: none;
|
||
}
|
||
.project-table-headers th[data-sort] {
|
||
cursor: pointer;
|
||
}
|
||
.project-table-headers th[data-sort]:hover {
|
||
background: var(--bg-hover);
|
||
}
|
||
.project-table-checkbox-col {
|
||
width: 32px;
|
||
padding: 8px 12px;
|
||
text-align: center;
|
||
}
|
||
.project-table-checkbox-col input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: pointer;
|
||
accent-color: var(--primary);
|
||
margin: 0;
|
||
vertical-align: middle;
|
||
}
|
||
.project-table-name-col {
|
||
min-width: 140px;
|
||
}
|
||
.project-table-title-col {
|
||
width: 100%;
|
||
}
|
||
.sort-indicator {
|
||
color: var(--text-muted);
|
||
margin-left: 4px;
|
||
font-size: 0.75rem;
|
||
}
|
||
.sort-indicator.active {
|
||
color: var(--text);
|
||
}
|
||
|
||
.project-table-filters th {
|
||
padding: 6px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg);
|
||
}
|
||
.project-table-filters .column-filter {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
padding: 4px 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
font-size: 0.85rem;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
.project-table-filters .column-filter:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
}
|
||
.project-table-filters .column-filter.filter-active {
|
||
background: var(--bg-secondary);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.project-table-row {
|
||
cursor: pointer;
|
||
transition: background 0.08s;
|
||
}
|
||
.project-table-row:hover {
|
||
background: var(--bg-hover);
|
||
}
|
||
.project-table-row.is-selected {
|
||
background: var(--bg-selected, rgba(0, 105, 217, 0.08));
|
||
}
|
||
.project-table-row.is-selected:hover {
|
||
background: var(--bg-selected-hover, rgba(0, 105, 217, 0.15));
|
||
}
|
||
.project-table-row td {
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
vertical-align: middle;
|
||
}
|
||
.project-table-no-title {
|
||
color: var(--text-muted);
|
||
}
|
||
.project-table-no-match {
|
||
padding: 24px 16px !important;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* ── Project mode ──────────────────────────────────────────────────────── */
|
||
/* Activated when location.pathname is a single project segment (e.g.
|
||
/Project-1). Picker UI is hidden; this block surfaces the four
|
||
lifecycle-stage cards and MDL editing instructions. */
|
||
|
||
.project-title {
|
||
font-size: 1.6rem;
|
||
margin: 0 0 0.25rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.project-title__subtle {
|
||
color: var(--text-muted);
|
||
font-weight: normal;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.lead {
|
||
color: var(--text-muted);
|
||
margin: 0.25rem 0 1.5rem;
|
||
}
|
||
|
||
.stages {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 0.85rem;
|
||
margin: 1rem 0 1.5rem;
|
||
}
|
||
|
||
.stage-card {
|
||
display: block;
|
||
padding: 1rem 1.1rem;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
text-decoration: none;
|
||
color: var(--text);
|
||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.stage-card:hover {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.stage-card:active {
|
||
transform: translateY(1px);
|
||
}
|
||
|
||
.stage-card h3 {
|
||
margin: 0 0 0.3rem;
|
||
font-size: 1rem;
|
||
color: var(--primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stage-card p {
|
||
margin: 0;
|
||
color: var(--text-muted);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.browse-link {
|
||
display: inline-block;
|
||
margin-top: 0.25rem;
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.browse-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
#projectView ol {
|
||
padding-left: 1.5rem;
|
||
}
|
||
|
||
#projectView ol li {
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
|
||
#projectView code {
|
||
font-family: var(--font-mono);
|
||
background: var(--bg-secondary);
|
||
padding: 0.1em 0.35em;
|
||
border-radius: 3px;
|
||
font-size: 0.86em;
|
||
}
|
||
|
||
#projectView h2 {
|
||
font-size: 1.1rem;
|
||
margin: 2.25rem 0 0.5rem;
|
||
padding-bottom: 0.3rem;
|
||
border-bottom: 1px solid var(--border);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.party-list {
|
||
padding-left: 1.5rem;
|
||
margin: 0.4rem 0 1rem;
|
||
}
|
||
|
||
.party-list li {
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.party-list a {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.party-list a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.party-list-none-yet {
|
||
color: var(--text-muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
</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</span>
|
||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · blossom-dome-antler</span></span>
|
||
</div>
|
||
</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="landingMain" class="landing-main">
|
||
<!-- Picker mode (deployment root /). Project picker + groups. -->
|
||
<div id="pickerView">
|
||
<!-- Welcome / hero -->
|
||
<section class="landing-hero">
|
||
<h1>Welcome to the ZDDC Archive</h1>
|
||
<p class="landing-hero-sub">
|
||
Click a group or project below to open the archive. Use
|
||
<strong>+ New group</strong> to bundle a set of projects you open together.
|
||
</p>
|
||
</section>
|
||
|
||
<!-- Access warning banner (shown when URL ?projects= contains inaccessible items) -->
|
||
<div id="accessWarningBanner" class="access-warning-banner hidden" role="alert">
|
||
<span id="accessWarningText"></span>
|
||
<button class="warning-dismiss-btn" onclick="LandingApp.dismissWarning()" aria-label="Dismiss">×</button>
|
||
</div>
|
||
|
||
<!-- Groups card -->
|
||
<div class="landing-card">
|
||
<div class="landing-card-header">
|
||
<div class="landing-card-title">
|
||
<h2>Groups</h2>
|
||
<span id="groupCount" class="landing-count"></span>
|
||
</div>
|
||
<div class="landing-header-actions">
|
||
<button id="newGroupBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.startCreateGroup()">+ New group</button>
|
||
</div>
|
||
</div>
|
||
<div id="groupsContainer" class="groups-container">
|
||
<!-- Populated by JS -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Projects card -->
|
||
<div class="landing-card">
|
||
<!-- Action bar (only visible in select-mode) -->
|
||
<div id="selectActionBar" class="select-action-bar hidden">
|
||
<div class="select-action-bar__label">
|
||
<span id="selectModeTitle"></span>
|
||
<input id="groupNameInput" type="text" class="group-name-input" placeholder="Group name">
|
||
</div>
|
||
<div class="select-action-bar__buttons">
|
||
<button id="cancelSelectBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.cancelSelect()">Cancel</button>
|
||
<button id="openSelectedBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.openSelectedVisible()">Open selected</button>
|
||
<button id="saveGroupBtn" class="btn btn-primary btn-sm" onclick="LandingApp.saveGroup()">Save group</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="landing-card-header">
|
||
<div class="landing-card-title">
|
||
<h2>Projects</h2>
|
||
<span id="projectCount" class="landing-count"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="projectListContainer" class="project-list-container">
|
||
<!-- Populated by JS -->
|
||
<div class="project-list-loading">Loading projects…</div>
|
||
</div>
|
||
</div>
|
||
</div><!-- /pickerView -->
|
||
|
||
<!-- Project mode (/<project>). Stage cards + MDL section. Shown
|
||
by landing.js when location.pathname is a single segment. -->
|
||
<div id="projectView" class="hidden">
|
||
<h1 id="projectTitle" class="project-title">
|
||
<span id="projectName"></span>
|
||
<span class="project-title__subtle">— project workspace</span>
|
||
</h1>
|
||
<p class="lead">Pick a lifecycle stage, or browse all files.</p>
|
||
|
||
<div class="stages">
|
||
<a class="stage-card" id="stageArchive">
|
||
<h3>Archive</h3>
|
||
<p>Permanent record of issued and received transmittals, organized by counterparty.</p>
|
||
</a>
|
||
<a class="stage-card" id="stageWorking">
|
||
<h3>Working</h3>
|
||
<p>Per-user drafting workspace. Your folder is private by default; you can grant access by editing its <code>.zddc</code> file.</p>
|
||
</a>
|
||
<a class="stage-card" id="stageStaging">
|
||
<h3>Staging</h3>
|
||
<p>Outbound transmittals being prepared for issue.</p>
|
||
</a>
|
||
<a class="stage-card" id="stageReviewing">
|
||
<h3>Reviewing</h3>
|
||
<p>Pending review responses — inbound submittals paired with their in-progress drafts.</p>
|
||
</a>
|
||
</div>
|
||
|
||
<p><a id="browseAllLink" class="browse-link">Browse all files →</a></p>
|
||
|
||
<h2>Master Deliverables List (MDL)</h2>
|
||
<p>Each counterparty in the archive has an MDL — an editable
|
||
table of expected deliverables. The default columns mirror
|
||
the ZDDC tracking-number components (<code>originator</code>,
|
||
<code>phase</code>, <code>project</code>, <code>area</code>,
|
||
<code>discipline</code>, <code>type</code>,
|
||
<code>sequence</code>, <code>suffix</code>) plus
|
||
<code>title</code>, <code>plannedRevision</code>,
|
||
<code>plannedDate</code>, <code>status</code>, and
|
||
<code>owner</code>.</p>
|
||
|
||
<p><strong>To edit the MDL for any party:</strong></p>
|
||
<ol>
|
||
<li>Open the project archive: <a id="archiveBrowseLink"></a></li>
|
||
<li>Click into a party's folder (e.g. <code>PartyA</code>)</li>
|
||
<li>Click <code>mdl</code> inside the party folder</li>
|
||
</ol>
|
||
|
||
<div id="partyListSection">
|
||
<!-- Populated by JS when archive/ enumeration succeeds.
|
||
Either a "direct links" block with <ul.party-list> or a
|
||
"no parties yet" fallback. -->
|
||
</div>
|
||
|
||
<p>To customize the columns or schema for a specific party, drop
|
||
a <code>table.yaml</code> and <code>form.yaml</code> into
|
||
<code>archive/<party>/mdl/</code>. Operator-supplied
|
||
files override the embedded defaults entirely.</p>
|
||
</div><!-- /projectView -->
|
||
</main>
|
||
|
||
<!-- Help Panel -->
|
||
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
||
<div class="help-panel__header">
|
||
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC</h2>
|
||
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">×</button>
|
||
</div>
|
||
<div class="help-panel__body">
|
||
<h3>What is this page?</h3>
|
||
<p>This is the ZDDC archive landing page — a project picker. It lists every
|
||
project (top-level directory) you have access to on this server, plus any
|
||
<strong>groups</strong> you've defined for opening multiple projects at once.</p>
|
||
|
||
<h3>Projects</h3>
|
||
<p>Click a project to open it. The project's archive view (list of folders +
|
||
files, with all the standard ZDDC tools available inside) loads in the same
|
||
tab. Use back/forward to navigate between projects and the picker.</p>
|
||
|
||
<h3>Groups</h3>
|
||
<p>A group bundles a set of projects you commonly open together. Click
|
||
<strong>+ New group</strong>, give it a name, click projects to include
|
||
them, then save. Opening a group opens all its projects in one go.</p>
|
||
<dl>
|
||
<dt>Save group</dt>
|
||
<dd>Persist the selection as a named group on this server (visible to
|
||
other users with access to the same projects).</dd>
|
||
<dt>Open selected</dt>
|
||
<dd>Open the currently-checked projects without saving as a group.</dd>
|
||
<dt>Cancel</dt>
|
||
<dd>Exit select mode without saving.</dd>
|
||
</dl>
|
||
|
||
<h3>Access</h3>
|
||
<p>Projects and groups are filtered by your account's permissions.
|
||
If a URL references a project you don't have access to, a warning banner
|
||
appears and the inaccessible items are skipped silently.</p>
|
||
|
||
<h3>Header buttons</h3>
|
||
<dl>
|
||
<dt>◐ Theme</dt>
|
||
<dd>Cycle auto / light / dark.</dd>
|
||
<dt>? Help</dt>
|
||
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
|
||
</dl>
|
||
</div>
|
||
</aside>
|
||
|
||
<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));
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Escape a string for use in a RegExp (literal match)
|
||
function escapeRegex(str) {
|
||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
// Build regex pattern at parse time based on anchors
|
||
function compilePattern(raw, anchorStart, anchorEnd) {
|
||
var src = (anchorStart ? '^' : '') + raw + (anchorEnd ? '$' : '');
|
||
try {
|
||
return new RegExp(src, 'i');
|
||
} catch (e) {
|
||
// Invalid regex — escape and retry (always succeeds)
|
||
var safe = (anchorStart ? '^' : '') + escapeRegex(raw) + (anchorEnd ? '$' : '');
|
||
return new RegExp(safe, 'i');
|
||
}
|
||
}
|
||
|
||
// Parse a single token string into a node
|
||
function parseToken(token) {
|
||
var s = token;
|
||
var negate = false;
|
||
var anchorStart = false;
|
||
var anchorEnd = false;
|
||
|
||
if (s.charAt(0) === '!') {
|
||
negate = true;
|
||
s = s.slice(1);
|
||
}
|
||
if (s.charAt(0) === '^') {
|
||
anchorStart = true;
|
||
s = s.slice(1);
|
||
}
|
||
if (s.length > 0 && s.charAt(s.length - 1) === '$') {
|
||
anchorEnd = true;
|
||
s = s.slice(0, -1);
|
||
}
|
||
|
||
if (s === '') return null;
|
||
|
||
// bare * (possibly after stripping !) → wildcard-all or wildcard-none
|
||
if (s === '*' && !anchorStart && !anchorEnd) {
|
||
return negate ? null : { type: 'wildcard-all' };
|
||
}
|
||
|
||
var re = compilePattern(s, anchorStart, anchorEnd);
|
||
return { type: negate ? 'no-match' : 'match', re: re };
|
||
}
|
||
|
||
// Parse expression string into AST array
|
||
function parse(expression) {
|
||
if (!expression || typeof expression !== 'string') return [];
|
||
var trimmed = expression.trim();
|
||
if (trimmed === '') return [];
|
||
if (trimmed === '*') return [{ type: 'wildcard-all' }];
|
||
|
||
var ast = [];
|
||
var i = 0;
|
||
var len = trimmed.length;
|
||
|
||
while (i < len) {
|
||
var ch = trimmed.charAt(i);
|
||
|
||
if (ch === '(') {
|
||
var depth = 1;
|
||
var j = i + 1;
|
||
while (j < len && depth > 0) {
|
||
if (trimmed.charAt(j) === '(') depth++;
|
||
else if (trimmed.charAt(j) === ')') depth--;
|
||
j++;
|
||
}
|
||
var innerAst = parse(trimmed.slice(i + 1, j - 1));
|
||
if (innerAst.length === 1) {
|
||
ast.push(innerAst[0]);
|
||
} else if (innerAst.length > 1) {
|
||
for (var k = 0; k < innerAst.length; k++) ast.push(innerAst[k]);
|
||
}
|
||
i = j;
|
||
} else if (ch === '|') {
|
||
ast.push({ type: 'pipe' });
|
||
i++;
|
||
} else if (ch === ' ') {
|
||
i++;
|
||
} else {
|
||
var j = i;
|
||
while (j < len) {
|
||
var c = trimmed.charAt(j);
|
||
if (c === ' ' || c === '(' || c === '|' || c === ')') break;
|
||
j++;
|
||
}
|
||
var token = trimmed.slice(i, j);
|
||
if (token.length > 0) {
|
||
var node = parseToken(token);
|
||
if (node !== null) ast.push(node);
|
||
}
|
||
i = j;
|
||
}
|
||
}
|
||
|
||
// Group pipes into OR nodes
|
||
var hasPipe = false;
|
||
var branches = [[]];
|
||
for (var l = 0; l < ast.length; l++) {
|
||
if (ast[l].type === 'pipe') {
|
||
hasPipe = true;
|
||
branches.push([]);
|
||
} else {
|
||
branches[branches.length - 1].push(ast[l]);
|
||
}
|
||
}
|
||
branches = branches.filter(function(b) { return b.length > 0; });
|
||
|
||
if (!hasPipe) {
|
||
return ast.filter(function(n) { return n.type !== 'pipe'; });
|
||
}
|
||
|
||
var orNodes = branches.map(function(branch) {
|
||
if (branch.length === 1) return branch[0];
|
||
return { type: 'and', nodes: branch };
|
||
});
|
||
return [{ type: 'or', nodes: orNodes }];
|
||
}
|
||
|
||
// Check if a single node matches the value
|
||
function nodeMatches(node, value) {
|
||
switch (node.type) {
|
||
case 'wildcard-all': return true;
|
||
case 'match': return node.re.test(value);
|
||
case 'no-match': return !node.re.test(value);
|
||
case 'or':
|
||
for (var i = 0; i < node.nodes.length; i++) {
|
||
if (nodeMatches(node.nodes[i], value)) return true;
|
||
}
|
||
return false;
|
||
case 'and':
|
||
for (var i = 0; i < node.nodes.length; i++) {
|
||
if (!nodeMatches(node.nodes[i], value)) return false;
|
||
}
|
||
return true;
|
||
default: return false;
|
||
}
|
||
}
|
||
|
||
// Evaluate AST against value
|
||
function matches(value, ast) {
|
||
if (!ast || ast.length === 0) return true;
|
||
var v = String(value); // no forced lowercase — regex has 'i' flag
|
||
for (var i = 0; i < ast.length; i++) {
|
||
if (!nodeMatches(ast[i], v)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (!window.zddc) {
|
||
throw new Error('shared/zddc-filter.js: window.zddc must be loaded first');
|
||
}
|
||
window.zddc.filter = { parse: parse, matches: matches };
|
||
})();
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}());
|
||
|
||
// shared/toast.js — non-blocking notification helper available to every
|
||
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
|
||
// local showToast (classifier/js/excel.js); promoted here so tools that
|
||
// today use alert() or silent console.error can switch to a uniform
|
||
// non-blocking surface.
|
||
//
|
||
// Usage:
|
||
// window.zddc.toast('Saved.', 'success');
|
||
// window.zddc.toast('Could not load: ' + err.message, 'error');
|
||
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
||
//
|
||
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
|
||
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
|
||
// see ARCHITECTURE.md for the convention.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
// Don't overwrite if a tool defined its own first.
|
||
if (typeof window.zddc.toast === 'function') return;
|
||
|
||
var DEFAULT_DURATION_MS = 5000;
|
||
var FADE_MS = 300;
|
||
|
||
function toast(message, level, opts) {
|
||
opts = opts || {};
|
||
var lvl = (level === 'success' || level === 'error' ||
|
||
level === 'warning') ? level : 'info';
|
||
|
||
// Single-toast policy: dismiss any existing toast immediately
|
||
// so the new one is always the most recent. Matches the
|
||
// classifier's prior behavior and avoids stack-of-toasts UX.
|
||
var existing = document.querySelector('.zddc-toast');
|
||
if (existing) existing.remove();
|
||
|
||
var el = document.createElement('div');
|
||
el.className = 'zddc-toast zddc-toast--' + lvl;
|
||
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
||
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
||
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
||
el.textContent = message == null ? '' : String(message);
|
||
document.body.appendChild(el);
|
||
|
||
var dur = typeof opts.durationMs === 'number' ?
|
||
opts.durationMs : DEFAULT_DURATION_MS;
|
||
var timer = setTimeout(function () {
|
||
el.classList.add('zddc-toast--fade');
|
||
setTimeout(function () {
|
||
if (el.parentNode) el.parentNode.removeChild(el);
|
||
}, FADE_MS);
|
||
}, dur);
|
||
|
||
// Click-to-dismiss. Useful for sticky errors the user wants gone.
|
||
el.addEventListener('click', function () {
|
||
clearTimeout(timer);
|
||
if (el.parentNode) el.parentNode.removeChild(el);
|
||
});
|
||
|
||
return el;
|
||
}
|
||
|
||
window.zddc.toast = toast;
|
||
})();
|
||
|
||
// shared/nav.js — lateral navigation strip across the four canonical
|
||
// project stages (archive · working · staging · reviewing). Renders
|
||
// only when:
|
||
// 1. location.protocol is http: or https: (online — file:// has no
|
||
// project structure to navigate within), AND
|
||
// 2. a project segment can be detected from location.pathname (the
|
||
// first path segment, when it isn't a tool HTML file).
|
||
//
|
||
// The strip is inserted as a sibling of <header class="app-header">
|
||
// on DOMContentLoaded — no template changes required. Each tool just
|
||
// needs ../shared/nav.{js,css} in its build.sh.
|
||
//
|
||
// Stage URLs follow the canonical workflow folders documented at
|
||
// zddc.varasys.io/reference.html#transmittal-workflow:
|
||
// archive → <project>/archive.html (archive tool, project-root mode)
|
||
// working → <project>/working/ (directory listing → mdedit auto-serves)
|
||
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
|
||
// reviewing → <project>/reviewing/ (directory listing)
|
||
//
|
||
// If a deployment doesn't have one of these folders the link will 404 —
|
||
// the strip is convention-driven, not probed. Operators on non-standard
|
||
// layouts can override by setting window.zddc.nav.disabled = true before
|
||
// DOMContentLoaded.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
if (window.zddc.nav) return; // already loaded
|
||
|
||
var STAGES = [
|
||
{ key: 'archive', label: 'Archive', target: 'archive.html' },
|
||
{ key: 'working', label: 'Working', target: 'working/' },
|
||
{ key: 'staging', label: 'Staging', target: 'staging/' },
|
||
{ key: 'reviewing', label: 'Reviewing', target: 'reviewing/' },
|
||
];
|
||
|
||
function projectSegment(pathname) {
|
||
var parts = pathname.split('/').filter(Boolean);
|
||
if (parts.length === 0) return null;
|
||
var first = parts[0];
|
||
// At deployment root (e.g. /archive.html?projects=A,B or
|
||
// /index.html) the first segment is a tool HTML — no single
|
||
// project to scope the strip to.
|
||
if (first.indexOf('.') !== -1) return null;
|
||
return first;
|
||
}
|
||
|
||
function currentStage(pathname) {
|
||
var parts = pathname.split('/').filter(Boolean);
|
||
if (parts.length < 2) return null;
|
||
var second = parts[1];
|
||
// <project>/working/... | staging/... | reviewing/... | archive/...
|
||
for (var i = 0; i < STAGES.length; i++) {
|
||
if (second === STAGES[i].key) return STAGES[i].key;
|
||
}
|
||
// <project>/archive.html → still the archive stage
|
||
if (second === 'archive.html') return 'archive';
|
||
return null;
|
||
}
|
||
|
||
function shouldRender() {
|
||
if (typeof location === 'undefined') return false;
|
||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
||
return projectSegment(location.pathname) !== null;
|
||
}
|
||
|
||
function buildStrip(project, active) {
|
||
var nav = document.createElement('nav');
|
||
nav.className = 'zddc-stage-strip';
|
||
nav.setAttribute('aria-label', 'Project stage');
|
||
|
||
var label = document.createElement('span');
|
||
label.className = 'zddc-stage-strip__project';
|
||
label.textContent = project;
|
||
nav.appendChild(label);
|
||
|
||
var sep0 = document.createElement('span');
|
||
sep0.className = 'zddc-stage-strip__divider';
|
||
sep0.setAttribute('aria-hidden', 'true');
|
||
sep0.textContent = '/';
|
||
nav.appendChild(sep0);
|
||
|
||
for (var i = 0; i < STAGES.length; i++) {
|
||
var s = STAGES[i];
|
||
var a = document.createElement('a');
|
||
a.className = 'zddc-stage';
|
||
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
|
||
a.textContent = s.label;
|
||
if (s.key === active) {
|
||
a.classList.add('zddc-stage--active');
|
||
a.setAttribute('aria-current', 'page');
|
||
}
|
||
nav.appendChild(a);
|
||
|
||
if (i < STAGES.length - 1) {
|
||
var sep = document.createElement('span');
|
||
sep.className = 'zddc-stage-strip__sep';
|
||
sep.setAttribute('aria-hidden', 'true');
|
||
sep.textContent = '·';
|
||
nav.appendChild(sep);
|
||
}
|
||
}
|
||
|
||
return nav;
|
||
}
|
||
|
||
function mount() {
|
||
if (!shouldRender()) return;
|
||
var header = document.querySelector('.app-header');
|
||
if (!header) return;
|
||
// Don't double-mount if a tool's main.js calls us a second time.
|
||
if (header.previousElementSibling &&
|
||
header.previousElementSibling.classList &&
|
||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
||
return;
|
||
}
|
||
var project = projectSegment(location.pathname);
|
||
var active = currentStage(location.pathname);
|
||
var strip = buildStrip(project, active);
|
||
// Mount ABOVE the header — the strip is project-level chrome
|
||
// (where in the project), the header is tool-level chrome (which
|
||
// tool, theme, help). Reading order matches outer-to-inner scope.
|
||
header.parentNode.insertBefore(strip, header);
|
||
}
|
||
|
||
// Expose for tests + opt-out.
|
||
window.zddc.nav = {
|
||
mount: mount,
|
||
// Internals visible for unit tests; do not call from tools.
|
||
_projectSegment: projectSegment,
|
||
_currentStage: currentStage,
|
||
_stages: STAGES,
|
||
// Set to true before DOMContentLoaded to suppress mounting on
|
||
// deployments where the canonical folder layout doesn't apply.
|
||
disabled: false,
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
||
} else {
|
||
mount();
|
||
}
|
||
})();
|
||
|
||
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
||
// every tool's header into a clickable link. The destination is the
|
||
// nearest "home" the user can sensibly back out to:
|
||
//
|
||
// file:// → no wrap (no server home)
|
||
// http(s)://host/ → wrap, href = /
|
||
// http(s)://host/<tool>.html (deployment root)→ wrap, href = /
|
||
// http(s)://host/<project>/... → wrap, href = /<project>
|
||
//
|
||
// When inside a project, the logo takes the user to the project
|
||
// landing (synthetic page with the four lifecycle-stage cards + MDL
|
||
// instructions). When at the deployment root, the logo points at /
|
||
// (the project picker). Offline, the logo stays decorative — there's
|
||
// no real "home" to go to.
|
||
//
|
||
// Mounts as a sibling-replacement on DOMContentLoaded: wraps the
|
||
// existing logo SVG in an <a>, preserving classes and attributes.
|
||
// Idempotent: re-mounting on an already-wrapped logo is a no-op.
|
||
//
|
||
// Tools that want to override (e.g. a deployment that pins logo to
|
||
// an external URL) can set window.zddc.logo.disabled = true before
|
||
// DOMContentLoaded and inject their own anchor.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
if (window.zddc.logo) return;
|
||
|
||
function projectSegment(pathname) {
|
||
var parts = pathname.split('/').filter(Boolean);
|
||
if (parts.length === 0) return null;
|
||
var first = parts[0];
|
||
// Tool HTMLs at the deployment root (index.html, archive.html
|
||
// with ?projects=...) don't carry a project segment.
|
||
if (first.indexOf('.') !== -1) return null;
|
||
return first;
|
||
}
|
||
|
||
function targetHref() {
|
||
if (typeof location === 'undefined') return null;
|
||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
||
return null;
|
||
}
|
||
if (window.zddc.logo && window.zddc.logo.disabled) return null;
|
||
var seg = projectSegment(location.pathname);
|
||
return seg ? '/' + encodeURIComponent(seg) : '/';
|
||
}
|
||
|
||
function mount() {
|
||
var logo = document.querySelector('.app-header__logo');
|
||
if (!logo) return;
|
||
// Already wrapped (template-supplied anchor, or a previous mount).
|
||
if (logo.parentElement && logo.parentElement.tagName === 'A' &&
|
||
logo.parentElement.classList.contains('app-header__logo-link')) {
|
||
return;
|
||
}
|
||
var href = targetHref();
|
||
if (!href) return;
|
||
var a = document.createElement('a');
|
||
a.href = href;
|
||
a.className = 'app-header__logo-link';
|
||
var label = href === '/' ? 'ZDDC home' : 'Project home';
|
||
a.title = label;
|
||
a.setAttribute('aria-label', label);
|
||
logo.parentNode.insertBefore(a, logo);
|
||
a.appendChild(logo);
|
||
}
|
||
|
||
window.zddc.logo = {
|
||
mount: mount,
|
||
// Test seam.
|
||
_projectSegment: projectSegment,
|
||
_targetHref: targetHref,
|
||
disabled: false,
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
||
} else {
|
||
mount();
|
||
}
|
||
})();
|
||
|
||
/**
|
||
* ZDDC shared help panel — open/close logic.
|
||
* Works with all four tools regardless of their module pattern.
|
||
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
function init() {
|
||
var helpBtn = document.getElementById('help-btn');
|
||
var panel = document.getElementById('help-panel');
|
||
var closeBtn = document.getElementById('help-panel-close');
|
||
|
||
if (!helpBtn || !panel) { return; }
|
||
|
||
function isOpen() { return !panel.hidden; }
|
||
|
||
function openPanel() {
|
||
panel.hidden = false;
|
||
document.body.classList.add('help-open');
|
||
}
|
||
|
||
function closePanel() {
|
||
panel.hidden = true;
|
||
document.body.classList.remove('help-open');
|
||
}
|
||
|
||
helpBtn.addEventListener('click', function () {
|
||
if (isOpen()) { closePanel(); } else { openPanel(); }
|
||
});
|
||
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', closePanel);
|
||
}
|
||
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && isOpen()) { closePanel(); }
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
}());
|
||
|
||
(function() {
|
||
'use strict';
|
||
// ZDDC landing page — project picker.
|
||
//
|
||
// Two stacked sections:
|
||
// 1. Groups (saved bundles of projects) — click row to open, edit, or delete
|
||
// 2. Projects (live list from the server) — click row to open one project directly
|
||
//
|
||
// "Select-mode" (entered via "+ New group" or a group's edit ✏ button) shows
|
||
// checkboxes on each project row, a name input, and an action bar with
|
||
// Save / Open visible-checked / Cancel. In default (rest) mode there are no
|
||
// checkboxes; clicking anything just opens the archive.
|
||
//
|
||
// Storage: groups persist in localStorage under `zddc_landing_groups` as
|
||
// an array of { name: string, projects: string[] }. Old `zddc_landing_presets`
|
||
// entries are migrated once on init (project list only — filter/sort state
|
||
// from the old preset model is dropped).
|
||
|
||
// ── State ────────────────────────────────────────────────────────────────
|
||
|
||
var allProjects = []; // [{name, title, url}] from server
|
||
var groups = []; // [{name, projects: [name,...]}] from localStorage
|
||
var selected = new Set(); // checked project names (only used in select-mode)
|
||
var columnFilters = { pn: '', pt: '' };
|
||
var columnFilterASTs = { pn: null, pt: null };
|
||
var sortField = 'name'; // 'name' | 'title'
|
||
var sortDirection = 'asc';
|
||
var loadError = null; // user-facing error string
|
||
var loadErrorKind = null; // 'static' | 'auth' | 'non-json' | 'network'
|
||
|
||
// selectMode === null → default mode (click to open)
|
||
// selectMode === { kind: 'create' }
|
||
// selectMode === { kind: 'edit', originalName: '...' }
|
||
var selectMode = null;
|
||
|
||
var GROUPS_KEY = 'zddc_landing_groups';
|
||
var LEGACY_PRESETS_KEY = 'zddc_landing_presets';
|
||
var DEFAULT_SORT_FIELD = 'name';
|
||
var DEFAULT_SORT_DIRECTION = 'asc';
|
||
|
||
// ── URL state ────────────────────────────────────────────────────────────
|
||
// Only filters and sort persist in the URL. Selection (`?projects=`) used
|
||
// to live here for save-as-preset workflows; with click-to-open + named
|
||
// groups it adds noise and isn't shareable in any useful way (groups are
|
||
// localStorage-only per user).
|
||
|
||
function urlSerialize() {
|
||
var p = new URLSearchParams();
|
||
if (columnFilters.pn) p.set('pn', columnFilters.pn);
|
||
if (columnFilters.pt) p.set('pt', columnFilters.pt);
|
||
if (sortField !== DEFAULT_SORT_FIELD) p.set('sort', sortField);
|
||
if (sortDirection !== DEFAULT_SORT_DIRECTION) p.set('dir', sortDirection);
|
||
// Preserve channel selector from existing URL if present.
|
||
var v = new URLSearchParams(location.search).get('v');
|
||
if (v) p.set('v', v);
|
||
var qs = p.toString();
|
||
return qs ? '?' + qs : '';
|
||
}
|
||
|
||
function urlPush() {
|
||
var qs = urlSerialize();
|
||
if (qs === location.search) return;
|
||
try {
|
||
history.replaceState(null, '', location.pathname + qs);
|
||
} catch (e) { /* file:// protocol restrictions */ }
|
||
}
|
||
|
||
function urlRestore() {
|
||
var p = new URLSearchParams(location.search);
|
||
if (p.has('pn')) {
|
||
columnFilters.pn = p.get('pn');
|
||
columnFilterASTs.pn = parseFilterAST(columnFilters.pn);
|
||
}
|
||
if (p.has('pt')) {
|
||
columnFilters.pt = p.get('pt');
|
||
columnFilterASTs.pt = parseFilterAST(columnFilters.pt);
|
||
}
|
||
if (p.has('sort')) {
|
||
var s = p.get('sort');
|
||
if (s === 'name' || s === 'title') sortField = s;
|
||
}
|
||
if (p.has('dir')) {
|
||
var d = p.get('dir');
|
||
if (d === 'asc' || d === 'desc') sortDirection = d;
|
||
}
|
||
}
|
||
|
||
function parseFilterAST(text) {
|
||
if (!text) return null;
|
||
try { return zddc.filter.parse(text); } catch (e) { return null; }
|
||
}
|
||
|
||
// ── Server fetch ─────────────────────────────────────────────────────────
|
||
|
||
async function fetchProjects() {
|
||
var base = location.origin + location.pathname.replace(/\/[^\/]*$/, '/');
|
||
try {
|
||
var resp = await fetch(base, {
|
||
headers: { 'Accept': 'application/json' },
|
||
cache: 'no-cache',
|
||
credentials: 'same-origin'
|
||
});
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
|
||
var ctype = resp.headers.get('Content-Type') || '';
|
||
var body = await resp.text();
|
||
var trimmed = body.trim();
|
||
var looksLikeJson = trimmed.startsWith('[') || trimmed.startsWith('{');
|
||
if (!ctype.toLowerCase().includes('json') && !looksLikeJson) {
|
||
console.warn('Project-list endpoint returned non-JSON', {
|
||
requested: base,
|
||
finalUrl: resp.url,
|
||
redirected: resp.redirected,
|
||
contentType: ctype,
|
||
bodyStart: trimmed.slice(0, 200)
|
||
});
|
||
if (resp.redirected) {
|
||
loadErrorKind = 'auth';
|
||
throw new Error("The request was redirected to " + resp.url + ' — likely to an auth/login page. Sign in and reload.');
|
||
}
|
||
if (/<title>\s*Loading\s+ZDDC/i.test(trimmed) || /<title>\s*Loading\s+Archive/i.test(trimmed)) {
|
||
loadErrorKind = 'static';
|
||
throw new Error("This deployment doesn't expose a project list. The server is serving static stubs without a zddc-server backend.");
|
||
}
|
||
loadErrorKind = 'non-json';
|
||
throw new Error("The server at " + base + " returned HTML where a JSON project list was expected. Its zddc-server may be too old (no Accept: application/json dispatch on /), a reverse proxy is stripping the header, or the static site at the root has shadowed the API endpoint.");
|
||
}
|
||
|
||
var data = JSON.parse(body);
|
||
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
|
||
allProjects = data.map(function(p) {
|
||
return {
|
||
name: String(p.name || ''),
|
||
title: String(p.title || ''),
|
||
url: String(p.url || '')
|
||
};
|
||
}).filter(function(p) { return p.name; });
|
||
return true;
|
||
} catch (e) {
|
||
loadError = e.message || String(e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── Filter / sort ────────────────────────────────────────────────────────
|
||
|
||
function visibleProjects() {
|
||
var rows = allProjects.slice();
|
||
if (columnFilterASTs.pn && columnFilterASTs.pn.length > 0) {
|
||
rows = rows.filter(function(r) { return zddc.filter.matches(r.name, columnFilterASTs.pn); });
|
||
}
|
||
if (columnFilterASTs.pt && columnFilterASTs.pt.length > 0) {
|
||
rows = rows.filter(function(r) { return zddc.filter.matches(r.title || '', columnFilterASTs.pt); });
|
||
}
|
||
rows.sort(function(a, b) {
|
||
var av = (a[sortField] || '').toString();
|
||
var bv = (b[sortField] || '').toString();
|
||
if (sortField === 'title') {
|
||
if (!av && bv) return 1;
|
||
if (av && !bv) return -1;
|
||
}
|
||
var cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' });
|
||
return cmp * (sortDirection === 'desc' ? -1 : 1);
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
// ── Rendering ────────────────────────────────────────────────────────────
|
||
|
||
function render() {
|
||
renderActionBar();
|
||
renderGroups();
|
||
renderProjects();
|
||
renderProjectCount();
|
||
renderGroupCount();
|
||
}
|
||
|
||
function renderActionBar() {
|
||
var bar = document.getElementById('selectActionBar');
|
||
var title = document.getElementById('selectModeTitle');
|
||
var input = document.getElementById('groupNameInput');
|
||
var newBtn = document.getElementById('newGroupBtn');
|
||
if (!bar) return;
|
||
|
||
if (!selectMode) {
|
||
bar.classList.add('hidden');
|
||
if (newBtn) newBtn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
bar.classList.remove('hidden');
|
||
if (newBtn) newBtn.disabled = true;
|
||
|
||
if (selectMode.kind === 'create') {
|
||
title.textContent = 'New group:';
|
||
if (document.activeElement !== input) input.value = '';
|
||
} else {
|
||
title.textContent = 'Editing:';
|
||
if (document.activeElement !== input) input.value = selectMode.originalName || '';
|
||
}
|
||
}
|
||
|
||
function renderGroups() {
|
||
var container = document.getElementById('groupsContainer');
|
||
if (!container) return;
|
||
if (groups.length === 0) {
|
||
container.innerHTML = '<div class="groups-empty">No saved groups yet. Use <strong>+ New group</strong> to bundle a set of projects.</div>';
|
||
return;
|
||
}
|
||
var html = '<table class="groups-table">';
|
||
html += '<tbody>';
|
||
for (var i = 0; i < groups.length; i++) {
|
||
var g = groups[i];
|
||
var n = escapeHtml(g.name);
|
||
var count = g.projects.length;
|
||
html += '<tr class="groups-row" data-name="' + n + '" onclick="LandingApp.openGroup(event)">';
|
||
html += '<td class="groups-row-name">' + n + '</td>';
|
||
html += '<td class="groups-row-count">' + count + ' project' + (count === 1 ? '' : 's') + '</td>';
|
||
html += '<td class="groups-row-actions">';
|
||
html += '<button class="groups-btn-edit" data-name="' + n + '" onclick="event.stopPropagation(); LandingApp.startEditGroup(event)" title="Edit group">✎</button>';
|
||
html += '<button class="groups-btn-delete" data-name="' + n + '" onclick="event.stopPropagation(); LandingApp.deleteGroup(event)" title="Delete group">×</button>';
|
||
html += '</td>';
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function renderProjects() {
|
||
var container = document.getElementById('projectListContainer');
|
||
if (loadError) {
|
||
var heading, help;
|
||
if (loadErrorKind === 'static') {
|
||
heading = 'This server doesn\'t list projects';
|
||
help = 'You\'re on a static deployment (Caddy serving stubs) — there\'s no zddc-server backend here to enumerate projects. '
|
||
+ 'Open a project directly via its URL (e.g. <code>/<project>/Archive/</code>), or ask whoever sent you this link for the project URL they meant.';
|
||
} else if (loadErrorKind === 'auth') {
|
||
heading = 'Sign-in required';
|
||
help = 'The server bounced this request to an auth page. Sign in there, then reload this URL.';
|
||
} else {
|
||
heading = 'Couldn\'t load the project list';
|
||
help = 'Reload the page to try again. If this keeps happening, the server may be down or your link may be stale.';
|
||
}
|
||
container.innerHTML =
|
||
'<div class="project-list-empty">'
|
||
+ '<h3>' + escapeHtml(heading) + '</h3>'
|
||
+ '<p>' + escapeHtml(loadError) + '</p>'
|
||
+ '<p class="landing-empty-help">' + help + '</p>'
|
||
+ '</div>';
|
||
return;
|
||
}
|
||
if (allProjects.length === 0) {
|
||
container.innerHTML =
|
||
'<div class="project-list-empty">'
|
||
+ '<h3>No projects to show</h3>'
|
||
+ '<p>Either you don\'t have access to any projects on this server yet, or none have been set up.</p>'
|
||
+ '<p class="landing-empty-help">If someone shared this link with you, ask them which project administrator can grant your account access — and double-check that you\'re signed in with the same email they expected.</p>'
|
||
+ '</div>';
|
||
return;
|
||
}
|
||
|
||
var rows = visibleProjects();
|
||
var anyTitles = allProjects.some(function(p) { return p.title; });
|
||
var inSelect = !!selectMode;
|
||
var visibleSelected = inSelect ? rows.filter(function(r) { return selected.has(r.name); }).length : 0;
|
||
var headerCheckedState = !inSelect ? 'unchecked'
|
||
: visibleSelected === 0 ? 'unchecked'
|
||
: visibleSelected === rows.length ? 'checked' : 'indeterminate';
|
||
|
||
var html = '<table class="project-table' + (inSelect ? ' is-select-mode' : '') + '">';
|
||
html += '<thead>';
|
||
html += '<tr class="project-table-headers">';
|
||
if (inSelect) {
|
||
html += '<th class="project-table-checkbox-col">'
|
||
+ '<input type="checkbox" id="headerCheckbox" '
|
||
+ (headerCheckedState === 'checked' ? 'checked ' : '')
|
||
+ 'onclick="LandingApp.toggleHeaderCheckbox()" '
|
||
+ 'title="Check / uncheck all visible projects">'
|
||
+ '</th>';
|
||
}
|
||
html += '<th class="project-table-name-col" data-sort="name" onclick="LandingApp.toggleSort(\'name\')">'
|
||
+ 'Project number ' + sortIndicator('name')
|
||
+ '</th>';
|
||
if (anyTitles) {
|
||
html += '<th class="project-table-title-col" data-sort="title" onclick="LandingApp.toggleSort(\'title\')">'
|
||
+ 'Title ' + sortIndicator('title')
|
||
+ '</th>';
|
||
}
|
||
html += '</tr>';
|
||
html += '<tr class="project-table-filters">';
|
||
if (inSelect) html += '<th></th>';
|
||
html += '<th><input type="text" class="column-filter ' + (columnFilters.pn ? 'filter-active' : '') + '" '
|
||
+ 'data-column="pn" placeholder="filter…" '
|
||
+ 'value="' + escapeHtml(columnFilters.pn) + '" '
|
||
+ 'oninput="LandingApp.onColumnFilterInput(event)"></th>';
|
||
if (anyTitles) {
|
||
html += '<th><input type="text" class="column-filter ' + (columnFilters.pt ? 'filter-active' : '') + '" '
|
||
+ 'data-column="pt" placeholder="filter…" '
|
||
+ 'value="' + escapeHtml(columnFilters.pt) + '" '
|
||
+ 'oninput="LandingApp.onColumnFilterInput(event)"></th>';
|
||
}
|
||
html += '</tr>';
|
||
html += '</thead>';
|
||
|
||
var colspan = (inSelect ? 1 : 0) + 1 + (anyTitles ? 1 : 0);
|
||
if (rows.length === 0) {
|
||
html += '<tbody><tr><td colspan="' + colspan + '" class="project-table-no-match">'
|
||
+ 'No projects match the current filters.'
|
||
+ '</td></tr></tbody>';
|
||
} else {
|
||
html += '<tbody>';
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var r = rows[i];
|
||
var isSel = inSelect && selected.has(r.name);
|
||
html += '<tr class="project-table-row' + (isSel ? ' is-selected' : '') + '" '
|
||
+ 'data-name="' + escapeHtml(r.name) + '" onclick="LandingApp.onProjectRowClick(event)">';
|
||
if (inSelect) {
|
||
html += '<td class="project-table-checkbox-col"><input type="checkbox" value="' + escapeHtml(r.name) + '"'
|
||
+ (isSel ? ' checked' : '')
|
||
+ ' onclick="event.stopPropagation(); LandingApp.toggleByCheckbox(event)"></td>';
|
||
}
|
||
html += '<td class="project-table-name-col">' + escapeHtml(r.name) + '</td>';
|
||
if (anyTitles) {
|
||
html += '<td class="project-table-title-col">' + (r.title ? escapeHtml(r.title) : '<span class="project-table-no-title">—</span>') + '</td>';
|
||
}
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody>';
|
||
}
|
||
html += '</table>';
|
||
container.innerHTML = html;
|
||
|
||
var headerCb = document.getElementById('headerCheckbox');
|
||
if (headerCb) headerCb.indeterminate = headerCheckedState === 'indeterminate';
|
||
}
|
||
|
||
function sortIndicator(field) {
|
||
if (sortField !== field) return '<span class="sort-indicator">↕</span>';
|
||
return '<span class="sort-indicator active">' + (sortDirection === 'asc' ? '▲' : '▼') + '</span>';
|
||
}
|
||
|
||
function renderProjectCount() {
|
||
var el = document.getElementById('projectCount');
|
||
if (!el) return;
|
||
if (loadError || allProjects.length === 0) { el.textContent = ''; return; }
|
||
var rows = visibleProjects();
|
||
var base = rows.length === allProjects.length
|
||
? '(' + allProjects.length + ')'
|
||
: '(' + rows.length + ' of ' + allProjects.length + ')';
|
||
if (selectMode) {
|
||
base = base + ' — ' + selected.size + ' checked';
|
||
}
|
||
el.textContent = base;
|
||
}
|
||
|
||
function renderGroupCount() {
|
||
var el = document.getElementById('groupCount');
|
||
if (!el) return;
|
||
el.textContent = groups.length === 0 ? '' : '(' + groups.length + ')';
|
||
}
|
||
|
||
// ── Events / actions ─────────────────────────────────────────────────────
|
||
|
||
function toggleSort(field) {
|
||
if (sortField === field) {
|
||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
sortField = field;
|
||
sortDirection = 'asc';
|
||
}
|
||
urlPush();
|
||
render();
|
||
}
|
||
|
||
function onColumnFilterInput(e) {
|
||
var col = e.target.getAttribute('data-column');
|
||
var val = e.target.value;
|
||
columnFilters[col] = val;
|
||
columnFilterASTs[col] = parseFilterAST(val);
|
||
urlPush();
|
||
renderProjects();
|
||
renderProjectCount();
|
||
var sel = document.querySelector('.column-filter[data-column="' + col + '"]');
|
||
if (sel) {
|
||
sel.focus();
|
||
sel.setSelectionRange(sel.value.length, sel.value.length);
|
||
}
|
||
}
|
||
|
||
function onProjectRowClick(e) {
|
||
var row = e.target.closest('.project-table-row');
|
||
if (!row) return;
|
||
var name = row.getAttribute('data-name');
|
||
if (!name) return;
|
||
if (selectMode) {
|
||
// In select-mode the row toggles its checkbox.
|
||
if (selected.has(name)) selected.delete(name);
|
||
else selected.add(name);
|
||
render();
|
||
} else {
|
||
// Default mode: click opens that single project directly.
|
||
openArchiveWith([name]);
|
||
}
|
||
}
|
||
|
||
function toggleByCheckbox(e) {
|
||
if (!selectMode) return;
|
||
var cb = e.target;
|
||
var name = cb.value;
|
||
if (cb.checked) selected.add(name);
|
||
else selected.delete(name);
|
||
render();
|
||
}
|
||
|
||
function toggleHeaderCheckbox() {
|
||
if (!selectMode) return;
|
||
var cb = document.getElementById('headerCheckbox');
|
||
if (!cb) return;
|
||
var rows = visibleProjects();
|
||
if (cb.checked) rows.forEach(function(r) { selected.add(r.name); });
|
||
else rows.forEach(function(r) { selected.delete(r.name); });
|
||
render();
|
||
}
|
||
|
||
// Navigation hook — tests replace this via LandingApp._setNavigate.
|
||
// (Patching window.location.href is unreliable in modern engines.)
|
||
var navigate = function(url) { location.href = url; };
|
||
|
||
function openArchiveWith(names) {
|
||
if (!names || names.length === 0) return;
|
||
var base = location.pathname.replace(/\/[^\/]*$/, '/');
|
||
var v = new URLSearchParams(location.search).get('v');
|
||
|
||
if (names.length === 1) {
|
||
// Single project → canonical project-subtree URL so the user
|
||
// can edit the address bar to swap archive.html for
|
||
// working/, staging/, reviewing/, etc. zddc-server's
|
||
// availability.go auto-serves the right tool at each.
|
||
// Multi-project (the `else` branch) keeps the ?projects=
|
||
// form because there's no single subtree root.
|
||
var url = base + encodeURIComponent(names[0]) + '/archive.html';
|
||
if (v) url += '?v=' + encodeURIComponent(v);
|
||
navigate(url);
|
||
return;
|
||
}
|
||
|
||
var params = ['projects=' + names.map(encodeURIComponent).join(',')];
|
||
if (v) params.push('v=' + encodeURIComponent(v));
|
||
navigate(base + 'archive.html?' + params.join('&'));
|
||
}
|
||
|
||
function openGroup(e) {
|
||
var row = e.target.closest('.groups-row');
|
||
if (!row) return;
|
||
var name = row.getAttribute('data-name');
|
||
var g = groups.find(function(x) { return x.name === name; });
|
||
if (!g) return;
|
||
// Drop projects the user no longer has access to (server-side ACL may
|
||
// have changed since the group was saved).
|
||
var accessible = new Set(allProjects.map(function(p) { return p.name; }));
|
||
var openable = g.projects.filter(function(p) { return accessible.has(p); });
|
||
if (openable.length === 0) {
|
||
showWarning('Group "' + name + '" has no projects you currently have access to.');
|
||
return;
|
||
}
|
||
if (openable.length < g.projects.length) {
|
||
// Open with what we can; warn but don't block.
|
||
console.warn('Skipping inaccessible projects in group', name, g.projects.filter(function(p) { return !accessible.has(p); }));
|
||
}
|
||
openArchiveWith(openable);
|
||
}
|
||
|
||
function dismissWarning() {
|
||
var el = document.getElementById('accessWarningBanner');
|
||
if (el) el.classList.add('hidden');
|
||
}
|
||
|
||
function showWarning(message) {
|
||
var el = document.getElementById('accessWarningBanner');
|
||
var txt = document.getElementById('accessWarningText');
|
||
if (!el || !txt) return;
|
||
txt.textContent = message;
|
||
el.classList.remove('hidden');
|
||
}
|
||
|
||
// ── Select-mode (create / edit groups) ───────────────────────────────────
|
||
|
||
function startCreateGroup() {
|
||
selectMode = { kind: 'create' };
|
||
selected = new Set();
|
||
render();
|
||
var input = document.getElementById('groupNameInput');
|
||
if (input) input.focus();
|
||
}
|
||
|
||
function startEditGroup(e) {
|
||
var btn = e.target.closest('.groups-btn-edit');
|
||
if (!btn) return;
|
||
var name = btn.getAttribute('data-name');
|
||
var g = groups.find(function(x) { return x.name === name; });
|
||
if (!g) return;
|
||
selectMode = { kind: 'edit', originalName: g.name };
|
||
selected = new Set(g.projects);
|
||
render();
|
||
var input = document.getElementById('groupNameInput');
|
||
if (input) input.focus();
|
||
}
|
||
|
||
function deleteGroup(e) {
|
||
var btn = e.target.closest('.groups-btn-delete');
|
||
if (!btn) return;
|
||
var name = btn.getAttribute('data-name');
|
||
if (!confirm('Delete group "' + name + '"?')) return;
|
||
groups = groups.filter(function(g) { return g.name !== name; });
|
||
persistGroups();
|
||
render();
|
||
}
|
||
|
||
function cancelSelect() {
|
||
selectMode = null;
|
||
selected = new Set();
|
||
render();
|
||
}
|
||
|
||
function saveGroup() {
|
||
if (!selectMode) return;
|
||
var input = document.getElementById('groupNameInput');
|
||
if (!input) return;
|
||
var name = (input.value || '').trim();
|
||
if (!name) {
|
||
input.focus();
|
||
return;
|
||
}
|
||
var projects = Array.from(selected).sort();
|
||
if (projects.length === 0) {
|
||
alert('Select at least one project before saving the group.');
|
||
return;
|
||
}
|
||
if (selectMode.kind === 'create') {
|
||
// Reject duplicate names so two groups can't share an identity.
|
||
if (groups.some(function(g) { return g.name === name; })) {
|
||
alert('A group named "' + name + '" already exists. Pick a different name or edit that group instead.');
|
||
return;
|
||
}
|
||
groups.push({ name: name, projects: projects });
|
||
} else {
|
||
// Editing: rename if name changed (and the new name doesn't collide).
|
||
if (name !== selectMode.originalName && groups.some(function(g) { return g.name === name; })) {
|
||
alert('A group named "' + name + '" already exists. Pick a different name.');
|
||
return;
|
||
}
|
||
groups = groups.map(function(g) {
|
||
return g.name === selectMode.originalName
|
||
? { name: name, projects: projects }
|
||
: g;
|
||
});
|
||
}
|
||
persistGroups();
|
||
selectMode = null;
|
||
selected = new Set();
|
||
render();
|
||
}
|
||
|
||
function openSelectedVisible() {
|
||
if (!selectMode) return;
|
||
// Rule: open only those that are currently visible (filtered in) AND
|
||
// checked. Filter-hidden but checked items are intentionally left out.
|
||
var visibleNames = new Set(visibleProjects().map(function(r) { return r.name; }));
|
||
var openable = Array.from(selected).filter(function(n) { return visibleNames.has(n); });
|
||
if (openable.length === 0) {
|
||
alert('No checked projects are currently visible. Adjust filters or check more projects to open.');
|
||
return;
|
||
}
|
||
openArchiveWith(openable);
|
||
}
|
||
|
||
// ── Persistence ──────────────────────────────────────────────────────────
|
||
|
||
function loadGroups() {
|
||
var raw;
|
||
try { raw = localStorage.getItem(GROUPS_KEY); }
|
||
catch (e) { raw = null; }
|
||
if (raw) {
|
||
try {
|
||
var parsed = JSON.parse(raw);
|
||
groups = Array.isArray(parsed) ? parsed.filter(isValidGroup) : [];
|
||
return;
|
||
} catch (e) { /* fall through to legacy */ }
|
||
}
|
||
// One-shot migration: convert old `zddc_landing_presets` (which carried
|
||
// filter+sort state alongside the project list) to plain groups.
|
||
try {
|
||
var legacy = localStorage.getItem(LEGACY_PRESETS_KEY);
|
||
if (!legacy) return;
|
||
var legacyParsed = JSON.parse(legacy);
|
||
if (!Array.isArray(legacyParsed)) return;
|
||
groups = legacyParsed.map(function(p) {
|
||
var projects = (p && p.state && Array.isArray(p.state.projects)) ? p.state.projects : [];
|
||
return { name: String(p && p.name || ''), projects: projects };
|
||
}).filter(isValidGroup);
|
||
persistGroups();
|
||
} catch (e) { groups = []; }
|
||
}
|
||
|
||
function isValidGroup(g) {
|
||
return g && typeof g.name === 'string' && g.name.length > 0
|
||
&& Array.isArray(g.projects);
|
||
}
|
||
|
||
function persistGroups() {
|
||
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(groups)); }
|
||
catch (e) { /* private mode / quota */ }
|
||
}
|
||
|
||
// ── Project mode ─────────────────────────────────────────────────────────
|
||
//
|
||
// The same landing tool serves at /<project> as the project-workspace
|
||
// page. Mode is determined from location.pathname:
|
||
//
|
||
// / → 'picker' (existing behavior)
|
||
// /<single-segment> → 'project'
|
||
// /index.html → 'picker' (file:// + standalone-served root)
|
||
// anything else → 'picker' (best-effort fallback)
|
||
//
|
||
// Project mode shows the four canonical lifecycle-stage cards, a
|
||
// "browse all files" link, and a Master Deliverables List section
|
||
// with direct links to any parties currently in archive/. The party
|
||
// list is fetched from <project>/<archive>/?json=1; failures fall
|
||
// back to the static "no parties yet" copy.
|
||
|
||
function detectMode() {
|
||
if (typeof location === 'undefined') return 'picker';
|
||
var path = location.pathname || '/';
|
||
// Strip any trailing /index.html so the deployment-root case
|
||
// matches even on file:// or behind some servers.
|
||
var trimmed = path.replace(/\/index\.html$/, '/');
|
||
if (trimmed === '' || trimmed === '/') return 'picker';
|
||
// Single non-slash, non-dot segment → project root.
|
||
var parts = trimmed.split('/').filter(Boolean);
|
||
if (parts.length === 1 && parts[0].indexOf('.') === -1) {
|
||
return 'project';
|
||
}
|
||
return 'picker';
|
||
}
|
||
|
||
function projectFromPath() {
|
||
var parts = (location.pathname || '/').split('/').filter(Boolean);
|
||
return parts[0] || '';
|
||
}
|
||
|
||
// Render the project-workspace view: title, four stage links, MDL
|
||
// section. Stage hrefs use the no-trailing-slash form so the server
|
||
// routes them to each canonical default tool (mdedit for working/,
|
||
// transmittal for staging/, etc.). Browse-all and the archive deep
|
||
// link use the slash form to land on the directory listing.
|
||
async function renderProjectMode() {
|
||
var project = projectFromPath();
|
||
if (!project) return;
|
||
|
||
// Hide picker, show project view.
|
||
var picker = document.getElementById('pickerView');
|
||
var projectView = document.getElementById('projectView');
|
||
if (picker) picker.classList.add('hidden');
|
||
if (projectView) projectView.classList.remove('hidden');
|
||
|
||
document.title = project + ' — ZDDC';
|
||
var titleEl = document.getElementById('projectName');
|
||
if (titleEl) titleEl.textContent = project;
|
||
|
||
var p = encodeURIComponent(project);
|
||
var stages = [
|
||
{ id: 'stageArchive', href: '/' + p + '/archive' },
|
||
{ id: 'stageWorking', href: '/' + p + '/working' },
|
||
{ id: 'stageStaging', href: '/' + p + '/staging' },
|
||
{ id: 'stageReviewing', href: '/' + p + '/reviewing' },
|
||
];
|
||
for (var i = 0; i < stages.length; i++) {
|
||
var a = document.getElementById(stages[i].id);
|
||
if (a) a.setAttribute('href', stages[i].href);
|
||
}
|
||
|
||
var browseAll = document.getElementById('browseAllLink');
|
||
if (browseAll) {
|
||
browseAll.setAttribute('href', '/' + p + '/');
|
||
browseAll.textContent = 'Browse all files →';
|
||
}
|
||
var archiveBrowse = document.getElementById('archiveBrowseLink');
|
||
if (archiveBrowse) {
|
||
archiveBrowse.setAttribute('href', '/' + p + '/archive/');
|
||
archiveBrowse.innerHTML = '<code>/' + escapeHtml(project) + '/archive/</code>';
|
||
}
|
||
|
||
// Fetch party list. Best-effort — failures render the
|
||
// no-parties-yet fallback. We try /<project>/archive/ — the
|
||
// server returns the listing in either lowercase or PascalCase
|
||
// form; either yields the same JSON shape via case-insensitive
|
||
// URL canonicalization.
|
||
var partySection = document.getElementById('partyListSection');
|
||
if (!partySection) return;
|
||
|
||
var parties = await fetchParties(p);
|
||
if (parties == null) {
|
||
// Network error or unauthenticated — show neither list nor
|
||
// explicit "none" message. The page is still usable.
|
||
partySection.innerHTML = '';
|
||
return;
|
||
}
|
||
if (parties.length === 0) {
|
||
partySection.innerHTML =
|
||
'<p class="party-list-none-yet">No party folders yet. The MDL view auto-renders at any '
|
||
+ '<code>archive/<party>/mdl/</code> URL, even when the folder doesn\'t exist on '
|
||
+ 'disk — so you can start editing an MDL before any transmittals have been exchanged.</p>';
|
||
return;
|
||
}
|
||
var html = '<p><strong>Direct links — parties currently in <code>archive/</code>:</strong></p>'
|
||
+ '<ul class="party-list">';
|
||
for (var j = 0; j < parties.length; j++) {
|
||
var name = parties[j].name;
|
||
var url = parties[j].url; // server-provided absolute URL
|
||
html += '<li><a href="' + url + 'mdl/">' + escapeHtml(name) + ' MDL →</a></li>';
|
||
}
|
||
html += '</ul>';
|
||
partySection.innerHTML = html;
|
||
}
|
||
|
||
// Returns an array of {name, url} for each party folder in the
|
||
// project's archive/, sorted by name. Returns null if the listing
|
||
// can't be fetched (offline, 4xx, or non-JSON response). Returns
|
||
// [] if the listing succeeds but archive/ is empty / has no
|
||
// visible party folders.
|
||
async function fetchParties(projectURL) {
|
||
try {
|
||
var resp = await fetch('/' + projectURL + '/archive/', {
|
||
headers: { 'Accept': 'application/json' },
|
||
cache: 'no-cache',
|
||
credentials: 'same-origin'
|
||
});
|
||
if (!resp.ok) return null;
|
||
var ctype = resp.headers.get('Content-Type') || '';
|
||
if (!ctype.toLowerCase().includes('json')) return null;
|
||
var data = await resp.json();
|
||
if (!Array.isArray(data)) return null;
|
||
// Server emits directories with trailing "/" on the name.
|
||
// Filter to dirs only, strip the slash for display.
|
||
var out = [];
|
||
for (var i = 0; i < data.length; i++) {
|
||
var e = data[i];
|
||
if (!e.is_dir) continue;
|
||
var nm = String(e.name || '').replace(/\/$/, '');
|
||
if (!nm) continue;
|
||
if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue;
|
||
out.push({ name: nm, url: e.url || ('/' + projectURL + '/archive/' + encodeURIComponent(nm) + '/') });
|
||
}
|
||
out.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
|
||
return out;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── Bootstrap ────────────────────────────────────────────────────────────
|
||
|
||
async function init() {
|
||
if (detectMode() === 'project') {
|
||
await renderProjectMode();
|
||
return;
|
||
}
|
||
await initPicker();
|
||
}
|
||
|
||
async function initPicker() {
|
||
loadGroups();
|
||
urlRestore();
|
||
|
||
var ok = await fetchProjects();
|
||
if (ok) {
|
||
// No URL-restored selection in the new model, but warn about
|
||
// groups that reference inaccessible projects so the user knows
|
||
// why their group is shorter than expected when opened.
|
||
var accessibleNames = new Set(allProjects.map(function(p) { return p.name; }));
|
||
var ghostlyGroups = groups.filter(function(g) {
|
||
return g.projects.some(function(p) { return !accessibleNames.has(p); });
|
||
});
|
||
if (ghostlyGroups.length > 0) {
|
||
console.info('Some saved groups reference projects you no longer have access to; they will open with the accessible subset only.', ghostlyGroups.map(function(g) { return g.name; }));
|
||
}
|
||
}
|
||
|
||
render();
|
||
|
||
// Wire up keyboard shortcuts in the action-bar input: Enter saves,
|
||
// Escape cancels.
|
||
var input = document.getElementById('groupNameInput');
|
||
if (input) {
|
||
input.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); saveGroup(); }
|
||
else if (e.key === 'Escape') { e.preventDefault(); cancelSelect(); }
|
||
});
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
var div = document.createElement('div');
|
||
div.textContent = String(text == null ? '' : text);
|
||
return div.innerHTML;
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
|
||
// Public API for inline handlers.
|
||
window.LandingApp = {
|
||
init: init,
|
||
toggleByCheckbox: toggleByCheckbox,
|
||
toggleHeaderCheckbox: toggleHeaderCheckbox,
|
||
toggleSort: toggleSort,
|
||
onColumnFilterInput: onColumnFilterInput,
|
||
onProjectRowClick: onProjectRowClick,
|
||
openGroup: openGroup,
|
||
startCreateGroup: startCreateGroup,
|
||
startEditGroup: startEditGroup,
|
||
deleteGroup: deleteGroup,
|
||
cancelSelect: cancelSelect,
|
||
saveGroup: saveGroup,
|
||
openSelectedVisible: openSelectedVisible,
|
||
dismissWarning: dismissWarning,
|
||
// Project-mode entry points (also tested directly).
|
||
detectMode: detectMode,
|
||
renderProjectMode: renderProjectMode,
|
||
// Test-only: override the navigation function (avoids the messy
|
||
// browser-locked-down state of window.location).
|
||
_setNavigate: function(fn) { navigate = fn; }
|
||
};
|
||
|
||
})();
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|