ZDDC/zddc/internal/apps/embedded/index.html

2671 lines
90 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC — 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;
}
/* 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 classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
#help-btn {
font-size: 1rem;
}
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
/* Used by all four tools. Toggle open/close via shared/help.js. */
.help-panel {
position: fixed;
top: 0;
right: 0;
width: min(420px, 85vw);
height: 100vh;
z-index: 1000;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.help-panel:not([hidden]) {
transform: translateX(0);
}
.help-panel[hidden] {
display: flex;
transform: translateX(100%);
pointer-events: none;
}
.help-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.help-panel__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.help-panel__close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.35rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.help-panel__close:hover {
color: var(--text);
background: var(--bg-secondary);
}
.help-panel__body {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 2rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.help-panel__body h3 {
font-size: 0.95rem;
font-weight: 700;
margin: 1.25rem 0 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.15rem;
}
.help-panel__body h3:first-child {
margin-top: 0;
}
.help-panel__body h4 {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.3rem;
padding-left: 0.5rem;
border-left: 3px solid var(--border-dark);
color: var(--text-muted);
}
.help-panel__body p {
margin: 0 0 0.5rem;
}
.help-panel__body ol,
.help-panel__body ul {
padding-left: 1.5rem;
margin: 0.3rem 0 0.5rem;
}
.help-panel__body li {
margin-bottom: 0.3rem;
}
.help-panel__body dl {
margin: 0.3rem 0;
}
.help-panel__body dt {
font-weight: 600;
color: var(--text);
}
.help-panel__body dd {
margin: 0 0 0.5rem 1rem;
color: var(--text-muted);
}
.help-panel__body code {
font-family: var(--font-mono);
font-size: 0.8em;
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.help-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: var(--radius);
vertical-align: middle;
letter-spacing: 0.02em;
}
.help-badge--draft {
color: #2563eb;
background: #eff6ff;
}
.help-badge--published {
color: #7c3aed;
background: #f5f3ff;
}
/* Shrink main content when help panel is open */
body.help-open .app-header {
margin-right: min(420px, 85vw);
}
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
.column-filter {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.column-filter:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
}
.column-filter::placeholder {
color: var(--text-muted);
}
/* 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);
}
/* 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);
}
</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 · lily-quilt-hazel</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 class="landing-main">
<!-- 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">&times;</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>
</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">&times;</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();
}
})();
/**
* 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>/&lt;project&gt;/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 */ }
}
// ── Bootstrap ────────────────────────────────────────────────────────────
async function init() {
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,
// Test-only: override the navigation function (avoids the messy
// browser-locked-down state of window.location).
_setNavigate: function(fn) { navigate = fn; }
};
})();
</script>
</body>
</html>