The earlier symlink approach (commit 03f83ad) broke under the canonical
deployment shape. The Caddy systemd unit at
/etc/containers/systemd/caddy.container mounts only
/home/user/src/zddc/website:/usr/share/caddy/zddc:ro
into the Caddy container, so a symlink at
website/releases/landing_alpha.html → ../../landing/dist/index.html
resolves to /usr/share/caddy/landing/dist/index.html inside the
container — a path that simply doesn't exist there. Result:
GET https://zddc.varasys.io/releases/landing_alpha.html → 404, and
the dev cluster's level-2 stub failed to load.
Revert update_alpha() to a plain copy. Trade-off goes back to: every
dev build dirties the corresponding _alpha.html in git. Commit
alongside source changes (alpha is mutable channel anyway) or
git checkout to discard. cp follows symlinks at the destination, so
the helper now `rm -f`s the dest before copying — handles the
symlink-to-file transition cleanly.
Updates AGENTS.md and CLAUDE.md to describe the copy semantics and
the volume-mount constraint that motivates them. Five _alpha.html
files convert from symlinks back to regular files (typechange).
End-to-end verified: curl https://zddc.varasys.io/releases/landing_alpha.html
returns 200 (30177 bytes) after the rebuild.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1036 lines
30 KiB
HTML
1036 lines
30 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 Archive — Projects</title>
|
|
<style>
|
|
/* ==========================================================================
|
|
ZDDC Shared Base — single source of truth for tokens and primitives
|
|
Included first by every tool's build.sh via ../shared/base.css
|
|
========================================================================== */
|
|
|
|
/* ── CSS custom properties ────────────────────────────────────────────────── */
|
|
:root {
|
|
/* Brand / accent (matches zddc.varasys.io website --accent) */
|
|
--primary: #2a5a8a;
|
|
--primary-hover: #1d4060;
|
|
--primary-active: #163352;
|
|
--primary-light: #e8f0f7;
|
|
|
|
/* Semantic colours */
|
|
--success: #28a745;
|
|
--warning: #d97706;
|
|
--danger: #dc3545;
|
|
--info: #17a2b8;
|
|
|
|
/* Backgrounds */
|
|
--bg: #ffffff;
|
|
--bg-secondary: #f8f9fa;
|
|
--bg-hover: #f0f4f8;
|
|
--bg-selected: var(--primary-light);
|
|
|
|
/* Text */
|
|
--text: #212529;
|
|
--text-muted: #6c757d;
|
|
--text-light: #ffffff;
|
|
|
|
/* Borders */
|
|
--border: #dee2e6;
|
|
--border-dark: #adb5bd;
|
|
|
|
/* Shape */
|
|
--radius: 4px;
|
|
|
|
/* Typography */
|
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
|
}
|
|
|
|
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
|
|
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
|
|
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme="light"]) {
|
|
--primary: #4a90c4;
|
|
--primary-hover: #5ba3d9;
|
|
--primary-active: #6ab5e8;
|
|
--primary-light: #1a3550;
|
|
|
|
--bg: #1e1e1e;
|
|
--bg-secondary: #252526;
|
|
--bg-hover: #2d2d30;
|
|
--bg-selected: #1a3550;
|
|
|
|
--text: #d4d4d4;
|
|
--text-muted: #9d9d9d;
|
|
--text-light: #ffffff;
|
|
|
|
--border: #3e3e42;
|
|
--border-dark: #6e6e72;
|
|
}
|
|
}
|
|
|
|
/* Manual dark override — wins over media query */
|
|
[data-theme="dark"] {
|
|
--primary: #4a90c4;
|
|
--primary-hover: #5ba3d9;
|
|
--primary-active: #6ab5e8;
|
|
--primary-light: #1a3550;
|
|
|
|
--bg: #1e1e1e;
|
|
--bg-secondary: #252526;
|
|
--bg-hover: #2d2d30;
|
|
--bg-selected: #1a3550;
|
|
|
|
--text: #d4d4d4;
|
|
--text-muted: #9d9d9d;
|
|
--text-light: #ffffff;
|
|
|
|
--border: #3e3e42;
|
|
--border-dark: #6e6e72;
|
|
}
|
|
|
|
/* ── Reset ────────────────────────────────────────────────────────────────── */
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
/* ── Base document ────────────────────────────────────────────────────────── */
|
|
html, body {
|
|
height: 100%;
|
|
font-family: var(--font);
|
|
font-size: 16px;
|
|
line-height: 1.5;
|
|
color: var(--text);
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
/* ── Typography ───────────────────────────────────────────────────────────── */
|
|
h1, h2, h3, h4, h5, h6 {
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
a {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* ── Utility ──────────────────────────────────────────────────────────────── */
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.truncate {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
|
|
::-webkit-scrollbar {
|
|
width: 7px;
|
|
height: 7px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: #c1c1c1;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #a0a0a0;
|
|
}
|
|
|
|
/* ── Button primitive ─────────────────────────────────────────────────────── */
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.4rem 0.85rem;
|
|
font-family: var(--font);
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
line-height: 1.4;
|
|
text-align: center;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
vertical-align: middle;
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius);
|
|
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
|
|
background: var(--bg-secondary);
|
|
color: var(--text);
|
|
}
|
|
|
|
.btn:disabled,
|
|
.btn[disabled] {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn:not(:disabled):hover {
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
|
}
|
|
|
|
.btn:not(:disabled):active {
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* Variants */
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: var(--text-light);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.btn-primary:not(:disabled):hover {
|
|
background: var(--primary-hover);
|
|
border-color: var(--primary-hover);
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.btn-primary:not(:disabled):active {
|
|
background: var(--primary-active);
|
|
border-color: var(--primary-active);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
border-color: var(--border);
|
|
}
|
|
|
|
.btn-secondary:not(:disabled):hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.btn-success {
|
|
background: var(--success);
|
|
color: var(--text-light);
|
|
border-color: var(--success);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--danger);
|
|
color: var(--text-light);
|
|
border-color: var(--danger);
|
|
}
|
|
|
|
/* Sizes */
|
|
.btn-sm {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.btn-lg {
|
|
padding: 0.6rem 1.4rem;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.btn-link {
|
|
background: transparent;
|
|
border-color: transparent;
|
|
color: var(--primary);
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
}
|
|
|
|
.btn-link:not(:disabled):hover {
|
|
text-decoration: underline;
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* ── App header chrome ────────────────────────────────────────────────────── */
|
|
.app-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.35rem 1rem;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Tool name inside the header */
|
|
.app-header__title {
|
|
font-size: 17px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
letter-spacing: 0.01em;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Build timestamp ──────────────────────────────────────────────────────── */
|
|
.build-timestamp {
|
|
font-size: 0.55rem;
|
|
color: var(--text-muted);
|
|
opacity: 0.7;
|
|
font-weight: 300;
|
|
white-space: nowrap;
|
|
padding-top: 0.15rem;
|
|
}
|
|
|
|
/* Title + timestamp stacked vertically on the left side of the header */
|
|
.header-title-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
line-height: 1;
|
|
}
|
|
|
|
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
|
|
/* Square, centered — overrides the asymmetric text-button padding/line-height */
|
|
#help-btn,
|
|
#theme-btn,
|
|
#refreshHeaderBtn {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
padding: 0;
|
|
line-height: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
|
|
|
|
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
|
|
#theme-btn,
|
|
#help-btn {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
|
|
/* Used by all four tools. Toggle open/close via shared/help.js. */
|
|
|
|
.help-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
width: min(420px, 85vw);
|
|
height: 100vh;
|
|
z-index: 1000;
|
|
background: var(--bg);
|
|
border-left: 1px solid var(--border);
|
|
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
|
|
display: flex;
|
|
flex-direction: column;
|
|
transform: translateX(100%);
|
|
transition: transform 0.25s ease;
|
|
}
|
|
|
|
.help-panel:not([hidden]) {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.help-panel[hidden] {
|
|
display: flex;
|
|
transform: translateX(100%);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.help-panel__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.75rem 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.help-panel__title {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
margin: 0;
|
|
}
|
|
|
|
.help-panel__close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
font-size: 1.35rem;
|
|
cursor: pointer;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: var(--radius);
|
|
line-height: 1;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.help-panel__close:hover {
|
|
color: var(--text);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.help-panel__body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem 1rem 2rem;
|
|
font-size: 0.85rem;
|
|
line-height: 1.6;
|
|
color: var(--text);
|
|
}
|
|
|
|
.help-panel__body h3 {
|
|
font-size: 0.95rem;
|
|
font-weight: 700;
|
|
margin: 1.25rem 0 0.35rem;
|
|
color: var(--text);
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 0.15rem;
|
|
}
|
|
|
|
.help-panel__body h3:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.help-panel__body h4 {
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
margin: 1.25rem 0 0.3rem;
|
|
padding-left: 0.5rem;
|
|
border-left: 3px solid var(--border-dark);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.help-panel__body p {
|
|
margin: 0 0 0.5rem;
|
|
}
|
|
|
|
.help-panel__body ol,
|
|
.help-panel__body ul {
|
|
padding-left: 1.5rem;
|
|
margin: 0.3rem 0 0.5rem;
|
|
}
|
|
|
|
.help-panel__body li {
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
|
|
.help-panel__body dl {
|
|
margin: 0.3rem 0;
|
|
}
|
|
|
|
.help-panel__body dt {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
|
|
.help-panel__body dd {
|
|
margin: 0 0 0.5rem 1rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.help-panel__body code {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8em;
|
|
background: var(--bg-secondary);
|
|
padding: 0.1em 0.3em;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.help-badge {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
padding: 0.1rem 0.35rem;
|
|
border-radius: var(--radius);
|
|
vertical-align: middle;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.help-badge--draft {
|
|
color: #2563eb;
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.help-badge--published {
|
|
color: #7c3aed;
|
|
background: #f5f3ff;
|
|
}
|
|
|
|
/* Shrink main content when help panel is open */
|
|
body.help-open .app-header {
|
|
margin-right: min(420px, 85vw);
|
|
}
|
|
|
|
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
|
|
.column-filter {
|
|
display: block;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
margin-top: 0.25rem;
|
|
padding: 0.2rem 0.4rem;
|
|
font-size: 0.8rem;
|
|
font-family: var(--font);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.column-filter:focus {
|
|
border-color: var(--primary);
|
|
outline: none;
|
|
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
|
|
}
|
|
|
|
.column-filter::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Landing page layout */
|
|
body {
|
|
margin: 0;
|
|
font-family: var(--font);
|
|
font-size: 14px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text);
|
|
}
|
|
|
|
.landing-main {
|
|
max-width: 640px;
|
|
margin: 32px auto;
|
|
padding: 0 16px;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* Main card */
|
|
.landing-card {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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-header h2 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
|
|
.landing-header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* Project list */
|
|
.project-list {
|
|
padding: 8px 0;
|
|
min-height: 80px;
|
|
}
|
|
|
|
.project-list-empty {
|
|
padding: 32px 16px;
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.project-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px 16px;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
border-radius: 0;
|
|
transition: background 0.1s;
|
|
}
|
|
.project-item:hover { background: var(--bg-hover); }
|
|
.project-item input[type="checkbox"] {
|
|
width: 16px;
|
|
height: 16px;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
accent-color: var(--primary);
|
|
}
|
|
.project-item-name {
|
|
font-size: 0.9375rem;
|
|
color: var(--text);
|
|
user-select: none;
|
|
}
|
|
|
|
/* Footer */
|
|
.landing-card-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
border-top: 1px solid var(--border);
|
|
gap: 8px;
|
|
}
|
|
|
|
/* Preset menu */
|
|
.preset-control {
|
|
position: relative;
|
|
}
|
|
.preset-menu {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
margin-top: 4px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
|
min-width: 200px;
|
|
z-index: 100;
|
|
padding: 4px 0;
|
|
}
|
|
.preset-menu.hidden { display: none; }
|
|
.preset-menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
gap: 8px;
|
|
}
|
|
.preset-menu-item:hover { background: var(--bg-hover); }
|
|
.preset-menu-item-name {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.preset-delete-btn {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--text-muted);
|
|
font-size: 0.875rem;
|
|
padding: 0 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
.preset-delete-btn:hover { color: var(--danger); }
|
|
.preset-menu-empty {
|
|
padding: 8px 12px;
|
|
color: var(--text-muted);
|
|
font-size: 0.875rem;
|
|
}
|
|
.preset-menu-divider {
|
|
border: none;
|
|
border-top: 1px solid var(--border);
|
|
margin: 4px 0;
|
|
}
|
|
|
|
/* Loading state */
|
|
.project-list-loading {
|
|
padding: 32px 16px;
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="app-header">
|
|
<div class="header-left">
|
|
<span class="app-header__title">ZDDC Archive</span>
|
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">Built: 2026-04-28 23:40:19 BETA</span></span>
|
|
</div>
|
|
<div class="header-right">
|
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="landing-main">
|
|
<!-- 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>
|
|
|
|
<div class="landing-card">
|
|
<div class="landing-card-header">
|
|
<h2>Select Projects</h2>
|
|
<div class="landing-header-actions">
|
|
<!-- Presets dropdown -->
|
|
<div class="preset-control">
|
|
<button id="presetMenuBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.togglePresetMenu()">▾ Presets</button>
|
|
<div id="presetMenu" class="preset-menu hidden"></div>
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm" onclick="LandingApp.selectAll()">Select All</button>
|
|
<button class="btn btn-secondary btn-sm" onclick="LandingApp.selectNone()">Select None</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="projectList" class="project-list">
|
|
<!-- Populated by JS -->
|
|
</div>
|
|
|
|
<div class="landing-card-footer">
|
|
<button id="savePresetBtn" class="btn btn-secondary" onclick="LandingApp.savePreset()">Save Preset…</button>
|
|
<button id="openArchiveBtn" class="btn btn-primary" onclick="LandingApp.openArchive()">Open Archive →</button>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
/**
|
|
* 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();
|
|
}
|
|
}());
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
var accessibleProjects = []; // [{name, url}, ...] from server
|
|
var presets = []; // [{name, projects[]}, ...] from localStorage
|
|
var PRESETS_KEY = 'zddc_landing_presets';
|
|
|
|
// ── Initialise ──────────────────────────────────────────────────────────
|
|
|
|
async function init() {
|
|
loadPresets();
|
|
|
|
var urlProjects = getUrlProjects();
|
|
|
|
var projectList = document.getElementById('projectList');
|
|
projectList.innerHTML = '<div class="project-list-loading">Loading projects…<\/div>';
|
|
|
|
try {
|
|
var resp = await fetch(location.origin + location.pathname.replace(/\/[^\/]*$/, '/'), {
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
accessibleProjects = await resp.json();
|
|
} catch (e) {
|
|
projectList.innerHTML = '<div class="project-list-empty">Could not load project list: ' + escapeHtml(e.message) + '<\/div>';
|
|
return;
|
|
}
|
|
|
|
// Warn about URL projects that are not accessible
|
|
if (urlProjects.size > 0) {
|
|
var missing = Array.from(urlProjects).filter(function(p) {
|
|
return !accessibleProjects.some(function(ap) { return ap.name === p; });
|
|
});
|
|
if (missing.length > 0) {
|
|
showWarning('This link includes projects you don\'t have access to: ' + missing.map(escapeHtml).join(', '));
|
|
}
|
|
}
|
|
|
|
renderProjects(urlProjects);
|
|
renderPresetMenu();
|
|
|
|
// Close preset menu on outside click
|
|
document.addEventListener('click', function(e) {
|
|
var menu = document.getElementById('presetMenu');
|
|
var btn = document.getElementById('presetMenuBtn');
|
|
if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
|
|
menu.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
function getUrlProjects() {
|
|
var params = new URLSearchParams(location.search);
|
|
var val = params.get('projects');
|
|
if (!val) return new Set();
|
|
return new Set(val.split(',').map(function(p) { return p.trim(); }).filter(Boolean));
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────────
|
|
|
|
function renderProjects(preCheck) {
|
|
var container = document.getElementById('projectList');
|
|
if (accessibleProjects.length === 0) {
|
|
container.innerHTML = '<div class="project-list-empty">No projects available.<\/div>';
|
|
return;
|
|
}
|
|
var html = accessibleProjects.map(function(p) {
|
|
var checked = (preCheck.size === 0 || preCheck.has(p.name)) ? ' checked' : '';
|
|
return '<div class="project-item" onclick="LandingApp.toggleProject(this)">' +
|
|
'<input type="checkbox" value="' + escapeHtml(p.name) + '"' + checked + ' onclick="event.stopPropagation()">' +
|
|
'<span class="project-item-name">' + escapeHtml(p.name) + '<\/span>' +
|
|
'<\/div>';
|
|
}).join('');
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function renderPresetMenu() {
|
|
var menu = document.getElementById('presetMenu');
|
|
if (!menu) return;
|
|
if (presets.length === 0) {
|
|
menu.innerHTML = '<div class="preset-menu-empty">No presets saved.<\/div>';
|
|
return;
|
|
}
|
|
menu.innerHTML = presets.map(function(preset) {
|
|
return '<div class="preset-menu-item">' +
|
|
'<span class="preset-menu-item-name" onclick="LandingApp.applyPreset(' + JSON.stringify(preset.name) + ')">' +
|
|
escapeHtml(preset.name) + '<\/span>' +
|
|
'<button class="preset-delete-btn" onclick="LandingApp.deletePreset(' + JSON.stringify(preset.name) + ')" title="Delete preset">×<\/button>' +
|
|
'<\/div>';
|
|
}).join('');
|
|
}
|
|
|
|
// ── Actions ──────────────────────────────────────────────────────────────
|
|
|
|
function selectAll() {
|
|
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
|
|
cb.checked = true;
|
|
});
|
|
}
|
|
|
|
function selectNone() {
|
|
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
|
|
cb.checked = false;
|
|
});
|
|
}
|
|
|
|
function toggleProject(row) {
|
|
var cb = row.querySelector('input[type="checkbox"]');
|
|
if (cb) cb.checked = !cb.checked;
|
|
}
|
|
|
|
function openArchive() {
|
|
var checked = Array.from(document.querySelectorAll('#projectList input[type="checkbox"]:checked'))
|
|
.map(function(cb) { return cb.value; });
|
|
if (checked.length === 0) {
|
|
alert('Select at least one project to open.');
|
|
return;
|
|
}
|
|
var base = location.pathname.replace(/\/[^\/]*$/, '/');
|
|
var params = ['projects=' + checked.map(encodeURIComponent).join(',')];
|
|
// Propagate ?v= (channel selector) so the archive page loads through
|
|
// the same level-2 bootstrap stub on the same channel as this landing.
|
|
var v = new URLSearchParams(location.search).get('v');
|
|
if (v) {
|
|
params.push('v=' + encodeURIComponent(v));
|
|
}
|
|
location.href = base + 'archive.html?' + params.join('&');
|
|
}
|
|
|
|
function savePreset() {
|
|
var name = prompt('Enter a name for this preset:');
|
|
if (!name || !name.trim()) return;
|
|
name = name.trim();
|
|
var checked = Array.from(document.querySelectorAll('#projectList input[type="checkbox"]:checked'))
|
|
.map(function(cb) { return cb.value; });
|
|
// Replace existing preset with same name
|
|
presets = presets.filter(function(p) { return p.name !== name; });
|
|
presets.push({ name: name, projects: checked });
|
|
savePresets();
|
|
renderPresetMenu();
|
|
}
|
|
|
|
function togglePresetMenu() {
|
|
var menu = document.getElementById('presetMenu');
|
|
if (menu) menu.classList.toggle('hidden');
|
|
}
|
|
|
|
function applyPreset(name) {
|
|
var preset = presets.find(function(p) { return p.name === name; });
|
|
if (!preset) return;
|
|
var projectSet = new Set(preset.projects);
|
|
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
|
|
cb.checked = projectSet.has(cb.value);
|
|
});
|
|
document.getElementById('presetMenu').classList.add('hidden');
|
|
}
|
|
|
|
function deletePreset(name) {
|
|
presets = presets.filter(function(p) { return p.name !== name; });
|
|
savePresets();
|
|
renderPresetMenu();
|
|
}
|
|
|
|
function dismissWarning() {
|
|
var el = document.getElementById('accessWarningBanner');
|
|
if (el) el.classList.add('hidden');
|
|
}
|
|
|
|
// ── Warning ───────────────────────────────────────────────────────────────
|
|
|
|
function showWarning(message) {
|
|
var el = document.getElementById('accessWarningBanner');
|
|
var txt = document.getElementById('accessWarningText');
|
|
if (!el || !txt) return;
|
|
txt.textContent = message;
|
|
el.classList.remove('hidden');
|
|
}
|
|
|
|
// ── Persistence ───────────────────────────────────────────────────────────
|
|
|
|
function loadPresets() {
|
|
try {
|
|
var raw = localStorage.getItem(PRESETS_KEY);
|
|
presets = raw ? JSON.parse(raw) : [];
|
|
if (!Array.isArray(presets)) presets = [];
|
|
} catch (e) {
|
|
presets = [];
|
|
}
|
|
}
|
|
|
|
function savePresets() {
|
|
try {
|
|
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
|
|
} catch (e) { /* quota exceeded or private browsing */ }
|
|
}
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
|
|
function escapeHtml(text) {
|
|
var div = document.createElement('div');
|
|
div.textContent = String(text);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
|
|
window.LandingApp = {
|
|
init: init,
|
|
selectAll: selectAll,
|
|
selectNone: selectNone,
|
|
toggleProject: toggleProject,
|
|
openArchive: openArchive,
|
|
savePreset: savePreset,
|
|
togglePresetMenu: togglePresetMenu,
|
|
applyPreset: applyPreset,
|
|
deletePreset: deletePreset,
|
|
dismissWarning: dismissWarning
|
|
};
|
|
|
|
})();
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|