From f01a177b73f7950951d0ee5f7e7347db144f7536 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 1 May 2026 15:23:26 -0500 Subject: [PATCH] feat(html): TIFF and ZIP listing previews + favicon in app headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds shared/preview-lib.js with two cross-tool renderers: - renderTiff (UTIF.js, lazy-loaded from CDN; PDF-style toolbar with page nav, zoom, fit-width/fit-page; multi-page TIFFs decode lazily) - renderZipListing (JSZip; sortable name/size/modified table, sticky header, host-grouped paths) Wired into the four tools that have a preview surface (archive, classifier, mdedit, transmittal). Cross-document compatible so the same renderer works for popup-window tools (archive/classifier/transmittal) and inline tools (mdedit). Archive previously had no image branch at all — now previews JPG/PNG/GIF/WebP/BMP/SVG natively, plus TIFF via UTIF, plus the ZIP listing. Adds the dark-blue rounded-square favicon to each app's header (left of the title) and to the website navigation. Single inline SVG, sized via .app-header__logo (in shared/base.css) for tools and .brand-logo (in website/css/style.css) for the website. Self-contained — the SVG carries its own background, no wrapper styling needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- archive/build.sh | 1 + archive/js/table.js | 125 +++++++- archive/template.html | 8 + classifier/build.sh | 1 + classifier/js/preview.js | 75 +++-- classifier/template.html | 8 + mdedit/build.sh | 1 + mdedit/js/file-system.js | 26 +- mdedit/js/file-tree.js | 109 ++++++- mdedit/template.html | 8 + shared/base.css | 10 + shared/preview-lib.js | 544 ++++++++++++++++++++++++++++++++ transmittal/build.sh | 1 + transmittal/js/files-preview.js | 72 ++++- transmittal/template.html | 8 + 15 files changed, 955 insertions(+), 42 deletions(-) create mode 100644 shared/preview-lib.js diff --git a/archive/build.sh b/archive/build.sh index 4f6d344..9d7d6cd 100644 --- a/archive/build.sh +++ b/archive/build.sh @@ -32,6 +32,7 @@ concat_files \ "../shared/zddc.js" \ "../shared/hash.js" \ "../shared/theme.js" \ + "../shared/preview-lib.js" \ "js/init.js" \ "js/parser.js" \ "js/source.js" \ diff --git a/archive/js/table.js b/archive/js/table.js index 5e9a5a2..2b34320 100644 --- a/archive/js/table.js +++ b/archive/js/table.js @@ -9,7 +9,17 @@ const processedLinks = new WeakSet(); let fileLinkHandlersAttached = false; let filePreviewWindow = null; - const PREVIEW_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'xls']; + // All extensions previewable in the popup. Image / tiff / zip / text routed + // through #previewContent below; pdf gets a direct iframe; docx/xlsx use + // dedicated lazy-loaded renderers. + const PREVIEW_EXTENSIONS = [ + 'pdf', + 'docx', 'xlsx', 'xls', + ...zddc.preview.IMAGE_EXTENSIONS, + ...zddc.preview.TIFF_EXTENSIONS, + ...zddc.preview.TEXT_EXTENSIONS, + 'zip' + ]; const loadedLibraries = new Map(); let resizing = null; @@ -445,6 +455,24 @@ } /* docx-preview container */ .docx-wrapper { padding: 1rem; } + /* Image preview */ + img.preview-image { + max-width: 100%; + max-height: 100%; + display: block; + margin: auto; + object-fit: contain; + } + /* Text preview */ + pre.preview-text { + padding: 1rem; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.85rem; + white-space: pre-wrap; + word-wrap: break-word; + color: var(--text); + background: var(--bg); + } /* xlsx table styling */ .xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; } .xlsx-table th, .xlsx-table td { @@ -529,8 +557,16 @@ await renderDocxInWindow(file); } else if (ext === 'xlsx' || ext === 'xls') { await renderXlsxInWindow(file); + } else if (zddc.preview.isTiff(ext)) { + await renderTiffInWindow(file); + } else if (zddc.preview.isZip(ext)) { + await renderZipInWindow(file); + } else if (zddc.preview.isImage(ext)) { + renderImageInWindow(file, url); + } else if (zddc.preview.isText(ext)) { + await renderTextInWindow(file); } - + } catch (err) { console.error('Error loading file preview:', err); alert(`Error loading preview: ${err.message}`); @@ -619,6 +655,91 @@ if (table) table.className = 'xlsx-table'; } + async function _getFileArrayBuffer(file) { + if (file.handle) { + const f = await file.handle.getFile(); + return f.arrayBuffer(); + } + if (file.url) { + const r = await fetch(file.url); + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.arrayBuffer(); + } + throw new Error('No file source available'); + } + + /** + * Render an image (non-tiff) directly using the popup's element. + * The browser handles decoding for jpg/jpeg/png/gif/webp/bmp/svg/ico natively. + */ + function renderImageInWindow(file, url) { + const container = filePreviewWindow.document.getElementById('previewContent'); + if (!container) return; + container.innerHTML = ''; + const img = filePreviewWindow.document.createElement('img'); + img.className = 'preview-image'; + img.src = url; + img.alt = file.name || ''; + container.appendChild(img); + } + + /** + * Render a TIFF using the shared zddc.preview.renderTiff helper (UTIF.js). + */ + async function renderTiffInWindow(file) { + const container = filePreviewWindow.document.getElementById('previewContent'); + if (!container) return; + try { + const arrayBuffer = await _getFileArrayBuffer(file); + await zddc.preview.renderTiff(filePreviewWindow.document, container, arrayBuffer, { + fileName: file.name + }); + } catch (err) { + console.error('Error rendering TIFF:', err); + container.innerHTML = `
Error rendering TIFF: ${err.message}
Click Download to view in another application.
`; + } + } + + /** + * Render a ZIP listing using the shared zddc.preview.renderZipListing helper. + */ + async function renderZipInWindow(file) { + const container = filePreviewWindow.document.getElementById('previewContent'); + if (!container) return; + try { + const arrayBuffer = await _getFileArrayBuffer(file); + await zddc.preview.renderZipListing(filePreviewWindow.document, container, arrayBuffer, { + fileName: file.name + }); + } catch (err) { + console.error('Error rendering ZIP listing:', err); + container.innerHTML = `
Error reading ZIP: ${err.message}
`; + } + } + + /** + * Render a text file as preformatted text. Truncates very large files to + * keep the popup responsive — users can Download to see the full file. + */ + async function renderTextInWindow(file) { + const container = filePreviewWindow.document.getElementById('previewContent'); + if (!container) return; + try { + const fileObj = file.handle ? await file.handle.getFile() : await fetch(file.url).then(r => r.blob()); + let text = await fileObj.text(); + const MAX = 200000; + if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated, ' + (text.length - MAX) + ' more chars — Download for full file)'; + container.innerHTML = ''; + const pre = filePreviewWindow.document.createElement('pre'); + pre.className = 'preview-text'; + pre.textContent = text; + container.appendChild(pre); + } catch (err) { + console.error('Error rendering text file:', err); + container.innerHTML = `
Error reading file: ${err.message}
`; + } + } + /** * Setup event delegation for file links * Left-click: Download file (or preview if PDF and preview mode enabled) diff --git a/archive/template.html b/archive/template.html index 27612e5..8fe4d90 100644 --- a/archive/template.html +++ b/archive/template.html @@ -20,6 +20,14 @@
+
ZDDC Archive {{BUILD_LABEL}} diff --git a/classifier/build.sh b/classifier/build.sh index c6e6463..5bd62f3 100644 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -30,6 +30,7 @@ concat_files \ "../shared/zddc.js" \ "../shared/hash.js" \ "../shared/theme.js" \ + "../shared/preview-lib.js" \ "js/app.js" \ "js/utils.js" \ "../shared/zddc-filter.js" \ diff --git a/classifier/js/preview.js b/classifier/js/preview.js index 6ef8e38..a955eec 100644 --- a/classifier/js/preview.js +++ b/classifier/js/preview.js @@ -10,31 +10,15 @@ let currentRowIndex = null; let previewWindow = null; - // File type mappings (extensions stored without leading dot, matching shared/zddc.js) - const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico']; - const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'html', 'css', 'js', 'ts', 'py', 'sh', 'bat', 'yaml', 'yml', 'ini', 'cfg', 'conf']; + // Use shared extension lists from window.zddc.preview where possible + const IMAGE_EXTENSIONS = zddc.preview.IMAGE_EXTENSIONS; + const TIFF_EXTENSIONS = zddc.preview.TIFF_EXTENSIONS; + const TEXT_EXTENSIONS = zddc.preview.TEXT_EXTENSIONS; const PDF_EXTENSIONS = ['pdf']; const ZIP_EXTENSIONS = ['zip']; - // Cache for lazily loaded CDN libraries - const loadedLibraries = new Map(); - - /** - * Lazily load a script from CDN. Returns a promise that resolves when loaded. - * Caches the promise so subsequent calls return immediately. - */ - function loadLibrary(url) { - if (loadedLibraries.has(url)) return loadedLibraries.get(url); - const promise = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = url; - script.onload = resolve; - script.onerror = () => reject(new Error(`Failed to load library: ${url}`)); - document.head.appendChild(script); - }); - loadedLibraries.set(url, promise); - return promise; - } + // Lazily load a script from CDN — delegates to shared cache. + const loadLibrary = zddc.preview.loadLibrary; /** * Initialize preview module @@ -265,12 +249,16 @@ previewWindow.focus(); } - // For office types, render content after window is ready + // For types that need decoding, render content after window is ready const ext = (file.extension || '').toLowerCase(); if (ext === 'docx') { await renderDocxInWindow(file); } else if (ext === 'xlsx' || ext === 'xls') { await renderXlsxInWindow(file); + } else if (TIFF_EXTENSIONS.includes(ext)) { + await renderTiffInWindow(file); + } else if (ZIP_EXTENSIONS.includes(ext)) { + await renderZipInWindow(file); } } catch (err) { console.error('Error opening preview:', err); @@ -299,6 +287,8 @@ return `
${escapeHtml(displayText)}
`; case 'docx': case 'xlsx': + case 'tiff': + case 'zip': return `
Loading preview...
`; default: return ` @@ -310,12 +300,13 @@ `; } } - + /** * Get preview type from extension */ function getPreviewType(ext) { if (PDF_EXTENSIONS.includes(ext)) return 'pdf'; + if (TIFF_EXTENSIONS.includes(ext)) return 'tiff'; if (IMAGE_EXTENSIONS.includes(ext)) return 'image'; if (TEXT_EXTENSIONS.includes(ext)) return 'text'; if (ext === 'docx') return 'docx'; @@ -452,6 +443,42 @@ if (table) table.className = 'xlsx-table'; } + /** + * Render a TIFF file in the preview window using shared zddc.preview.renderTiff + */ + async function renderTiffInWindow(file) { + const container = previewWindow.document.getElementById('previewContent'); + if (!container) return; + try { + const blob = await getFileBlob(file); + const arrayBuffer = await blob.arrayBuffer(); + await zddc.preview.renderTiff(previewWindow.document, container, arrayBuffer, { + fileName: zddc.joinExtension(file.originalFilename, file.extension) + }); + } catch (err) { + console.error('Error rendering TIFF:', err); + container.innerHTML = `
Error rendering TIFF: ${err.message}
Click Download to view in another application.
`; + } + } + + /** + * Render a ZIP listing in the preview window using shared zddc.preview.renderZipListing + */ + async function renderZipInWindow(file) { + const container = previewWindow.document.getElementById('previewContent'); + if (!container) return; + try { + const blob = await getFileBlob(file); + const arrayBuffer = await blob.arrayBuffer(); + await zddc.preview.renderZipListing(previewWindow.document, container, arrayBuffer, { + fileName: zddc.joinExtension(file.originalFilename, file.extension) + }); + } catch (err) { + console.error('Error rendering ZIP listing:', err); + container.innerHTML = `
Error reading ZIP: ${err.message}
`; + } + } + /** * Escape HTML for safe display */ diff --git a/classifier/template.html b/classifier/template.html index f897afa..41a3ebf 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -17,6 +17,14 @@
+
ZDDC Classifier {{BUILD_LABEL}} diff --git a/mdedit/build.sh b/mdedit/build.sh index 99b9845..4475c12 100644 --- a/mdedit/build.sh +++ b/mdedit/build.sh @@ -40,6 +40,7 @@ concat_files \ concat_files \ "../shared/zddc.js" \ "../shared/theme.js" \ + "../shared/preview-lib.js" \ "js/app.js" \ "js/utils.js" \ "js/front-matter.js" \ diff --git a/mdedit/js/file-system.js b/mdedit/js/file-system.js index 80a67b0..c83e7e5 100644 --- a/mdedit/js/file-system.js +++ b/mdedit/js/file-system.js @@ -150,6 +150,9 @@ async function openDirectory() { directoryHandle = await window.showDirectoryPicker(); if (DEBUG) console.log('Directory selected:', directoryHandle.name); + // Local picker wins over any active server-source mode. + serverSourceMode = false; + updateDirectoryStatus(directoryHandle.name); await readDirectory(directoryHandle); @@ -667,12 +670,26 @@ async function readServerDirectory(dirUrl, parentNode, depth) { */ async function loadServerDirectory() { if (!(location.protocol === 'http:' || location.protocol === 'https:')) return; - serverSourceMode = true; let href = window.location.href.split('?')[0].split('#')[0]; const lastSlash = href.lastIndexOf('/'); const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/'; + // Only enter server-source mode if the host actually serves JSON directory + // listings (zddc-server / Caddy). On a plain static host the probe fails + // and we must leave "Select Directory" visible so the user can still load + // local files. + try { + const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); + if (!resp.ok) return; + const items = await resp.json(); + if (!Array.isArray(items)) return; + } catch (_) { + return; + } + + serverSourceMode = true; + const rootName = (() => { const path = baseUrl.replace(/\/$/, ''); const seg = path.substring(path.lastIndexOf('/') + 1); @@ -686,15 +703,12 @@ async function loadServerDirectory() { entries: {}, }; - // Surface refresh, hide write-only controls + // Surface refresh, hide write-only controls. "Select Directory" stays + // visible so the user can switch to a local folder at any time. const refreshBtn = document.getElementById('refresh-directory'); if (refreshBtn) refreshBtn.classList.remove('hidden'); const newFileRootBtn = document.getElementById('new-file-root'); if (newFileRootBtn) newFileRootBtn.classList.add('hidden'); - const selectDirBtn = document.getElementById('select-directory'); - if (selectDirBtn) { - selectDirBtn.classList.add('hidden'); - } const stats = await readServerDirectory(baseUrl, fileTree, 0); renderFileTree(); diff --git a/mdedit/js/file-tree.js b/mdedit/js/file-tree.js index f179fd9..b6097a0 100644 --- a/mdedit/js/file-tree.js +++ b/mdedit/js/file-tree.js @@ -294,16 +294,25 @@ async function displayFileContent(fileHandle, filePath) { document.getElementById('welcome-screen').classList.add('hidden'); document.getElementById('content-container').classList.remove('hidden'); - const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']; - const isImage = imageExtensions.some(ext => fileName.toLowerCase().endsWith(ext)); + const lower = fileName.toLowerCase(); + const lastDot = lower.lastIndexOf('.'); + const ext = lastDot >= 0 ? lower.substring(lastDot + 1) : ''; - const isHtml = fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm'); - const isDocx = fileName.toLowerCase().endsWith('.docx'); - const isXlsx = fileName.toLowerCase().endsWith('.xlsx') || fileName.toLowerCase().endsWith('.xls'); - const isPdf = fileName.toLowerCase().endsWith('.pdf'); + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']; + const isImage = imageExtensions.some(e => lower.endsWith(e)); + const isTiff = window.zddc && window.zddc.preview && window.zddc.preview.isTiff(ext); + const isZip = lower.endsWith('.zip'); + const isHtml = lower.endsWith('.html') || lower.endsWith('.htm'); + const isDocx = lower.endsWith('.docx'); + const isXlsx = lower.endsWith('.xlsx') || lower.endsWith('.xls'); + const isPdf = lower.endsWith('.pdf'); if (isImage) { displayImagePreview(file, filePath, fileName, fileHandle, lastModified); + } else if (isTiff) { + displayTiffPreview(file, filePath, fileName, fileHandle, lastModified); + } else if (isZip) { + displayZipPreview(file, filePath, fileName, fileHandle, lastModified); } else if (isHtml) { displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified); } else if (isDocx) { @@ -387,6 +396,94 @@ async function displayImagePreview(file, filePath, fileName, fileHandle, lastMod editorInstances.set(filePath, instanceData); } +/** + * Display TIFF preview using shared zddc.preview.renderTiff (UTIF.js + canvas). + */ +async function displayTiffPreview(file, filePath, fileName, fileHandle, lastModified) { + const contentContainer = document.getElementById('content-container'); + if (!contentContainer) return; + + document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; }); + + if (editorInstances.has(filePath)) { + const existing = editorInstances.get(filePath); + if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex'; + return; + } + + const fileViewContainer = document.createElement('div'); + fileViewContainer.className = 'file-view-container flex flex-col h-full'; + + const fileHeader = document.createElement('div'); + fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; + const fileTitle = document.createElement('span'); + fileTitle.textContent = fileName || 'No file selected'; + fileHeader.appendChild(fileTitle); + fileViewContainer.appendChild(fileHeader); + + const tiffContainer = document.createElement('div'); + tiffContainer.className = 'flex-1 min-h-0'; + tiffContainer.style.display = 'flex'; + tiffContainer.style.flexDirection = 'column'; + fileViewContainer.appendChild(tiffContainer); + + contentContainer.appendChild(fileViewContainer); + + try { + const arrayBuffer = await file.arrayBuffer(); + await window.zddc.preview.renderTiff(document, tiffContainer, arrayBuffer, { fileName: fileName }); + } catch (err) { + console.error('Error rendering TIFF:', err); + tiffContainer.textContent = 'Error rendering TIFF: ' + (err.message || err); + } + + editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false }); +} + +/** + * Display ZIP listing using shared zddc.preview.renderZipListing. + */ +async function displayZipPreview(file, filePath, fileName, fileHandle, lastModified) { + const contentContainer = document.getElementById('content-container'); + if (!contentContainer) return; + + document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; }); + + if (editorInstances.has(filePath)) { + const existing = editorInstances.get(filePath); + if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex'; + return; + } + + const fileViewContainer = document.createElement('div'); + fileViewContainer.className = 'file-view-container flex flex-col h-full'; + + const fileHeader = document.createElement('div'); + fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; + const fileTitle = document.createElement('span'); + fileTitle.textContent = fileName || 'No file selected'; + fileHeader.appendChild(fileTitle); + fileViewContainer.appendChild(fileHeader); + + const zipContainer = document.createElement('div'); + zipContainer.className = 'flex-1 min-h-0'; + zipContainer.style.display = 'flex'; + zipContainer.style.flexDirection = 'column'; + fileViewContainer.appendChild(zipContainer); + + contentContainer.appendChild(fileViewContainer); + + try { + const arrayBuffer = await file.arrayBuffer(); + await window.zddc.preview.renderZipListing(document, zipContainer, arrayBuffer, { fileName: fileName }); + } catch (err) { + console.error('Error rendering ZIP listing:', err); + zipContainer.textContent = 'Error reading ZIP: ' + (err.message || err); + } + + editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false }); +} + /** * Display HTML preview in sandboxed iframe */ diff --git a/mdedit/template.html b/mdedit/template.html index 018de94..48808fb 100644 --- a/mdedit/template.html +++ b/mdedit/template.html @@ -17,6 +17,14 @@
+
ZDDC Markdown {{BUILD_LABEL}} diff --git a/shared/base.css b/shared/base.css index a0e0aa1..a3ac5b7 100644 --- a/shared/base.css +++ b/shared/base.css @@ -266,6 +266,16 @@ a:hover { 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; diff --git a/shared/preview-lib.js b/shared/preview-lib.js new file mode 100644 index 0000000..bde1861 --- /dev/null +++ b/shared/preview-lib.js @@ -0,0 +1,544 @@ +/** + * ZDDC — shared preview helpers + * + * Cross-tool helpers for previewing file types that need a decoder: + * - TIFF (UTIF.js) — multi-page, browser-PDF-viewer-style toolbar + * - ZIP listing (JSZip) — sortable file-list view + * + * Renderers operate on any document (parent window or popup window), so the + * same code works for tools whose preview opens in a popup (classifier, + * archive, transmittal) and tools that render inline (mdedit). + * + * Public API on window.zddc.preview: + * loadLibrary(url) → Promise + * renderTiff(doc, container, arrayBuffer, opts) → Promise + * renderZipListing(doc, container, arrayBuffer, opts) → Promise + * TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS + * isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext) + * + * Each tool keeps its own dispatcher; this lib only owns the heavy renderers. + */ + +(function (root) { + 'use strict'; + + var TIFF_EXTENSIONS = ['tif', 'tiff']; + var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico']; + var TEXT_EXTENSIONS = [ + 'txt', 'md', 'markdown', 'json', 'xml', 'csv', 'tsv', 'log', + 'html', 'htm', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx', + 'py', 'rb', 'sh', 'bash', 'zsh', 'bat', 'ps1', + 'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml', + 'c', 'cc', 'cpp', 'h', 'hpp', 'go', 'rs', 'java', 'kt', + 'sql', 'env' + ]; + var OFFICE_EXTENSIONS = ['docx', 'xlsx', 'xls']; + + function lowerExt(ext) { return (ext || '').toLowerCase(); } + function isTiff(ext) { return TIFF_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; } + function isImage(ext) { return IMAGE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; } + function isText(ext) { return TEXT_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; } + function isZip(ext) { return lowerExt(ext) === 'zip'; } + function isOffice(ext) { return OFFICE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; } + + // ── CDN library loader (parent window cache) ───────────────────────────── + + var _libCache = new Map(); + + function loadLibrary(url) { + if (_libCache.has(url)) return _libCache.get(url); + var p = new Promise(function (resolve, reject) { + var s = document.createElement('script'); + s.src = url; + s.onload = function () { resolve(); }; + s.onerror = function () { reject(new Error('Failed to load: ' + url)); }; + document.head.appendChild(s); + }); + _libCache.set(url, p); + return p; + } + + // ── Style injection (idempotent per-document) ──────────────────────────── + + function injectStyles(doc, id, css) { + if (doc.getElementById(id)) return; + var style = doc.createElement('style'); + style.id = id; + style.textContent = css; + doc.head.appendChild(style); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + function formatSize(bytes) { + if (bytes == null) return ''; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + } + + function formatDate(d) { + if (!d) return ''; + var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ── TIFF renderer ──────────────────────────────────────────────────────── + + var TIFF_CSS = + '.tiff-toolbar{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;' + + 'background:#f5f5f5;border-bottom:1px solid #ddd;flex-wrap:wrap;font-size:.85rem;}' + + '.tiff-toolbar .tiff-btn{padding:.25rem .55rem;border:1px solid #ccc;border-radius:3px;' + + 'background:#fff;cursor:pointer;font-size:.85rem;line-height:1;min-width:1.8rem;}' + + '.tiff-toolbar .tiff-btn:hover:not(:disabled){background:#e8e8e8;}' + + '.tiff-toolbar .tiff-btn:disabled{opacity:.4;cursor:default;}' + + '.tiff-toolbar .tiff-page-info{display:inline-flex;align-items:center;gap:.3rem;}' + + '.tiff-toolbar .tiff-page-input{width:3.2rem;padding:.2rem .3rem;border:1px solid #ccc;' + + 'border-radius:3px;text-align:center;font-size:.85rem;}' + + '.tiff-toolbar .tiff-zoom-select{padding:.2rem .3rem;border:1px solid #ccc;border-radius:3px;' + + 'background:#fff;font-size:.85rem;}' + + '.tiff-toolbar .tiff-spacer{flex:1;}' + + '.tiff-viewport{flex:1;overflow:auto;background:#525659;display:flex;align-items:flex-start;' + + 'justify-content:center;padding:1rem;}' + + '.tiff-canvas{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.4);display:block;' + + 'image-rendering:auto;}' + + '.tiff-error{flex:1;display:flex;align-items:center;justify-content:center;color:#900;' + + 'padding:2rem;text-align:center;}'; + + function renderTiff(doc, container, arrayBuffer, opts) { + opts = opts || {}; + injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS); + + return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () { + var ifds; + try { + ifds = window.UTIF.decode(arrayBuffer); + } catch (e) { + container.innerHTML = '
Failed to parse TIFF: ' + + escapeHtml(e.message || e) + '
'; + return; + } + if (!ifds || !ifds.length) { + container.innerHTML = '
No images found in TIFF.
'; + return; + } + + // Reset container to a flex column + container.innerHTML = ''; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.minHeight = '0'; + container.style.height = '100%'; + container.style.overflow = 'hidden'; + + // Toolbar + var toolbar = doc.createElement('div'); + toolbar.className = 'tiff-toolbar'; + + var btnPrev = doc.createElement('button'); + btnPrev.className = 'tiff-btn'; btnPrev.type = 'button'; + btnPrev.title = 'Previous page'; btnPrev.textContent = '◀'; + + var pageInfo = doc.createElement('span'); + pageInfo.className = 'tiff-page-info'; + var pageInput = doc.createElement('input'); + pageInput.type = 'number'; pageInput.min = '1'; pageInput.value = '1'; + pageInput.className = 'tiff-page-input'; + var pageOf = doc.createElement('span'); + pageOf.textContent = ' of ' + ifds.length; + pageInfo.appendChild(doc.createTextNode('Page ')); + pageInfo.appendChild(pageInput); + pageInfo.appendChild(pageOf); + + var btnNext = doc.createElement('button'); + btnNext.className = 'tiff-btn'; btnNext.type = 'button'; + btnNext.title = 'Next page'; btnNext.textContent = '▶'; + + var spacer = doc.createElement('span'); + spacer.className = 'tiff-spacer'; + + var btnZoomOut = doc.createElement('button'); + btnZoomOut.className = 'tiff-btn'; btnZoomOut.type = 'button'; + btnZoomOut.title = 'Zoom out'; btnZoomOut.textContent = '−'; + + var zoomSelect = doc.createElement('select'); + zoomSelect.className = 'tiff-zoom-select'; + var zoomOptions = [ + ['fit-width', 'Fit width'], + ['fit-page', 'Fit page'], + ['0.5', '50%'], + ['0.75', '75%'], + ['1', '100%'], + ['1.25', '125%'], + ['1.5', '150%'], + ['2', '200%'], + ['3', '300%'], + ['4', '400%'] + ]; + zoomOptions.forEach(function (z) { + var o = doc.createElement('option'); + o.value = z[0]; o.textContent = z[1]; + zoomSelect.appendChild(o); + }); + zoomSelect.value = 'fit-width'; + + var btnZoomIn = doc.createElement('button'); + btnZoomIn.className = 'tiff-btn'; btnZoomIn.type = 'button'; + btnZoomIn.title = 'Zoom in'; btnZoomIn.textContent = '+'; + + toolbar.appendChild(btnPrev); + toolbar.appendChild(pageInfo); + toolbar.appendChild(btnNext); + toolbar.appendChild(spacer); + toolbar.appendChild(btnZoomOut); + toolbar.appendChild(zoomSelect); + toolbar.appendChild(btnZoomIn); + + // Viewport with canvas + var viewport = doc.createElement('div'); + viewport.className = 'tiff-viewport'; + var canvas = doc.createElement('canvas'); + canvas.className = 'tiff-canvas'; + viewport.appendChild(canvas); + + container.appendChild(toolbar); + container.appendChild(viewport); + + // Render state + var currentPage = 0; + var zoom = 1; + var fitMode = 'width'; // 'width' | 'page' | null + var decoded = new Array(ifds.length); + + function decodePage(i) { + if (decoded[i]) return decoded[i]; + var ifd = ifds[i]; + window.UTIF.decodeImage(arrayBuffer, ifd); + var rgba = window.UTIF.toRGBA8(ifd); + decoded[i] = { rgba: rgba, w: ifd.width, h: ifd.height }; + return decoded[i]; + } + + function applyZoom() { + var page = decoded[currentPage]; + if (!page) return; + var availW = viewport.clientWidth - 32; // padding + var availH = viewport.clientHeight - 32; + var scale; + if (fitMode === 'width') { + scale = availW / page.w; + } else if (fitMode === 'page') { + scale = Math.min(availW / page.w, availH / page.h); + } else { + scale = zoom; + } + if (!isFinite(scale) || scale <= 0) scale = 1; + canvas.style.width = (page.w * scale) + 'px'; + canvas.style.height = (page.h * scale) + 'px'; + } + + function renderPage() { + var page; + try { + page = decodePage(currentPage); + } catch (e) { + container.innerHTML = '
Failed to decode page ' + + (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '
'; + return; + } + canvas.width = page.w; + canvas.height = page.h; + var ctx = canvas.getContext('2d'); + var imgData = ctx.createImageData(page.w, page.h); + imgData.data.set(page.rgba); + ctx.putImageData(imgData, 0, 0); + applyZoom(); + pageInput.value = String(currentPage + 1); + btnPrev.disabled = currentPage <= 0; + btnNext.disabled = currentPage >= ifds.length - 1; + } + + function setZoomFromSelect() { + var v = zoomSelect.value; + if (v === 'fit-width') { fitMode = 'width'; } + else if (v === 'fit-page') { fitMode = 'page'; } + else { fitMode = null; zoom = parseFloat(v) || 1; } + applyZoom(); + } + + function nudgeZoom(factor) { + if (fitMode) { + // capture current effective scale before leaving fit mode + var page = decoded[currentPage]; + if (page) { + var availW = viewport.clientWidth - 32; + var availH = viewport.clientHeight - 32; + zoom = fitMode === 'width' + ? availW / page.w + : Math.min(availW / page.w, availH / page.h); + } else { + zoom = 1; + } + fitMode = null; + } + zoom = Math.max(0.1, Math.min(8, zoom * factor)); + // Match select option if any are close, else show as percent + var matched = false; + for (var i = 0; i < zoomSelect.options.length; i++) { + var ov = zoomSelect.options[i].value; + if (ov !== 'fit-width' && ov !== 'fit-page' && Math.abs(parseFloat(ov) - zoom) < 0.001) { + zoomSelect.value = ov; matched = true; break; + } + } + if (!matched) { + // Nearest standard step + var best = '1', bestDiff = Infinity; + for (var j = 0; j < zoomSelect.options.length; j++) { + var v2 = zoomSelect.options[j].value; + if (v2 === 'fit-width' || v2 === 'fit-page') continue; + var diff = Math.abs(parseFloat(v2) - zoom); + if (diff < bestDiff) { bestDiff = diff; best = v2; } + } + zoom = parseFloat(best); + zoomSelect.value = best; + } + applyZoom(); + } + + btnPrev.addEventListener('click', function () { + if (currentPage > 0) { currentPage--; renderPage(); } + }); + btnNext.addEventListener('click', function () { + if (currentPage < ifds.length - 1) { currentPage++; renderPage(); } + }); + pageInput.addEventListener('change', function () { + var n = parseInt(pageInput.value, 10); + if (!isNaN(n) && n >= 1 && n <= ifds.length) { + currentPage = n - 1; + renderPage(); + } else { + pageInput.value = String(currentPage + 1); + } + }); + zoomSelect.addEventListener('change', setZoomFromSelect); + btnZoomIn.addEventListener('click', function () { nudgeZoom(1.25); }); + btnZoomOut.addEventListener('click', function () { nudgeZoom(1 / 1.25); }); + + // Keyboard nav (only when toolbar/viewport in focus path) + container.tabIndex = 0; + container.addEventListener('keydown', function (e) { + if (e.target === pageInput) return; + if (e.key === 'ArrowLeft' || e.key === 'PageUp') { + if (currentPage > 0) { currentPage--; renderPage(); e.preventDefault(); } + } else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { + if (currentPage < ifds.length - 1) { currentPage++; renderPage(); e.preventDefault(); } + } + }); + + // Re-fit on viewport resize + if (typeof (doc.defaultView && doc.defaultView.ResizeObserver) === 'function') { + var ro = new doc.defaultView.ResizeObserver(function () { applyZoom(); }); + ro.observe(viewport); + } else if (doc.defaultView) { + doc.defaultView.addEventListener('resize', function () { applyZoom(); }); + } + + renderPage(); + }); + } + + // ── ZIP listing renderer ───────────────────────────────────────────────── + + var ZIP_CSS = + '.zip-header{padding:.4rem .8rem;background:#f5f5f5;border-bottom:1px solid #ddd;' + + 'font-size:.85rem;color:#444;}' + + '.zip-table-wrap{flex:1;overflow:auto;}' + + '.zip-table{width:100%;border-collapse:collapse;font-size:.85rem;font-family:' + + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' + + '.zip-table thead th{position:sticky;top:0;background:#f0f0f0;text-align:left;' + + 'padding:.4rem .6rem;border-bottom:1px solid #ccc;cursor:pointer;user-select:none;' + + 'font-weight:600;}' + + '.zip-table thead th:hover{background:#e6e6e6;}' + + '.zip-table thead th.zip-sort-asc::after{content:" ▲";font-size:.7rem;color:#888;}' + + '.zip-table thead th.zip-sort-desc::after{content:" ▼";font-size:.7rem;color:#888;}' + + '.zip-table tbody td{padding:.3rem .6rem;border-bottom:1px solid #eee;}' + + '.zip-table tbody tr:hover{background:#f6faff;}' + + '.zip-table .zip-folder{color:#888;}' + + '.zip-table .zip-name{color:#222;}' + + '.zip-table .zip-size,.zip-table .zip-date{font-variant-numeric:tabular-nums;' + + 'white-space:nowrap;color:#555;}' + + '.zip-table .zip-col-size,.zip-table .zip-col-date{text-align:right;}' + + '.zip-empty{padding:2rem;text-align:center;color:#888;}'; + + function renderZipListing(doc, container, arrayBuffer, opts) { + opts = opts || {}; + injectStyles(doc, 'zddc-zip-styles', ZIP_CSS); + + return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () { + return window.JSZip.loadAsync(arrayBuffer); + }).then(function (zip) { + var entries = []; + zip.forEach(function (relativePath, zipEntry) { + if (zipEntry.dir) return; + var size = (zipEntry._data && zipEntry._data.uncompressedSize) || 0; + entries.push({ + path: relativePath, + name: relativePath.split('/').pop(), + size: size, + modified: zipEntry.date instanceof Date ? zipEntry.date : null + }); + }); + + container.innerHTML = ''; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.minHeight = '0'; + container.style.height = '100%'; + container.style.overflow = 'hidden'; + + var totalSize = entries.reduce(function (s, e) { return s + e.size; }, 0); + + var header = doc.createElement('div'); + header.className = 'zip-header'; + header.textContent = entries.length + ' file' + (entries.length === 1 ? '' : 's') + + (totalSize ? ' · ' + formatSize(totalSize) + ' uncompressed' : ''); + container.appendChild(header); + + if (!entries.length) { + var empty = doc.createElement('div'); + empty.className = 'zip-empty'; + empty.textContent = '(empty archive)'; + container.appendChild(empty); + return; + } + + var wrap = doc.createElement('div'); + wrap.className = 'zip-table-wrap'; + + var table = doc.createElement('table'); + table.className = 'zip-table'; + var thead = doc.createElement('thead'); + var trh = doc.createElement('tr'); + var cols = [ + { key: 'path', label: 'Name', cls: 'zip-col-name' }, + { key: 'size', label: 'Size', cls: 'zip-col-size' }, + { key: 'modified', label: 'Modified', cls: 'zip-col-date' } + ]; + cols.forEach(function (c) { + var th = doc.createElement('th'); + th.className = c.cls; + th.dataset.key = c.key; + th.textContent = c.label; + trh.appendChild(th); + }); + thead.appendChild(trh); + table.appendChild(thead); + + var tbody = doc.createElement('tbody'); + table.appendChild(tbody); + + wrap.appendChild(table); + container.appendChild(wrap); + + var sortKey = 'path'; + var sortDir = 1; + + function render() { + var sorted = entries.slice().sort(function (a, b) { + var av, bv; + if (sortKey === 'size') { av = a.size; bv = b.size; } + else if (sortKey === 'modified') { + av = a.modified ? a.modified.getTime() : 0; + bv = b.modified ? b.modified.getTime() : 0; + } else { + av = a.path.toLowerCase(); bv = b.path.toLowerCase(); + } + if (av < bv) return -1 * sortDir; + if (av > bv) return 1 * sortDir; + return 0; + }); + + tbody.innerHTML = ''; + sorted.forEach(function (e) { + var tr = doc.createElement('tr'); + var td1 = doc.createElement('td'); + var slash = e.path.lastIndexOf('/'); + if (slash >= 0) { + var folder = doc.createElement('span'); + folder.className = 'zip-folder'; + folder.textContent = e.path.substring(0, slash + 1); + td1.appendChild(folder); + } + var name = doc.createElement('span'); + name.className = 'zip-name'; + name.textContent = e.name; + td1.appendChild(name); + + var td2 = doc.createElement('td'); + td2.className = 'zip-size'; + td2.textContent = formatSize(e.size); + + var td3 = doc.createElement('td'); + td3.className = 'zip-date'; + td3.textContent = formatDate(e.modified); + + tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); + tbody.appendChild(tr); + }); + + // Update sort arrows + var ths = thead.querySelectorAll('th'); + for (var i = 0; i < ths.length; i++) { + ths[i].classList.remove('zip-sort-asc', 'zip-sort-desc'); + if (ths[i].dataset.key === sortKey) { + ths[i].classList.add(sortDir > 0 ? 'zip-sort-asc' : 'zip-sort-desc'); + } + } + } + + thead.querySelectorAll('th').forEach(function (th) { + th.addEventListener('click', function () { + var k = th.dataset.key; + if (sortKey === k) sortDir = -sortDir; + else { sortKey = k; sortDir = 1; } + render(); + }); + }); + + render(); + }).catch(function (err) { + container.innerHTML = '
Failed to read ZIP: ' + + escapeHtml(err.message || err) + '
'; + }); + } + + // ── Public API ─────────────────────────────────────────────────────────── + + if (!root.zddc) root.zddc = {}; + root.zddc.preview = { + TIFF_EXTENSIONS: TIFF_EXTENSIONS, + IMAGE_EXTENSIONS: IMAGE_EXTENSIONS, + TEXT_EXTENSIONS: TEXT_EXTENSIONS, + OFFICE_EXTENSIONS: OFFICE_EXTENSIONS, + isTiff: isTiff, + isImage: isImage, + isText: isText, + isZip: isZip, + isOffice: isOffice, + loadLibrary: loadLibrary, + renderTiff: renderTiff, + renderZipListing: renderZipListing, + formatSize: formatSize, + formatDate: formatDate + }; +})(typeof window !== 'undefined' ? window : this); diff --git a/transmittal/build.sh b/transmittal/build.sh index 243f7a6..b192e4c 100755 --- a/transmittal/build.sh +++ b/transmittal/build.sh @@ -41,6 +41,7 @@ concat_files \ "../shared/zddc.js" \ "../shared/hash.js" \ "../shared/theme.js" \ + "../shared/preview-lib.js" \ "js/app.js" \ "js/reactive.js" \ "js/dom.js" \ diff --git a/transmittal/js/files-preview.js b/transmittal/js/files-preview.js index c2b29b7..59c8525 100644 --- a/transmittal/js/files-preview.js +++ b/transmittal/js/files-preview.js @@ -11,11 +11,15 @@ // Current preview popup window reference var previewWindow = null; - // Extensions that support rich in-browser preview + // Extensions that support rich in-browser preview (in addition to images, + // tiff, zip, and text — wired up in isPreviewable below). var PREVIEW_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'xls']; - // Extensions that preview as images - var IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico']; + // Use shared image / tiff / text lists from zddc.preview so the four tools + // stay in sync on what is previewable. + var IMAGE_EXTENSIONS = window.zddc.preview.IMAGE_EXTENSIONS; + var TIFF_EXTENSIONS = window.zddc.preview.TIFF_EXTENSIONS; + var TEXT_EXTENSIONS = window.zddc.preview.TEXT_EXTENSIONS; // Cache for lazily loaded CDN libraries var loadedLibraries = new Map(); @@ -72,7 +76,11 @@ function isPreviewable(ext) { var lower = (ext || '').toLowerCase(); - return PREVIEW_EXTENSIONS.indexOf(lower) !== -1 || IMAGE_EXTENSIONS.indexOf(lower) !== -1; + return PREVIEW_EXTENSIONS.indexOf(lower) !== -1 + || IMAGE_EXTENSIONS.indexOf(lower) !== -1 + || TIFF_EXTENSIONS.indexOf(lower) !== -1 + || TEXT_EXTENSIONS.indexOf(lower) !== -1 + || lower === 'zip'; } function hasFileSource(file) { @@ -147,6 +155,7 @@ '.sheet-tab:hover { background: #e8e8e8; }\n' + '.sheet-tab.active { background: white; border-color: #ddd; border-bottom-color: white; margin-bottom: -1px; font-weight: 500; }\n' + 'img.preview-image { max-width: 100%; max-height: 100%; object-fit: contain; margin: auto; display: block; }\n' + + 'pre.preview-text { padding: 1rem; font-family: Consolas, Monaco, monospace; font-size: .85rem; white-space: pre-wrap; word-wrap: break-word; }\n' + '\n' + '\n' + '\n' + @@ -253,6 +262,55 @@ container.appendChild(img); } + async function renderTiffInWindow(file) { + var container = previewWindow.document.getElementById('previewContent'); + if (!container) return; + try { + var arrayBuffer = await getFileArrayBuffer(file); + await window.zddc.preview.renderTiff(previewWindow.document, container, arrayBuffer, { + fileName: file.name + }); + } catch (err) { + console.error('[transmittal] Error rendering TIFF:', err); + container.innerHTML = '
Error rendering TIFF: ' + util.escapeHtml(err.message || '') + '
Click Download to view in another application.
'; + } + } + + async function renderZipInWindow(file) { + var container = previewWindow.document.getElementById('previewContent'); + if (!container) return; + try { + var arrayBuffer = await getFileArrayBuffer(file); + await window.zddc.preview.renderZipListing(previewWindow.document, container, arrayBuffer, { + fileName: file.name + }); + } catch (err) { + console.error('[transmittal] Error rendering ZIP listing:', err); + container.innerHTML = '
Error reading ZIP: ' + util.escapeHtml(err.message || '') + '
'; + } + } + + async function renderTextInWindow(file) { + var container = previewWindow.document.getElementById('previewContent'); + if (!container) return; + try { + var arrayBuffer = await getFileArrayBuffer(file); + var text = new TextDecoder('utf-8', { fatal: false }).decode(arrayBuffer); + var MAX = 200000; + if (text.length > MAX) { + text = text.substring(0, MAX) + '\n\n... (truncated, ' + (text.length - MAX) + ' more chars — Download for full file)'; + } + container.innerHTML = ''; + var pre = previewWindow.document.createElement('pre'); + pre.className = 'preview-text'; + pre.textContent = text; + container.appendChild(pre); + } catch (err) { + console.error('[transmittal] Error reading text file:', err); + container.innerHTML = '
Error reading file: ' + util.escapeHtml(err.message || '') + '
'; + } + } + async function showFilePreview(file) { var ext = (file.extension || '').toLowerCase(); try { @@ -284,8 +342,14 @@ await renderDocxInWindow(file); } else if (ext === 'xlsx' || ext === 'xls') { await renderXlsxInWindow(file); + } else if (TIFF_EXTENSIONS.indexOf(ext) !== -1) { + await renderTiffInWindow(file); + } else if (ext === 'zip') { + await renderZipInWindow(file); } else if (IMAGE_EXTENSIONS.indexOf(ext) !== -1) { await renderImageInWindow(file, url); + } else if (TEXT_EXTENSIONS.indexOf(ext) !== -1) { + await renderTextInWindow(file); } } catch (err) { console.error('[transmittal] Error loading file preview:', err); diff --git a/transmittal/template.html b/transmittal/template.html index bfda20b..fdeeb51 100644 --- a/transmittal/template.html +++ b/transmittal/template.html @@ -34,6 +34,14 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
JavaScript not available +
ZDDC Transmittal {{BUILD_LABEL}}